@goodandready/dsh-subscriptions 0.6.57 → 0.6.59
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/README.md +10 -0
- package/README.ru.md +10 -0
- package/README.zh.md +9 -0
- package/lib/accounts.js +2 -2
- package/lib/alerts.js +55 -0
- package/lib/client.js +153 -28
- package/lib/config-schema.js +2 -0
- package/lib/quarantine.js +3 -0
- package/lib/routes/accounts.js +115 -1
- package/lib/stream-rotate.js +13 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -145,6 +145,9 @@ const res = await ctx.subscriptions.request('codex', '/backend-api/codex/images/
|
|
|
145
145
|
| `/dsh-subscriptions/proxy-check` | POST | Latency check of a slot's proxy against its vendor base URL |
|
|
146
146
|
| `/dsh-subscriptions/oauth/device/start` | POST | Begin Codex device-code login (returns user code + verification URL) |
|
|
147
147
|
| `/dsh-subscriptions/oauth/device/poll` | POST | Poll device-code authorization status |
|
|
148
|
+
| `/dsh-subscriptions/quarantine/park` | POST | Park an account into quarantine for custom duration (`durationMs`, `reason`) |
|
|
149
|
+
| `/dsh-subscriptions/quarantine/probe` | POST | Run immediate live warmup health probe or force-release account |
|
|
150
|
+
| `/dsh-subscriptions/alerts` | GET | List recent in-app alerts and notifications |
|
|
148
151
|
|
|
149
152
|
---
|
|
150
153
|
|
|
@@ -193,6 +196,12 @@ const res = await ctx.subscriptions.request('codex', '/backend-api/codex/images/
|
|
|
193
196
|
* **Curated Models Catalog**: Quick preview of supported model families with context windows and capability tags (Vision, Reasoning, Hybrid).
|
|
194
197
|
* **Non-Blocking Storage**: `HistoryStore` debounces disk persistence asynchronously to prevent blocking the Node.js event loop during high-throughput streaming.
|
|
195
198
|
|
|
199
|
+
### 17. 🛡️ Quarantine Management, Live Quota Countdown & In-App Toast Alerts (v0.6.59)
|
|
200
|
+
* **Manual Quarantine Control (Park & Probe)**: Directly park active accounts in quarantine (`POST /dsh-subscriptions/quarantine/park`) for maintenance or pause, or trigger an immediate live warmup probe and instant release (`POST /dsh-subscriptions/quarantine/probe`) right from the account slot card.
|
|
201
|
+
* **Dynamic Quota Window Countdown**: Live second-by-second countdown timer displayed in the `SubsPill` session widget and account settings cards, showing exact time remaining until rolling rate-limit windows (`five_hour`, `seven_day`, `primary_window`) reset.
|
|
202
|
+
* **In-App Toast Alerts (`enableToasts`)**: Lightweight floating toast alerts in the DSH chat interface notifying you when accounts enter quarantine, recover from cooldowns, or exceed quota safety thresholds.
|
|
203
|
+
* **Automated Webhook Integration**: Instant event notifications dispatched via `notifyQuarantineEntered` and `notifyQuarantineReleased`.
|
|
204
|
+
|
|
196
205
|
---
|
|
197
206
|
|
|
198
207
|
## 📦 Quick Installation
|
|
@@ -226,6 +235,7 @@ dsh-subscriptions:
|
|
|
226
235
|
codexVerbosity: '' # v0.4.17: low | medium | high (text.verbosity)
|
|
227
236
|
codexFastMode: false # v0.4.17: service_tier priority (1.5x speed tier)
|
|
228
237
|
composerQuota: 'off' # v0.4.18: composer indicator: off | percent | bar | forecast
|
|
238
|
+
enableToasts: true # v0.6.59: real-time in-app toast alerts on quarantine and quota events
|
|
229
239
|
# Per-slot fields (v0.4.9): expiresAt (ms epoch), proxyUrl (http/https/socks5://)
|
|
230
240
|
accounts:
|
|
231
241
|
codex:
|
package/README.ru.md
CHANGED
|
@@ -145,6 +145,9 @@ const res = await ctx.subscriptions.request('codex', '/backend-api/codex/images/
|
|
|
145
145
|
| `/dsh-subscriptions/proxy-check` | POST | Проверка задержки прокси слота через базовый URL вендора |
|
|
146
146
|
| `/dsh-subscriptions/oauth/device/start` | POST | Начать вход Codex по коду устройства (возвращает код и адрес подтверждения) |
|
|
147
147
|
| `/dsh-subscriptions/oauth/device/poll` | POST | Опрос статуса авторизации по коду устройства |
|
|
148
|
+
| `/dsh-subscriptions/quarantine/park` | POST | Временная парковка аккаунта в карантин (`durationMs`, `reason`) |
|
|
149
|
+
| `/dsh-subscriptions/quarantine/probe` | POST | Немедленный запуск прогревочного зонда или досрочный вывод из карантина |
|
|
150
|
+
| `/dsh-subscriptions/alerts` | GET | Список последних внутренних оповещений и уведомлений |
|
|
148
151
|
|
|
149
152
|
---
|
|
150
153
|
|
|
@@ -169,6 +172,12 @@ const res = await ctx.subscriptions.request('codex', '/backend-api/codex/images/
|
|
|
169
172
|
* **Каталог моделей подписок**: Компактный список поддерживаемых моделей с размером контекста (128k, 200k, 1M) и тегами возможностей.
|
|
170
173
|
* **Асинхронная история**: Неблокирующий дебаунсинг записи `HistoryStore`, исключающий просадки event loop при интенсивном стриминге.
|
|
171
174
|
|
|
175
|
+
### 13. 🛡️ Управление карантином, живой обратный отсчет квот и всплывающие оповещения (v0.6.59)
|
|
176
|
+
* **Ручное управление карантином (Park & Probe)**: Возможность временно отправить аккаунт в карантин (`POST /dsh-subscriptions/quarantine/park`) для обслуживания или паузы, либо запустить немедленный прогревочный микро-зонд (`vendor.check`) и моментально вывести аккаунт из карантина (`POST /dsh-subscriptions/quarantine/probe`) прямо из карточки в настройках.
|
|
177
|
+
* **Динамический посекундный таймер сброса окон квот**: Интерактивный таймер в виджете сессии `SubsPill` и в карточках аккаунтов, рассчитывающий точное оставшееся время до разблокировки скользящих лимитов (`five_hour`, `seven_day`, `primary_window`).
|
|
178
|
+
* **Всплывающие уведомления (Toast Alerts)**: Легковесные всплывающие тосты в веб-интерфейсе DSH при событиях карантина (с отображением причины), восстановлении доступа и критическом расходе квоты. Включение управляется параметром `enableToasts` (по умолчанию `true`).
|
|
179
|
+
* **Интеграция с вебхуками**: Автоматическая отправка уведомлений через `notifyQuarantineEntered` и `notifyQuarantineReleased`.
|
|
180
|
+
|
|
172
181
|
---
|
|
173
182
|
|
|
174
183
|
## 📦 Быстрая установка
|
|
@@ -199,6 +208,7 @@ dsh-subscriptions:
|
|
|
199
208
|
codexVerbosity: '' # v0.4.17: low | medium | high (text.verbosity)
|
|
200
209
|
codexFastMode: false # v0.4.17: service_tier priority (скоростной тир 1.5x)
|
|
201
210
|
composerQuota: 'off' # v0.4.18: индикатор в строке ввода: off | percent | bar | forecast
|
|
211
|
+
enableToasts: true # v0.6.59: всплывающие уведомления при событиях карантина и квот
|
|
202
212
|
# Поля слота (v0.4.9): expiresAt (ms), proxyUrl (http/https/socks5://)
|
|
203
213
|
accounts:
|
|
204
214
|
codex:
|
package/README.zh.md
CHANGED
|
@@ -120,6 +120,12 @@ graph LR
|
|
|
120
120
|
* **匿名诊断报告**:设置卡片内一键生成(插件/运行时版本、系统、各供应商健康状态、HTTP 状态聚合、最近错误与耗时、非敏感配置)并自动复制到剪贴板;令牌、邮箱、凭据名与代理地址严格排除。
|
|
121
121
|
* 同区块提供问题追踪器链接,便于提交 issue。
|
|
122
122
|
|
|
123
|
+
### 隔离管理、动态额度重置倒计时与应用内弹窗提醒 (v0.6.59)
|
|
124
|
+
* **手动隔离管理(暂停与预热探测)**:支持在设置面板的账户卡片中一键暂停隔离活跃账户(`POST /dsh-subscriptions/quarantine/park`),或立即触发真实上游预热微探测(`probeWarmup`)并在验证成功后即时解除隔离(`POST /dsh-subscriptions/quarantine/probe`)。
|
|
125
|
+
* **动态额度重置倒计时**:在顶部会话栏 `SubsPill` 和账户卡片中秒级动态更新,精准展示滚动配额窗口(`five_hour`、`seven_day`、`primary_window`)的剩余解锁时间。
|
|
126
|
+
* **应用内即时弹窗提醒(Toast)**:当账户进入隔离、健康恢复解除隔离或额度达到预警线时,在聊天界面弹出轻量级浮动提醒。通过 `enableToasts: true` 控制。
|
|
127
|
+
* **自动化 Webhook 告警集成**:通过 `notifyQuarantineEntered` 与 `notifyQuarantineReleased` 自动向外部通知系统派发隔离生命周期事件。
|
|
128
|
+
|
|
123
129
|
### HTTP API 路由(`v0.4.9`)
|
|
124
130
|
| 路由 | 方法 | 用途 |
|
|
125
131
|
|---|---|---|
|
|
@@ -127,6 +133,9 @@ graph LR
|
|
|
127
133
|
| `/dsh-subscriptions/proxy-check` | POST | 检测槽位代理到供应商基础地址的延迟 |
|
|
128
134
|
| `/dsh-subscriptions/oauth/device/start` | POST | 发起 Codex 设备码登录(返回用户码与验证地址) |
|
|
129
135
|
| `/dsh-subscriptions/oauth/device/poll` | POST | 轮询设备码授权状态 |
|
|
136
|
+
| `/dsh-subscriptions/quarantine/park` | POST | 将指定账户临时暂停隔离(支持 `durationMs` 与 `reason`) |
|
|
137
|
+
| `/dsh-subscriptions/quarantine/probe` | POST | 立即执行健康预热探测或提前解除账户隔离 |
|
|
138
|
+
| `/dsh-subscriptions/alerts` | GET | 获取近期应用内告警与通知列表 |
|
|
130
139
|
|
|
131
140
|
---
|
|
132
141
|
|
package/lib/accounts.js
CHANGED
|
@@ -515,11 +515,11 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
|
|
|
515
515
|
}
|
|
516
516
|
return q
|
|
517
517
|
},
|
|
518
|
-
async probeWarmup(ref, probeFn) {
|
|
518
|
+
async probeWarmup(ref, probeFn, force = false) {
|
|
519
519
|
const q = quarantines.get(ref)
|
|
520
520
|
if (!q) return { success: true }
|
|
521
521
|
const acc = { quarantineReason: q.reason, quarantineUntil: q.until, quarantineAttempts: q.attempts || 1, status: q.status }
|
|
522
|
-
if (!needsWarmupProbe(acc)) {
|
|
522
|
+
if (!force && !needsWarmupProbe(acc)) {
|
|
523
523
|
return { success: false, reason: 'NOT_EXPIRED' }
|
|
524
524
|
}
|
|
525
525
|
quarantines.set(ref, enterProbing(acc))
|
package/lib/alerts.js
CHANGED
|
@@ -154,3 +154,58 @@ export function notifySessionExpired({
|
|
|
154
154
|
}
|
|
155
155
|
return alert
|
|
156
156
|
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* #509 & #510: Fires an alert when an account enters quarantine.
|
|
160
|
+
*/
|
|
161
|
+
export function notifyQuarantineEntered({
|
|
162
|
+
ref,
|
|
163
|
+
provider,
|
|
164
|
+
reason,
|
|
165
|
+
until,
|
|
166
|
+
webhookUrl = "",
|
|
167
|
+
fetchImpl = fetch,
|
|
168
|
+
nowMs = Date.now(),
|
|
169
|
+
}) {
|
|
170
|
+
const untilIso = until ? new Date(until).toISOString() : ""
|
|
171
|
+
const alert = recordAlert({
|
|
172
|
+
type: "quarantine_entered",
|
|
173
|
+
ref,
|
|
174
|
+
provider,
|
|
175
|
+
message: "Account " + (provider || "slot") + " (" + (ref || "") + ") entered quarantine (" + (reason || "RATE_LIMIT") + ")" + (untilIso ? " until " + untilIso : ""),
|
|
176
|
+
severity: "warn",
|
|
177
|
+
details: { reason, until },
|
|
178
|
+
nowMs,
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
if (alert && webhookUrl) {
|
|
182
|
+
dispatchWebhookAlert(alert, webhookUrl, fetchImpl).catch(() => {})
|
|
183
|
+
}
|
|
184
|
+
return alert
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* #509 & #510: Fires an alert when an account is released from quarantine.
|
|
189
|
+
*/
|
|
190
|
+
export function notifyQuarantineReleased({
|
|
191
|
+
ref,
|
|
192
|
+
provider,
|
|
193
|
+
message,
|
|
194
|
+
webhookUrl = "",
|
|
195
|
+
fetchImpl = fetch,
|
|
196
|
+
nowMs = Date.now(),
|
|
197
|
+
}) {
|
|
198
|
+
const alert = recordAlert({
|
|
199
|
+
type: "quarantine_released",
|
|
200
|
+
ref,
|
|
201
|
+
provider,
|
|
202
|
+
message: message || ("Account " + (provider || "slot") + " (" + (ref || "") + ") released from quarantine and is active"),
|
|
203
|
+
severity: "info",
|
|
204
|
+
nowMs,
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
if (alert && webhookUrl) {
|
|
208
|
+
dispatchWebhookAlert(alert, webhookUrl, fetchImpl).catch(() => {})
|
|
209
|
+
}
|
|
210
|
+
return alert
|
|
211
|
+
}
|
package/lib/client.js
CHANGED
|
@@ -60,7 +60,7 @@ window.__ModuleLoader__.load({
|
|
|
60
60
|
'.dsub-helpBox{margin-top:8px;padding:12px 14px;border-radius:8px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l1);font-size:12px;line-height:1.6}' +
|
|
61
61
|
'.dsub-helpHead{display:flex;align-items:center;justify-content:space-between;font-weight:600;margin-bottom:8px;color:var(--dsw-alias-brand-primary)}' +
|
|
62
62
|
'.dsub-helpStep{margin-bottom:6px;color:var(--dsw-alias-label-primary)}' +
|
|
63
|
-
'.dsub-btnActive{background:var(--dsw-alias-brand-primary);color:var(--dsw-alias-
|
|
63
|
+
'.dsub-btnActive{background:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-primary-foreground);border-color:var(--dsw-alias-brand-primary)}' +
|
|
64
64
|
'.dsub-h{font-size:13.5px;font-weight:600;color:var(--dsw-alias-label-primary)}' +
|
|
65
65
|
'.dsub-sub{font-size:12px;color:var(--dsw-alias-label-secondary)}' +
|
|
66
66
|
'.dsub-dim{font-size:11.5px;color:var(--dsw-alias-label-tertiary)}' +
|
|
@@ -74,14 +74,14 @@ window.__ModuleLoader__.load({
|
|
|
74
74
|
'.dsub-inp{height:30px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);border-radius:6px;padding:0 8px;font-size:12px;min-width:180px}' +
|
|
75
75
|
'.dsub-inp:focus{outline:none;border-color:var(--dsw-alias-brand-primary)}' +
|
|
76
76
|
'.dsub-box{border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:10px;margin-top:8px;background:var(--dsw-alias-bg-layer-2)}' +
|
|
77
|
-
'.dsub-cooldown{display:inline-block;padding:2px 6px;border-radius:4px;background:var(--dsw-alias-state-
|
|
77
|
+
'.dsub-cooldown{display:inline-block;padding:2px 6px;border-radius:4px;background:var(--dsw-alias-state-warn-primary);color:var(--dsw-alias-bg-layer-1);font-size:11px;font-weight:600}' +
|
|
78
78
|
'.dsub-bar{position:relative;height:6px;border-radius:3px;background:var(--dsw-alias-bg-layer-1);overflow:hidden;flex:1;min-width:80px}' +
|
|
79
79
|
'.dsub-barFill{height:100%;border-radius:3px;background:var(--dsw-alias-state-success-primary);transition:width .2s}' +
|
|
80
|
-
'.dsub-barWarn .dsub-barFill{background:var(--dsw-alias-state-
|
|
80
|
+
'.dsub-barWarn .dsub-barFill{background:var(--dsw-alias-state-warn-primary)}' +
|
|
81
81
|
'.dsub-barFull .dsub-barFill{background:var(--dsw-alias-state-error-primary)}' +
|
|
82
82
|
'.dsub-barRow{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--dsw-alias-label-secondary)}' +
|
|
83
83
|
'.dsub-manual{display:flex;flex-direction:column;gap:6px;margin-top:4px;padding-top:10px;border-top:1px solid var(--dsw-alias-border-l2)}' +
|
|
84
|
-
'.dsub-verify{font-size:12px;color:var(--dsw-alias-state-
|
|
84
|
+
'.dsub-verify{font-size:12px;color:var(--dsw-alias-state-warn-primary)}' +
|
|
85
85
|
'.dsub-verify a{color:var(--dsw-alias-brand-primary)}' +
|
|
86
86
|
'.dsub-pill{display:inline-flex;align-items:center;gap:7px;height:28px;padding:0 11px;border:1px solid var(--dsw-alias-border-l2);border-radius:999px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font-size:12px;font-weight:600;cursor:pointer;box-shadow:0 1px 3px color-mix(in srgb, black 15%, transparent),inset 0 1px 0 color-mix(in srgb, white 6%, transparent);transition:all .16s ease;user-select:none}' +
|
|
87
87
|
'.dsub-pill:hover{background:var(--dsw-alias-bg-layer-3);border-color:var(--dsw-alias-label-tertiary);transform:translateY(-1px);box-shadow:0 3px 8px color-mix(in srgb, black 25%, transparent),inset 0 1px 0 color-mix(in srgb, white 10%, transparent)}' +
|
|
@@ -89,9 +89,10 @@ window.__ModuleLoader__.load({
|
|
|
89
89
|
'.dsub-pillTag{padding:1px 5px;border-radius:4px;font-size:10.5px;font-weight:700;letter-spacing:.02em;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-secondary)}' +
|
|
90
90
|
'.dsub-led{width:7px;height:7px;border-radius:50%;flex:none;transition:all .2s ease}' +
|
|
91
91
|
'.dsub-ledOk{background:var(--dsw-alias-state-success-primary);box-shadow:0 0 6px color-mix(in srgb,var(--dsw-alias-state-success-primary) 70%,transparent)}' +
|
|
92
|
-
'.dsub-ledWarn{background:var(--dsw-alias-state-
|
|
92
|
+
'.dsub-ledWarn{background:var(--dsw-alias-state-warn-primary);box-shadow:0 0 6px color-mix(in srgb,var(--dsw-alias-state-warn-primary) 70%,transparent)}' +
|
|
93
93
|
'.dsub-ledBad{background:var(--dsw-alias-state-error-primary);box-shadow:0 0 6px color-mix(in srgb,var(--dsw-alias-state-error-primary) 70%,transparent)}' +
|
|
94
94
|
'.dsub-ledOff{background:var(--dsw-alias-label-tertiary)}' +
|
|
95
|
+
'.dsub-toast-container{position:fixed;top:16px;right:16px;z-index:10030;display:flex;flex-direction:column;gap:8px;pointer-events:none}' +'.dsub-toast{pointer-events:auto;min-width:280px;max-width:400px;padding:10px 14px;border-radius:10px;background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);border:1px solid var(--dsw-alias-border-l1);box-shadow:0 8px 24px color-mix(in srgb,black 40%,transparent);display:flex;align-items:center;justify-content:space-between;gap:10px;font-size:12px;line-height:1.4;animation:dsubFadeIn .2s ease-out}' +'.dsub-toast-warn{border-color:var(--dsw-alias-state-warn-primary)}' +'.dsub-toast-bad{border-color:var(--dsw-alias-state-error-primary)}' +'.dsub-toast-ok{border-color:var(--dsw-alias-state-success-primary)}' +'.dsub-toast-close{background:none;border:none;color:var(--dsw-alias-label-tertiary);cursor:pointer;font-size:14px;padding:0 2px}' +'.dsub-toast-close:hover{color:var(--dsw-alias-label-primary)}' +
|
|
95
96
|
'.dsub-modalWrap{position:fixed;inset:0;z-index:10020;display:grid;place-items:center;background:color-mix(in srgb, black 65%, transparent);backdrop-filter:blur(10px);animation:dsubFadeIn .16s ease-out}' +
|
|
96
97
|
'.dsub-modal{width:min(520px,94vw);max-height:min(85vh,720px);overflow-y:auto;border:1px solid var(--dsw-alias-border-l1);border-radius:16px;background:var(--dsw-alias-bg-layer-3);color:var(--dsw-alias-label-primary);box-shadow:0 24px 64px color-mix(in srgb, black 60%, transparent),0 0 0 1px color-mix(in srgb, white 7%, transparent);padding:20px;animation:dsubScaleIn .18s cubic-bezier(.16,1,.3,1)}' +
|
|
97
98
|
'@keyframes dsubFadeIn{from{opacity:0}to{opacity:1}}' +
|
|
@@ -111,7 +112,7 @@ window.__ModuleLoader__.load({
|
|
|
111
112
|
'.dsub-header-bar{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px;padding:8px 0 12px;margin-bottom:8px;border-bottom:1px solid var(--dsw-alias-border-l2)}' +
|
|
112
113
|
'.dsub-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}' +
|
|
113
114
|
'.dsub-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) 10%,transparent)}' +
|
|
114
|
-
'.dsub-badge-warn{border-color:var(--dsw-alias-state-
|
|
115
|
+
'.dsub-badge-warn{border-color:var(--dsw-alias-state-warn-primary);color:var(--dsw-alias-state-warn-primary);background:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 10%,transparent)}' +
|
|
115
116
|
'.dsub-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) 10%,transparent)}' +
|
|
116
117
|
'.dsub-badge-neutral{border-color:var(--dsw-alias-border-l2);color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-layer-2)}' +
|
|
117
118
|
'.dsub-section-card{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-3);border-radius:12px;padding:14px 16px;display:flex;flex-direction:column;gap:10px;margin-top:12px}' +
|
|
@@ -134,7 +135,7 @@ window.__ModuleLoader__.load({
|
|
|
134
135
|
'.dsub-barTrack{height:6px;border-radius:3px;background:var(--dsw-alias-bg-layer-1);overflow:hidden}' +
|
|
135
136
|
'.dsub-barFillGrad{height:100%;border-radius:3px;transition:width .3s ease}' +
|
|
136
137
|
'.dsub-panel-box{margin-top:10px;padding:12px 14px;border-radius:10px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);display:flex;flex-direction:column;gap:8px}' +
|
|
137
|
-
'.dsub-panel-warn{border-color:color-mix(in srgb,var(--dsw-alias-state-
|
|
138
|
+
'.dsub-panel-warn{border-color:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 40%,transparent);background:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 5%,transparent)}' +
|
|
138
139
|
'.dsub-panel-title{font-size:13px;font-weight:600;color:var(--dsw-alias-label-primary);display:flex;align-items:center;justify-content:space-between}' +
|
|
139
140
|
'.dsub-panel-desc{font-size:12px;color:var(--dsw-alias-label-secondary);line-height:1.4}' +
|
|
140
141
|
'.dsub-usage-grid{display:flex;flex-direction:column;gap:8px;margin-top:4px}' +
|
|
@@ -144,12 +145,12 @@ window.__ModuleLoader__.load({
|
|
|
144
145
|
'.dsub-usage-fill{height:100%;border-radius:999px;transition:width .3s ease}' +
|
|
145
146
|
'.dsub-usage-meta{display:flex;justify-content:space-between;align-items:center;font-size:11px;color:var(--dsw-alias-label-secondary)}' +
|
|
146
147
|
'.dsub-tag-ok{border-color:color-mix(in srgb,var(--dsw-alias-state-success-primary) 30%,transparent);color:var(--dsw-alias-state-success-primary);background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 8%,transparent);padding:1px 7px;border-radius:999px;font-size:11px;font-weight:600;border-style:solid;border-width:1px}' +
|
|
147
|
-
'.dsub-tag-warn{border-color:color-mix(in srgb,var(--dsw-alias-state-
|
|
148
|
+
'.dsub-tag-warn{border-color:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 40%,transparent);color:var(--dsw-alias-state-warn-primary);background:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 8%,transparent);padding:1px 7px;border-radius:999px;font-size:11px;font-weight:600;border-style:solid;border-width:1px}' +
|
|
148
149
|
'.dsub-tag-bad{border-color:color-mix(in srgb,var(--dsw-alias-state-error-primary) 40%,transparent);color:var(--dsw-alias-state-error-primary);background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 8%,transparent);padding:1px 7px;border-radius:999px;font-size:11px;font-weight:600;border-style:solid;border-width:1px}' +
|
|
149
150
|
'.dsub-slot-meta-row{display:flex;flex-wrap:wrap;align-items:center;gap:10px;margin-top:12px;padding-top:10px;border-top:1px solid var(--dsw-alias-border-l2);font-size:12px}' +
|
|
150
151
|
'.dsub-ref-tag{display:inline-flex;align-items:center;gap:5px;font-size:11.5px;color:var(--dsw-alias-label-secondary)}' +
|
|
151
152
|
'.dsub-ref-tag code{padding:2px 6px;border-radius:4px;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);font-family:ui-monospace,monospace;color:var(--dsw-alias-label-primary);font-size:11px}' +
|
|
152
|
-
'.dsub-speed-tag{display:inline-flex;align-items:center;gap:4px;font-size:11.5px;color:var(--dsw-alias-brand-
|
|
153
|
+
'.dsub-speed-tag{display:inline-flex;align-items:center;gap:4px;font-size:11.5px;color:var(--dsw-alias-brand-primary);background:color-mix(in srgb,var(--dsw-alias-brand-primary) 8%,transparent);padding:2px 8px;border-radius:6px;font-weight:500;border:1px solid color-mix(in srgb,var(--dsw-alias-brand-primary) 20%,transparent)}' +
|
|
153
154
|
'.dsub-btn-danger{border-color:color-mix(in srgb,var(--dsw-alias-state-error-primary) 40%,transparent)!important;color:var(--dsw-alias-state-error-primary)!important}' +
|
|
154
155
|
'.dsub-btn-danger:hover:not(:disabled){background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 12%,transparent)!important}' +
|
|
155
156
|
'.dsub-poolTitle{margin-top:16px;margin-bottom:8px;font-size:11.5px;font-weight:700;color:var(--dsw-alias-label-secondary);text-transform:uppercase;letter-spacing:.05em}' +
|
|
@@ -157,12 +158,12 @@ window.__ModuleLoader__.load({
|
|
|
157
158
|
'.dsub-accountCard:hover{background:var(--dsw-alias-bg-layer-3);border-color:var(--dsw-alias-label-tertiary)}' +
|
|
158
159
|
'.dsub-brandBadge{width:32px;height:32px;border-radius:8px;display:grid;place-items:center;font-size:12px;font-weight:700;flex-shrink:0}' +
|
|
159
160
|
'.dsub-brandCodex{background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 15%,transparent);color:var(--dsw-alias-state-success-primary);border:1px solid color-mix(in srgb,var(--dsw-alias-state-success-primary) 30%,transparent)}' +
|
|
160
|
-
'.dsub-brandClaude{background:color-mix(in srgb,var(--dsw-alias-state-
|
|
161
|
-
'.dsub-brandGrok{background:color-mix(in srgb,var(--dsw-alias-
|
|
161
|
+
'.dsub-brandClaude{background:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 15%,transparent);color:var(--dsw-alias-state-warn-primary);border:1px solid color-mix(in srgb,var(--dsw-alias-state-warn-primary) 30%,transparent)}' +
|
|
162
|
+
'.dsub-brandGrok{background:color-mix(in srgb,var(--dsw-alias-state-business-primary, var(--dsw-alias-brand-primary)) 15%,transparent);color:var(--dsw-alias-state-business-primary, var(--dsw-alias-brand-primary));border:1px solid color-mix(in srgb,var(--dsw-alias-state-business-primary, var(--dsw-alias-brand-primary)) 30%,transparent)}' +
|
|
162
163
|
'.dsub-brandAgy{background:color-mix(in srgb,var(--dsw-alias-brand-primary) 15%,transparent);color:var(--dsw-alias-brand-primary);border:1px solid color-mix(in srgb,var(--dsw-alias-brand-primary) 30%,transparent)}' +
|
|
163
164
|
'.dsub-brandOllama{background:color-mix(in srgb,var(--dsw-alias-label-tertiary) 15%,transparent);color:var(--dsw-alias-label-secondary);border:1px solid color-mix(in srgb,var(--dsw-alias-label-tertiary) 30%,transparent)}' +
|
|
164
|
-
'.dsub-brandKimi{background:color-mix(in srgb,var(--dsw-alias-state-
|
|
165
|
-
'.dsub-brandGlm{background:color-mix(in srgb,var(--dsw-alias-state-
|
|
165
|
+
'.dsub-brandKimi{background:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 15%,transparent);color:var(--dsw-alias-state-warn-primary);border:1px solid color-mix(in srgb,var(--dsw-alias-state-warn-primary) 30%,transparent)}' +
|
|
166
|
+
'.dsub-brandGlm{background:color-mix(in srgb,var(--dsw-alias-state-business-primary, var(--dsw-alias-brand-primary)) 15%,transparent);color:var(--dsw-alias-state-business-primary, var(--dsw-alias-brand-primary));border:1px solid color-mix(in srgb,var(--dsw-alias-state-business-primary, var(--dsw-alias-brand-primary)) 30%,transparent)}' +
|
|
166
167
|
'.dsub-planBadge{display:inline-flex;align-items:center;padding:1px 6px;border-radius:4px;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;background:color-mix(in srgb,var(--dsw-alias-brand-primary) 15%,transparent);color:var(--dsw-alias-brand-primary);border:1px solid color-mix(in srgb,var(--dsw-alias-brand-primary) 30%,transparent);margin-left:6px}' +
|
|
167
168
|
|
|
168
169
|
'.dsub-accInfo{flex:1;min-width:0;display:flex;flex-direction:column;gap:3px}' +
|
|
@@ -170,18 +171,18 @@ window.__ModuleLoader__.load({
|
|
|
170
171
|
'.dsub-accName{font-size:13px;font-weight:600}' +
|
|
171
172
|
'.dsub-accStatusTag{font-size:10.5px;font-weight:600;padding:1px 6px;border-radius:4px}' +
|
|
172
173
|
'.dsub-statusOk{background:color-mix(in srgb,var(--dsw-alias-state-success-primary) 15%,transparent);color:var(--dsw-alias-state-success-primary)}' +
|
|
173
|
-
'.dsub-statusWarn{background:color-mix(in srgb,var(--dsw-alias-state-
|
|
174
|
+
'.dsub-statusWarn{background:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 15%,transparent);color:var(--dsw-alias-state-warn-primary)}' +
|
|
174
175
|
'.dsub-statusBad{background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 15%,transparent);color:var(--dsw-alias-state-error-primary)}' +
|
|
175
176
|
'.dsub-statusOff{background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-tertiary)}' +
|
|
176
177
|
'.dsub-modalFoot{display:flex;align-items:center;justify-content:space-between;margin-top:16px;padding-top:12px;border-top:1px solid var(--dsw-alias-border-l2)}' +
|
|
177
|
-
'.dsub-btnPrimary{display:inline-flex;align-items:center;gap:6px;padding:6px 12px;border-radius:8px;font-size:12px;font-weight:600;background:var(--dsw-alias-brand-primary);color:var(--dsw-alias-
|
|
178
|
+
'.dsub-btnPrimary{display:inline-flex;align-items:center;gap:6px;padding:6px 12px;border-radius:8px;font-size:12px;font-weight:600;background:var(--dsw-alias-brand-primary);color:var(--dsw-alias-label-primary-foreground);border:0;cursor:pointer;transition:opacity .15s}' +
|
|
178
179
|
'.dsub-btnPrimary:hover{opacity:.9}' +
|
|
179
180
|
'.dsub-btnSec{display:inline-flex;align-items:center;gap:5px;padding:5px 10px;border-radius:7px;font-size:11.5px;font-weight:500;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-secondary);border:1px solid var(--dsw-alias-border-l2);cursor:pointer;text-decoration:none;transition:all .15s}' +
|
|
180
181
|
'.dsub-btnSec:hover{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-tertiary)}' +
|
|
181
182
|
'.dsub-cq{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--dsw-alias-label-secondary);padding:0 6px}' +
|
|
182
183
|
'.dsub-cqBar{position:relative;width:40px;height:5px;border-radius:3px;background:var(--dsw-alias-bg-layer-1);overflow:hidden}' +
|
|
183
184
|
'.dsub-cqBarFill{height:100%;border-radius:3px;background:var(--dsw-alias-state-success-primary)}' +
|
|
184
|
-
'.dsub-cqBarWarn .dsub-cqBarFill{background:var(--dsw-alias-state-
|
|
185
|
+
'.dsub-cqBarWarn .dsub-cqBarFill{background:var(--dsw-alias-state-warn-primary)}' +
|
|
185
186
|
'.dsub-cqBarFull .dsub-cqBarFill{background:var(--dsw-alias-state-error-primary)}' +
|
|
186
187
|
'.dsub-cqB{font-variant-numeric:tabular-nums;font-weight:600;color:var(--dsw-alias-label-primary)}' +
|
|
187
188
|
'.dsub-diag{font:11px/1.5 ui-monospace,SFMono-Regular,monospace;max-height:260px;overflow:auto;white-space:pre-wrap;word-break:break-word;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;padding:10px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary)}'
|
|
@@ -536,7 +537,19 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
536
537
|
"backupPayloadPlaceholder": "Paste DSHE... encrypted string here",
|
|
537
538
|
"backupExportOk": "Backup exported successfully",
|
|
538
539
|
"backupImportOk": "Backup imported successfully",
|
|
539
|
-
"backupDownload": "Download .enc"
|
|
540
|
+
"backupDownload": "Download .enc",
|
|
541
|
+
"quarantinePark": "Park",
|
|
542
|
+
"quarantineParkTitle": "Park account in quarantine for 1 hour",
|
|
543
|
+
"probeWarmupNow": "Probe Now",
|
|
544
|
+
"probeWarmupTitle": "Run immediate warmup health probe",
|
|
545
|
+
"releaseQuarantine": "Release",
|
|
546
|
+
"releaseQuarantineTitle": "Release account from quarantine",
|
|
547
|
+
"resetsIn": "Resets in",
|
|
548
|
+
"toastQuarantined": "Account placed in quarantine",
|
|
549
|
+
"toastReleased": "Account active and healthy",
|
|
550
|
+
"toastQuotaWarning": "Quota threshold exceeded",
|
|
551
|
+
"enableToasts": "In-app toast alerts",
|
|
552
|
+
"field.enableToasts": "Show popup toast notifications on quarantine or quota events"
|
|
540
553
|
};
|
|
541
554
|
|
|
542
555
|
const zh = {
|
|
@@ -735,7 +748,19 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
735
748
|
"backupPayloadPlaceholder": "在此粘贴 DSHE... 加密内容",
|
|
736
749
|
"backupExportOk": "备份导出成功",
|
|
737
750
|
"backupImportOk": "备份导入成功",
|
|
738
|
-
"backupDownload": "下载 .enc 文件"
|
|
751
|
+
"backupDownload": "下载 .enc 文件",
|
|
752
|
+
"quarantinePark": "隔离",
|
|
753
|
+
"quarantineParkTitle": "将账户临时隔离 1 小时",
|
|
754
|
+
"probeWarmupNow": "立即探测",
|
|
755
|
+
"probeWarmupTitle": "立即执行健康预热探测",
|
|
756
|
+
"releaseQuarantine": "解除隔离",
|
|
757
|
+
"releaseQuarantineTitle": "立即解除账户隔离状态",
|
|
758
|
+
"resetsIn": "重置倒计时",
|
|
759
|
+
"toastQuarantined": "账户进入隔离期",
|
|
760
|
+
"toastReleased": "账户已解除隔离恢复使用",
|
|
761
|
+
"toastQuotaWarning": "额度预警",
|
|
762
|
+
"enableToasts": "应用内弹窗提醒",
|
|
763
|
+
"field.enableToasts": "当账户发生隔离或额度达到警戒线时显示即时弹窗提醒"
|
|
739
764
|
};
|
|
740
765
|
// #51: live countdown to the quota window reset (account.quota.resetAt).
|
|
741
766
|
function cleanErrorMessage(raw) {
|
|
@@ -1251,6 +1276,46 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
1251
1276
|
}
|
|
1252
1277
|
|
|
1253
1278
|
|
|
1279
|
+
|
|
1280
|
+
const doParkQuarantine = async (provider, index, durationMs) => {
|
|
1281
|
+
setErr("")
|
|
1282
|
+
try {
|
|
1283
|
+
const res = await fetch("/dsh-subscriptions/quarantine/park", {
|
|
1284
|
+
method: "POST",
|
|
1285
|
+
headers: { "Content-Type": "application/json" },
|
|
1286
|
+
body: JSON.stringify({ provider, index, durationMs: durationMs || 3600000, reason: "MANUAL_PARK" }),
|
|
1287
|
+
})
|
|
1288
|
+
const data = await res.json().catch(() => ({}))
|
|
1289
|
+
if (!res.ok) throw new Error((data.error && data.error.message) || ("HTTP " + res.status))
|
|
1290
|
+
setAccounts(data.accounts || [])
|
|
1291
|
+
setInfo(t("toastQuarantined"))
|
|
1292
|
+
setTimeout(() => setInfo(""), 3000)
|
|
1293
|
+
} catch (e) { setErr(cleanErrorMessage(e.message || e)) }
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
const doProbeQuarantine = async (provider, index, releaseOnly) => {
|
|
1297
|
+
setErr("")
|
|
1298
|
+
const key = provider + ":" + index
|
|
1299
|
+
setChecking((c) => Object.assign({}, c, { [key]: true }))
|
|
1300
|
+
try {
|
|
1301
|
+
const res = await fetch("/dsh-subscriptions/quarantine/probe", {
|
|
1302
|
+
method: "POST",
|
|
1303
|
+
headers: { "Content-Type": "application/json" },
|
|
1304
|
+
body: JSON.stringify({ provider, index, release: Boolean(releaseOnly) }),
|
|
1305
|
+
})
|
|
1306
|
+
const data = await res.json().catch(() => ({}))
|
|
1307
|
+
if (!res.ok) throw new Error((data.error && data.error.message) || ("HTTP " + res.status))
|
|
1308
|
+
setAccounts(data.accounts || [])
|
|
1309
|
+
if (data.success || data.released) {
|
|
1310
|
+
setInfo(t("toastReleased"))
|
|
1311
|
+
setTimeout(() => setInfo(""), 3000)
|
|
1312
|
+
} else if (data.error) {
|
|
1313
|
+
setErr(cleanErrorMessage(data.error))
|
|
1314
|
+
}
|
|
1315
|
+
} catch (e) { setErr(cleanErrorMessage(e.message || e)) }
|
|
1316
|
+
setChecking((c) => Object.assign({}, c, { [key]: false }))
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1254
1319
|
const doCheck = async (provider, index) => {
|
|
1255
1320
|
const key = provider + ':' + index
|
|
1256
1321
|
setChecking((c) => Object.assign({}, c, { [key]: true }))
|
|
@@ -1413,7 +1478,7 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
1413
1478
|
React.createElement('div', { style: { padding: '8px 0', overflowX: 'auto' } },
|
|
1414
1479
|
React.createElement('table', { style: { width: '100%', fontSize: '12px', borderCollapse: 'collapse' } },
|
|
1415
1480
|
React.createElement('thead', null,
|
|
1416
|
-
React.createElement('tr', { style: { borderBottom: '1px solid var(--dsw-alias-border-
|
|
1481
|
+
React.createElement('tr', { style: { borderBottom: '1px solid var(--dsw-alias-border-l2)', textAlign: 'left', opacity: 0.7 } },
|
|
1417
1482
|
React.createElement('th', { style: { padding: '6px' } }, t('healthAccount')),
|
|
1418
1483
|
React.createElement('th', { style: { padding: '6px' } }, t('healthScore')),
|
|
1419
1484
|
React.createElement('th', { style: { padding: '6px' } }, t('healthLatency')),
|
|
@@ -1424,11 +1489,11 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
1424
1489
|
React.createElement('tbody', null,
|
|
1425
1490
|
accounts.map((acc, idx) => {
|
|
1426
1491
|
const sc = acc.healthScore != null ? acc.healthScore : 100
|
|
1427
|
-
const healthColor = sc >= 80 ? 'var(--dsw-alias-state-success-primary)' : (sc >= 50 ? 'var(--dsw-alias-state-
|
|
1492
|
+
const healthColor = sc >= 80 ? 'var(--dsw-alias-state-success-primary)' : (sc >= 50 ? 'var(--dsw-alias-state-warn-primary)' : 'var(--dsw-alias-state-error-primary)')
|
|
1428
1493
|
const isQ = acc.status === 'quarantine' || (acc.quarantineUntil && Number(acc.quarantineUntil) > Date.now())
|
|
1429
1494
|
const statusLabel = isQ ? ('⛔ ' + t('healthQuarantine')) : (acc.status === 'probing' ? ('🟡 ' + t('healthWarmup')) : (acc.configured ? ('🟢 ' + t('healthActive')) : ('⚪ ' + t('healthIdle'))))
|
|
1430
1495
|
const pct = acc.usagePercent != null ? Math.round(acc.usagePercent) : null
|
|
1431
|
-
return React.createElement('tr', { key: (acc.ref || acc.provider + acc.index || idx), style: { borderBottom: '1px solid var(--dsw-alias-border-
|
|
1496
|
+
return React.createElement('tr', { key: (acc.ref || acc.provider + acc.index || idx), style: { borderBottom: '1px solid var(--dsw-alias-border-l3)' } },
|
|
1432
1497
|
React.createElement('td', { style: { padding: '6px', fontWeight: 600 } }, (acc.provider ? acc.provider.toUpperCase() : 'LLM') + ' #' + (acc.index || 1) + (acc.label ? ' (' + acc.label + ')' : '')),
|
|
1433
1498
|
React.createElement('td', { style: { padding: '6px', color: healthColor } }, sc + '%'),
|
|
1434
1499
|
React.createElement('td', { style: { padding: '6px' } }, acc.latencyMs ? acc.latencyMs + ' ms' : '—'),
|
|
@@ -1754,6 +1819,21 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
1754
1819
|
disabled: checking[key],
|
|
1755
1820
|
onClick: () => doCheck(row.slot.provider, row.slot.index).catch((e) => setErr(cleanErrorMessage(e.message || e))),
|
|
1756
1821
|
}, checking[key] ? t('checking') : t('check')),
|
|
1822
|
+
(account && account.quarantineUntil && Number(account.quarantineUntil) > Date.now() ? React.createElement(React.Fragment, null,
|
|
1823
|
+
React.createElement('button', {
|
|
1824
|
+
type: 'button', className: 'dsub-mini', title: t('probeWarmupTitle'),
|
|
1825
|
+
disabled: checking[key],
|
|
1826
|
+
onClick: () => doProbeQuarantine(row.slot.provider, row.slot.index, false),
|
|
1827
|
+
}, checking[key] ? t('checking') : ('🔄 ' + t('probeWarmupNow'))),
|
|
1828
|
+
React.createElement('button', {
|
|
1829
|
+
type: 'button', className: 'dsub-mini', title: t('releaseQuarantineTitle'),
|
|
1830
|
+
disabled: checking[key],
|
|
1831
|
+
onClick: () => doProbeQuarantine(row.slot.provider, row.slot.index, true),
|
|
1832
|
+
}, ('✕ ' + t('releaseQuarantine'))),
|
|
1833
|
+
) : (on ? React.createElement('button', {
|
|
1834
|
+
type: 'button', className: 'dsub-mini', title: t('quarantineParkTitle'),
|
|
1835
|
+
onClick: () => doParkQuarantine(row.slot.provider, row.slot.index, 3600000),
|
|
1836
|
+
}, ('⏸ ' + t('quarantinePark'))) : null)),
|
|
1757
1837
|
React.createElement('button', {
|
|
1758
1838
|
type: 'button', className: 'dsub-mini', title: t('removeSlot'),
|
|
1759
1839
|
onClick: () => {
|
|
@@ -1764,6 +1844,13 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
1764
1844
|
},
|
|
1765
1845
|
}, '\u00d7'),
|
|
1766
1846
|
),
|
|
1847
|
+
(account && account.quarantineUntil && Number(account.quarantineUntil) > Date.now()) ? React.createElement('div', { className: 'dsub-row', style: { marginTop: 4 } },
|
|
1848
|
+
React.createElement('span', { className: 'dsub-sub', style: { color: 'var(--dsw-alias-state-error-primary)', fontVariantNumeric: 'tabular-nums' } },
|
|
1849
|
+
'⛔ ' + (account.quarantineReason || 'QUARANTINE') + ' · ' + t('resetsIn') + ' ' + formatRelativeReset(account.quarantineUntil, t('lang'), Date.now())
|
|
1850
|
+
),
|
|
1851
|
+
) : (account && account.quota && account.quota.resetAt && Number(account.quota.resetAt) > Date.now()) ? React.createElement('div', { className: 'dsub-row', style: { marginTop: 4 } },
|
|
1852
|
+
React.createElement(ResetCountdown, { resetAt: account.quota.resetAt }),
|
|
1853
|
+
) : null,
|
|
1767
1854
|
React.createElement('div', { className: 'dsub-manual' },
|
|
1768
1855
|
React.createElement('span', { className: 'dsub-sub' }, t('manualTitle')),
|
|
1769
1856
|
React.createElement('div', { className: 'dsub-row' },
|
|
@@ -1976,7 +2063,7 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
1976
2063
|
function renderItem(label, pct, detail){
|
|
1977
2064
|
var p = Math.min(100, Math.max(0, Math.round(pct)))
|
|
1978
2065
|
var tagCls = p >= 90 ? 'dsub-tag-bad' : (p >= 70 ? 'dsub-tag-warn' : 'dsub-tag-ok')
|
|
1979
|
-
var fillBg = p >= 90 ? 'var(--dsw-alias-state-error-primary)' : (p >= 70 ? 'var(--dsw-alias-state-
|
|
2066
|
+
var fillBg = p >= 90 ? 'var(--dsw-alias-state-error-primary)' : (p >= 70 ? 'var(--dsw-alias-state-warn-primary)' : 'var(--dsw-alias-state-success-primary)')
|
|
1980
2067
|
return React.createElement('div', { key: label, className: 'dsub-usage-card' },
|
|
1981
2068
|
React.createElement('div', { className: 'dsub-usage-head' },
|
|
1982
2069
|
React.createElement('span', null, label),
|
|
@@ -2105,7 +2192,7 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
2105
2192
|
React.createElement('div', { className: 'dsub-header', style: { cursor: 'default' } },
|
|
2106
2193
|
React.createElement('div', { className: 'dsub-headText' },
|
|
2107
2194
|
React.createElement('div', { className: 'dsub-name' }, cardT('title')),
|
|
2108
|
-
React.createElement('div', { className: 'dsub-description', style: { color: 'var(--dsw-alias-state-
|
|
2195
|
+
React.createElement('div', { className: 'dsub-description', style: { color: 'var(--dsw-alias-state-warn-primary)' } },
|
|
2109
2196
|
cardT('unavailable') || cardT('settingsUnavailable') || 'Settings are unavailable',
|
|
2110
2197
|
),
|
|
2111
2198
|
),
|
|
@@ -2461,6 +2548,33 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
2461
2548
|
function SubsPill(props) {
|
|
2462
2549
|
const t = (props && props.t) || ((k) => k)
|
|
2463
2550
|
const [state, setState] = React.useState({ logged: [], usage: 0, accounts: [], open: false, active: null, loading: false })
|
|
2551
|
+
const [toasts, setToasts] = React.useState([])
|
|
2552
|
+
const lastAlertTsRef = React.useRef(Date.now() - 30000)
|
|
2553
|
+
|
|
2554
|
+
const dismissToast = (id) => {
|
|
2555
|
+
setToasts((prev) => prev.filter((t) => t.id !== id))
|
|
2556
|
+
}
|
|
2557
|
+
|
|
2558
|
+
const pollAlerts = () => {
|
|
2559
|
+
fetch('/dsh-subscriptions/alerts?limit=5', { cache: 'no-store' })
|
|
2560
|
+
.then((res) => res.json())
|
|
2561
|
+
.then((data) => {
|
|
2562
|
+
if (data && Array.isArray(data.alerts)) {
|
|
2563
|
+
const fresh = data.alerts.filter((a) => a && a.timestamp > lastAlertTsRef.current)
|
|
2564
|
+
if (fresh.length > 0) {
|
|
2565
|
+
lastAlertTsRef.current = Math.max(...fresh.map((a) => a.timestamp))
|
|
2566
|
+
fresh.forEach((al) => {
|
|
2567
|
+
const toastId = 't_' + al.timestamp + '_' + Math.random().toString(36).slice(2, 5)
|
|
2568
|
+
const cls = al.severity === 'error' ? 'dsub-toast-bad' : (al.severity === 'info' ? 'dsub-toast-ok' : 'dsub-toast-warn')
|
|
2569
|
+
const newToast = { id: toastId, message: al.message, cls }
|
|
2570
|
+
setToasts((prev) => [newToast, ...prev].slice(0, 3))
|
|
2571
|
+
setTimeout(() => dismissToast(toastId), 5000)
|
|
2572
|
+
})
|
|
2573
|
+
}
|
|
2574
|
+
}
|
|
2575
|
+
})
|
|
2576
|
+
.catch(() => {})
|
|
2577
|
+
}
|
|
2464
2578
|
|
|
2465
2579
|
const pull = () => {
|
|
2466
2580
|
setState((s) => Object.assign({}, s, { loading: true }))
|
|
@@ -2481,7 +2595,8 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
2481
2595
|
|
|
2482
2596
|
React.useEffect(() => {
|
|
2483
2597
|
pull()
|
|
2484
|
-
|
|
2598
|
+
pollAlerts()
|
|
2599
|
+
const id = setInterval(() => { pull(); pollAlerts(); }, 25 * 1000)
|
|
2485
2600
|
return () => clearInterval(id)
|
|
2486
2601
|
}, [])
|
|
2487
2602
|
|
|
@@ -2529,7 +2644,7 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
2529
2644
|
React.createElement('div', { className: 'dsub-heroTitle' },
|
|
2530
2645
|
React.createElement('span', { className: 'dsub-brandBadge ' + b.cls, style: { width: 24, height: 24, fontSize: 11 } }, b.icon),
|
|
2531
2646
|
React.createElement('span', null, (a.provider ? a.provider.toUpperCase() : 'LLM') + (a.index ? ' #' + a.index : '')),
|
|
2532
|
-
a.fastMode ? React.createElement('span', { className: 'dsub-pillTag', style: { background: 'color-mix(in srgb,var(--dsw-alias-state-
|
|
2647
|
+
a.fastMode ? React.createElement('span', { className: 'dsub-pillTag', style: { background: 'color-mix(in srgb,var(--dsw-alias-state-warn-primary) 15%,transparent)', color: 'var(--dsw-alias-state-warn-primary)' } }, '⚡ FAST 1.5x') : null,
|
|
2533
2648
|
),
|
|
2534
2649
|
React.createElement('span', { className: 'dsub-heroModel' }, a.model || 'active model'),
|
|
2535
2650
|
),
|
|
@@ -2544,7 +2659,7 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
2544
2659
|
className: 'dsub-barFillGrad',
|
|
2545
2660
|
style: {
|
|
2546
2661
|
width: Math.min(100, Math.max(0, primPct)) + '%',
|
|
2547
|
-
background: primPct >= 90 ? 'var(--dsw-alias-state-error-primary)' : (primPct >= 50 ? 'var(--dsw-alias-state-
|
|
2662
|
+
background: primPct >= 90 ? 'var(--dsw-alias-state-error-primary)' : (primPct >= 50 ? 'var(--dsw-alias-state-warn-primary)' : 'var(--dsw-alias-state-success-primary)'),
|
|
2548
2663
|
},
|
|
2549
2664
|
}),
|
|
2550
2665
|
),
|
|
@@ -2559,7 +2674,7 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
2559
2674
|
className: 'dsub-barFillGrad',
|
|
2560
2675
|
style: {
|
|
2561
2676
|
width: Math.min(100, Math.max(0, secPct)) + '%',
|
|
2562
|
-
background: secPct >= 90 ? 'var(--dsw-alias-state-error-primary)' : (secPct >= 50 ? 'var(--dsw-alias-state-
|
|
2677
|
+
background: secPct >= 90 ? 'var(--dsw-alias-state-error-primary)' : (secPct >= 50 ? 'var(--dsw-alias-state-warn-primary)' : 'var(--dsw-alias-brand-primary)'),
|
|
2563
2678
|
},
|
|
2564
2679
|
}),
|
|
2565
2680
|
),
|
|
@@ -2608,7 +2723,7 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
2608
2723
|
className: 'dsub-barFillGrad',
|
|
2609
2724
|
style: {
|
|
2610
2725
|
width: Math.min(100, Math.max(0, pct)) + '%',
|
|
2611
|
-
background: pct >= 90 ? 'var(--dsw-alias-state-error-primary)' : (pct >= 50 ? 'var(--dsw-alias-state-
|
|
2726
|
+
background: pct >= 90 ? 'var(--dsw-alias-state-error-primary)' : (pct >= 50 ? 'var(--dsw-alias-state-warn-primary)' : 'var(--dsw-alias-state-success-primary)'),
|
|
2612
2727
|
},
|
|
2613
2728
|
}),
|
|
2614
2729
|
) : null,
|
|
@@ -2617,6 +2732,16 @@ const cssId = 'dsh-subscriptions/settings.module.css'
|
|
|
2617
2732
|
})
|
|
2618
2733
|
|
|
2619
2734
|
return React.createElement(React.Fragment, null,
|
|
2735
|
+
toasts.length > 0 ? React.createElement('div', { className: 'dsub-toast-container' },
|
|
2736
|
+
toasts.map((toast) => React.createElement('div', { className: 'dsub-toast ' + toast.cls, key: toast.id },
|
|
2737
|
+
React.createElement('span', null, toast.message),
|
|
2738
|
+
React.createElement('button', {
|
|
2739
|
+
type: 'button',
|
|
2740
|
+
className: 'dsub-toast-close',
|
|
2741
|
+
onClick: () => dismissToast(toast.id),
|
|
2742
|
+
}, "\u00d7"),
|
|
2743
|
+
))
|
|
2744
|
+
) : null,
|
|
2620
2745
|
React.createElement('button', {
|
|
2621
2746
|
type: 'button',
|
|
2622
2747
|
className: 'dsub-pill',
|
package/lib/config-schema.js
CHANGED
|
@@ -108,6 +108,8 @@ export const Config = z.object({
|
|
|
108
108
|
.description('#351 External webhook URL to receive quota and session alerts (JSON POST).'),
|
|
109
109
|
alertThresholds: z.array(z.number()).volatile().default([80, 90, 95])
|
|
110
110
|
.description('#351 Quota usage percentage thresholds for firing alerts.'),
|
|
111
|
+
enableToasts: z.boolean().volatile().default(true)
|
|
112
|
+
.description('#510 In-app toast alerts for quarantine and quota threshold events.'),
|
|
111
113
|
autoPacing: z.boolean().volatile().default(true)
|
|
112
114
|
.description('#352 Predictive burn-rate load balancing between accounts.'),
|
|
113
115
|
drainBeforeReset: z.boolean().volatile().default(false)
|
package/lib/quarantine.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
export const REASON_RATE_LIMIT = 'RATE_LIMIT'
|
|
3
3
|
export const REASON_HARD_LIMIT = 'HARD_LIMIT'
|
|
4
4
|
export const REASON_REVOKED = 'TOKEN_REVOKED'
|
|
5
|
+
export const REASON_MANUAL_PARK = 'MANUAL_PARK'
|
|
5
6
|
|
|
6
7
|
export const STATUS_ACTIVE = 'active'
|
|
7
8
|
export const STATUS_QUARANTINE = 'quarantine'
|
|
@@ -13,6 +14,8 @@ export function quarantineDuration(reason) {
|
|
|
13
14
|
return 24 * 60 * 60 * 1000 // 24 hours
|
|
14
15
|
case REASON_REVOKED:
|
|
15
16
|
return 7 * 24 * 60 * 60 * 1000 // 7 days (requires re-auth)
|
|
17
|
+
case REASON_MANUAL_PARK:
|
|
18
|
+
return 60 * 60 * 1000 // 1 hour (manual park)
|
|
16
19
|
case REASON_RATE_LIMIT:
|
|
17
20
|
default:
|
|
18
21
|
return 60 * 60 * 1000 // 1 hour
|
package/lib/routes/accounts.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { exportVault, importVault } from '../vault.js'
|
|
2
|
-
import { normalizeSlots } from '../accounts.js'
|
|
2
|
+
import { normalizeSlots, vendorConfig } from '../accounts.js'
|
|
3
|
+
import { getVendor } from '../vendors/index.js'
|
|
4
|
+
import { notifyQuarantineEntered, notifyQuarantineReleased } from '../alerts.js'
|
|
3
5
|
import { Config, plainConfig, publicConfig } from '../config-schema.js'
|
|
4
6
|
import { decryptWithPassphrase, encryptWithPassphrase } from '../crypto.js'
|
|
5
7
|
import { isTrustedSettingsRequest, queryOf, readBody, safeJsonHandler, writeJson } from '../http.js'
|
|
@@ -21,6 +23,118 @@ export function registerAccountsRoutes(ctx, state) {
|
|
|
21
23
|
} = state
|
|
22
24
|
|
|
23
25
|
|
|
26
|
+
|
|
27
|
+
// Issue #509: Manual park to quarantine
|
|
28
|
+
ctx.effect(() => ctx.webServer.register({
|
|
29
|
+
kind: 'exact',
|
|
30
|
+
path: '/dsh-subscriptions/quarantine/park',
|
|
31
|
+
handler: safeJsonHandler(async (req, res) => {
|
|
32
|
+
if (req.method !== 'POST') {
|
|
33
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
|
|
34
|
+
return
|
|
35
|
+
}
|
|
36
|
+
if (!isTrustedSettingsRequest(req, ctx)) {
|
|
37
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
let payload
|
|
41
|
+
try { payload = JSON.parse((await readBody(req, 8 * 1024)).toString('utf8') || '{}') } catch {
|
|
42
|
+
writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
|
|
43
|
+
return
|
|
44
|
+
}
|
|
45
|
+
const provider = String(payload.provider || '')
|
|
46
|
+
const index = Number(payload.index || 1)
|
|
47
|
+
if (!isProvider(provider)) {
|
|
48
|
+
writeJson(res, 400, { ok: false, error: { code: 'provider', message: 'unknown provider' } })
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
const ref = refForSlot(provider, index)
|
|
52
|
+
if (!ref) {
|
|
53
|
+
writeJson(res, 404, { ok: false, error: { code: 'slot', message: 'slot not found' } })
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
const durationMs = Math.min(Math.max(60 * 1000, Number(payload.durationMs) || 3600 * 1000), 48 * 3600 * 1000)
|
|
57
|
+
const reason = String(payload.reason || 'MANUAL_PARK').slice(0, 64)
|
|
58
|
+
const until = Date.now() + durationMs
|
|
59
|
+
|
|
60
|
+
store.rememberQuarantine(ref, reason, until, 1)
|
|
61
|
+
notifyQuarantineEntered({ ref, provider, reason, until, webhookUrl: live().webhookUrl })
|
|
62
|
+
await syncAdapter()
|
|
63
|
+
|
|
64
|
+
writeJson(res, 200, { ok: true, ref, quarantineUntil: until, quarantineReason: reason, accounts: await accountsView() })
|
|
65
|
+
}),
|
|
66
|
+
}), 'dsh-subscriptions: /quarantine/park')
|
|
67
|
+
|
|
68
|
+
// Issue #509: Force probe warmup or release quarantine
|
|
69
|
+
ctx.effect(() => ctx.webServer.register({
|
|
70
|
+
kind: 'exact',
|
|
71
|
+
path: '/dsh-subscriptions/quarantine/probe',
|
|
72
|
+
handler: safeJsonHandler(async (req, res) => {
|
|
73
|
+
if (req.method !== 'POST') {
|
|
74
|
+
writeJson(res, 405, { ok: false, error: { code: 'method', message: 'POST only' } })
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
if (!isTrustedSettingsRequest(req, ctx)) {
|
|
78
|
+
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'same-origin only' } })
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
let payload
|
|
82
|
+
try { payload = JSON.parse((await readBody(req, 8 * 1024)).toString('utf8') || '{}') } catch {
|
|
83
|
+
writeJson(res, 400, { ok: false, error: { code: 'json', message: 'invalid json' } })
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
const provider = String(payload.provider || '')
|
|
87
|
+
const index = Number(payload.index || 1)
|
|
88
|
+
const forceRelease = Boolean(payload.release)
|
|
89
|
+
if (!isProvider(provider)) {
|
|
90
|
+
writeJson(res, 400, { ok: false, error: { code: 'provider', message: 'unknown provider' } })
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
const ref = refForSlot(provider, index)
|
|
94
|
+
if (!ref) {
|
|
95
|
+
writeJson(res, 404, { ok: false, error: { code: 'slot', message: 'slot not found' } })
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (forceRelease) {
|
|
100
|
+
store.releaseQuarantine(ref)
|
|
101
|
+
notifyQuarantineReleased({ ref, provider, webhookUrl: live().webhookUrl })
|
|
102
|
+
await syncAdapter()
|
|
103
|
+
writeJson(res, 200, { ok: true, success: true, released: true, accounts: await accountsView() })
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const probeFn = async (r) => {
|
|
108
|
+
const blob = await store.loadBlob(r)
|
|
109
|
+
if (!blob) throw new Error('no credentials for slot')
|
|
110
|
+
let vendor = null
|
|
111
|
+
try {
|
|
112
|
+
vendor = getVendor(provider)
|
|
113
|
+
} catch (err) {
|
|
114
|
+
ctx.logger?.debug?.('getVendor(%s) probe failed: %s', provider, err?.message)
|
|
115
|
+
}
|
|
116
|
+
if (vendor && typeof vendor.check === 'function') {
|
|
117
|
+
const cfg = vendorConfig(provider, live())
|
|
118
|
+
const fetchImpl = typeof state.fetchForRef === 'function' ? state.fetchForRef(r) : fetch
|
|
119
|
+
await vendor.check(blob, cfg, fetchImpl)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const result = await store.probeWarmup(ref, probeFn, true)
|
|
124
|
+
if (result.success) {
|
|
125
|
+
notifyQuarantineReleased({ ref, provider, webhookUrl: live().webhookUrl })
|
|
126
|
+
}
|
|
127
|
+
await syncAdapter()
|
|
128
|
+
|
|
129
|
+
writeJson(res, 200, {
|
|
130
|
+
ok: true,
|
|
131
|
+
success: Boolean(result.success),
|
|
132
|
+
error: result.error ? String(result.error.message || result.error).slice(0, 200) : null,
|
|
133
|
+
accounts: await accountsView(),
|
|
134
|
+
})
|
|
135
|
+
}),
|
|
136
|
+
}), 'dsh-subscriptions: /quarantine/probe')
|
|
137
|
+
|
|
24
138
|
ctx.effect(() => ctx.webServer.register({
|
|
25
139
|
kind: 'exact',
|
|
26
140
|
path: '/dsh-subscriptions/reset-credits',
|
package/lib/stream-rotate.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { pickAccount, markCooldown, isSwitchableError, isRegionError, modelFamily } from './rotate.js'
|
|
2
2
|
import { putInQuarantine, REASON_RATE_LIMIT, REASON_HARD_LIMIT, REASON_REVOKED } from './quarantine.js'
|
|
3
|
-
import { notifySessionExpired } from './alerts.js'
|
|
3
|
+
import { notifySessionExpired, notifyQuarantineEntered } from './alerts.js'
|
|
4
4
|
import { withRunawayGuard } from './runaway-guard.js'
|
|
5
5
|
|
|
6
6
|
export async function* streamWithRotation({
|
|
@@ -114,6 +114,18 @@ export async function* streamWithRotation({
|
|
|
114
114
|
account.quarantineAttempts = (account.quarantineAttempts || 0) + 1
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
try {
|
|
118
|
+
notifyQuarantineEntered({
|
|
119
|
+
ref: account.ref,
|
|
120
|
+
provider: options && options.provider,
|
|
121
|
+
reason: account.quarantineReason || reason,
|
|
122
|
+
until: account.quarantineUntil,
|
|
123
|
+
webhookUrl: webhookUrl || (options && options.webhookUrl),
|
|
124
|
+
fetchImpl,
|
|
125
|
+
nowMs: nowMs(),
|
|
126
|
+
})
|
|
127
|
+
} catch { /* fire-and-forget alert */ }
|
|
128
|
+
|
|
117
129
|
if (reason === REASON_REVOKED) {
|
|
118
130
|
try {
|
|
119
131
|
notifySessionExpired({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-subscriptions",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.59",
|
|
4
4
|
"description": "Use ChatGPT Codex, Claude, Grok, Antigravity, Kimi, GLM, Cursor, Kiro, Copilot, Qwen, ERNIE, Spark, JetBrains, Perplexity, Replit, and Cody subscriptions as DeepSeek Harness LLM providers via OAuth.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|