@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 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-text-contrast);border-color:var(--dsw-alias-brand-primary)}' +
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-warning-primary);color:var(--dsw-alias-bg-layer-1);font-size:11px;font-weight:600}' +
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-warning-primary)}' +
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-warning-primary)}' +
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-warning-primary);box-shadow:0 0 6px color-mix(in srgb,var(--dsw-alias-state-warning-primary) 70%,transparent)}' +
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-warning-primary);color:var(--dsw-alias-state-warning-primary);background:color-mix(in srgb,var(--dsw-alias-state-warning-primary) 10%,transparent)}' +
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-warning-primary) 40%,transparent);background:color-mix(in srgb,var(--dsw-alias-state-warning-primary) 5%,transparent)}' +
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-warning-primary) 40%,transparent);color:var(--dsw-alias-state-warning-primary);background:color-mix(in srgb,var(--dsw-alias-state-warning-primary) 8%,transparent);padding:1px 7px;border-radius:999px;font-size:11px;font-weight:600;border-style:solid;border-width:1px}' +
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-default);background:color-mix(in srgb,var(--dsw-alias-brand-default) 8%,transparent);padding:2px 8px;border-radius:6px;font-weight:500;border:1px solid color-mix(in srgb,var(--dsw-alias-brand-default) 20%,transparent)}' +
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-warning-primary) 15%,transparent);color:var(--dsw-alias-state-warning-primary);border:1px solid color-mix(in srgb,var(--dsw-alias-state-warning-primary) 30%,transparent)}' +
161
- '.dsub-brandGrok{background:color-mix(in srgb,var(--dsw-alias-accent-primary, var(--dsw-alias-brand-primary)) 15%,transparent);color:var(--dsw-alias-accent-primary, var(--dsw-alias-brand-primary));border:1px solid color-mix(in srgb,var(--dsw-alias-accent-primary, var(--dsw-alias-brand-primary)) 30%,transparent)}' +
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-warning-primary) 15%,transparent);color:var(--dsw-alias-state-warning-primary);border:1px solid color-mix(in srgb,var(--dsw-alias-state-warning-primary) 30%,transparent)}' +
165
- '.dsub-brandGlm{background:color-mix(in srgb,var(--dsw-alias-state-info-primary, var(--dsw-alias-brand-primary)) 15%,transparent);color:var(--dsw-alias-state-info-primary, var(--dsw-alias-brand-primary));border:1px solid color-mix(in srgb,var(--dsw-alias-state-info-primary, var(--dsw-alias-brand-primary)) 30%,transparent)}' +
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-warning-primary) 15%,transparent);color:var(--dsw-alias-state-warning-primary)}' +
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-text-contrast);border:0;cursor:pointer;transition:opacity .15s}' +
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-warning-primary)}' +
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-primary)', textAlign: 'left', opacity: 0.7 } },
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-warning-primary)' : 'var(--dsw-alias-state-error-primary)')
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-secondary)' } },
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-warning-primary)' : 'var(--dsw-alias-state-success-primary)')
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-warning-primary)' } },
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
- const id = setInterval(pull, 25 * 1000)
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-warning-primary) 15%,transparent)', color: 'var(--dsw-alias-state-warning-primary)' } }, '⚡ FAST 1.5x') : null,
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-warning-primary)' : 'var(--dsw-alias-state-success-primary)'),
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-warning-primary)' : 'var(--dsw-alias-brand-primary)'),
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-warning-primary)' : 'var(--dsw-alias-state-success-primary)'),
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',
@@ -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
@@ -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',
@@ -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.57",
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",