@iasiv5/dsh-quota-watch 0.0.1 → 0.0.3

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
@@ -6,11 +6,12 @@
6
6
 
7
7
  ## 功能
8
8
 
9
- - 在侧栏 Settings 行上方显示一个可折叠卡片;没有单独的设置分区。
10
- - GLM Coding Plan:展示套餐名、5 小时 / 每周 / 每月窗口、已用百分比、进度条和重置时间;兼容 Token / Credit 套餐,忽略 MCP `TIME_LIMIT`。
11
- - GitHub Copilot:读取 `premium_interactions` quota snapshot;只有 GitHub 返回有限、有效的 `remaining` 时才显示可用额度和百分比。若余额字段无效或账号被标记为 unlimited,则显示“余额暂不可用”;`credits_used` 如存在会单独标为已用量,绝不用于推算余额。
12
- - 宿主默认每 60 秒探测;profile patch 可将 `pollIntervalSec` 设置在 30–3600 秒。浏览器页签可见时每 30 秒刷新视图,隐藏时暂停;刷新按钮可立即请求宿主探测。
13
- - 探测失败时保留最后一次成功数据并标记可能过期;缺少凭据时显示未配置状态。
9
+ - 在侧栏 Settings 行上方显示一张常显摘要卡(无折叠按钮):GLM 与 GitHub Copilot 各占一行,统一显示**已用百分比**(Copilot 为 `100 − percent_remaining` 的单值反转),已用超过 80% 进度条转橙、超过 95% 转红。
10
+ - GLM 摘要行固定显示 5 小时窗口已用百分比,行尾小字为当日 token 用量;点击行展开详情:今日 tokens / 调用次数双大数字、全部窗口(5 小时 / 每周 / 每月 / MCP 月度 `TIME_LIMIT`)的百分比与重置时间、分模型用量与更新时间。同一时间至多展开一个详情。
11
+ - GitHub Copilot 摘要行仅在 GitHub 返回有限、有效的 `remaining` 且带 `percent_remaining` 时显示;点击展开可用额度、已用百分比、`credits_used` 与重置时间。`credits_used` 绝不用于推算余额,缺失有效百分比时该行不渲染。
12
+ - 今日 tokens / 调用次数按宿主本地时区的自然日(`00:00:00 → 当前时刻`)查询;不带滚动窗口语义。
13
+ - 凭据缺失的 provider 不渲染摘要行(两者皆缺时整卡隐藏);探测失败时保留最后一次成功数据并在行上加 ⚠ 过期标记。
14
+ - 宿主默认每 60 秒探测(窗口 + 用量 + Copilot);profile patch 可将 `pollIntervalSec` 设置在 30–3600 秒。浏览器页签可见时每 30 秒刷新视图,隐藏时暂停;刷新按钮可立即请求宿主探测。
14
15
 
15
16
  ## 兼容与配置
16
17
 
@@ -21,7 +22,7 @@
21
22
 
22
23
  ## 数据来源与语义
23
24
 
24
- - GLM 查询 `https://open.bigmodel.cn/api/monitor/usage/quota/limit` 或 `https://api.z.ai/api/monitor/usage/quota/limit`。该接口按 raw API key 鉴权,不加 Bearer 前缀。
25
+ - GLM 查询 `https://open.bigmodel.cn/api/monitor/usage/quota/limit` 或 `https://api.z.ai/api/monitor/usage/quota/limit`(窗口百分比),以及同域 `/api/monitor/usage/model-usage?startTime=…&endTime=…`(今日 tokens 与调用次数,时间格式 `yyyy-MM-dd HH:mm:ss`)。两个接口均按 raw API key 鉴权,不加 Bearer 前缀。
25
26
  - Copilot 查询 `https://api.github.com/copilot_internal/user`。这是 GitHub Copilot 内部、非公开稳定契约的端点,字段可能变更;插件在无法确认额度含义时显示不可用,不猜测或计算余额。
26
27
  - 对企业 / 组织管理的 Copilot seat,GitHub AI Credits 可能属于共享计费池。侧栏呈现的是 GitHub 用户 quota snapshot 中的可用额度,不保证等同组织账单的总余额。
27
28
  - 凭据只在 DSH 宿主进程解析并用于 HTTPS 请求;浏览器只接收规范化后的额度字段。插件不把 key、OAuth grant 或原始 provider 响应写入磁盘,也不保存额度历史。
@@ -31,7 +32,8 @@
31
32
  - API 路由只允许 loopback 或已配对的浏览器访问;响应禁用缓存。
32
33
  - 本仓库、测试 fixtures 与 npm 包不得包含任何真实 key、OAuth grant、临时 token、原始账号响应或个人余额。测试仅使用合成响应。
33
34
  - npm 包采用显式 `files` 白名单;`npm run security:check` 会扫描工作树和实际 pack 文件,诊断只报告路径和规则名,不打印匹配值。
34
- - 首次 bootstrap 发布真实插件 `0.0.1` 后配置 npm Trusted Publishing;后续 tag release 使用 GitHub Actions OIDC 与 provenance,不保存长期 npm token。
35
+ - 首次 bootstrap 发布真实插件 `0.0.1` 使用 npm web-login;后续 tag release 使用 GitHub Actions OIDC 与 provenance,版本发布者身份为 GitHub Actions,不保存长期 npm token。
36
+ - OIDC 不会改写已发布的 `0.0.1` 元数据,也不负责移除 Registry 级 maintainer 字段;源码和 npm tarball 不写入发布账号邮箱。
35
37
 
36
38
  ## 安装
37
39
 
package/lib/client.js CHANGED
@@ -50,67 +50,119 @@ var PROVIDER_IDS = Object.freeze({
50
50
  });
51
51
  var CLIENT_POLL_INTERVAL_MS = 3e4;
52
52
 
53
+ // src/core/adapters.mjs
54
+ var GLM_HOSTS = Object.freeze({
55
+ "zai-coding-cn": "open.bigmodel.cn",
56
+ zai: "api.z.ai",
57
+ "zai-coding": "api.z.ai"
58
+ });
59
+ var GLM_WINDOW_KEYS = Object.freeze({ 3: "5h", 5: "month", 6: "week" });
60
+ var COPILOT_HEADERS = Object.freeze({
61
+ accept: "application/json",
62
+ "user-agent": "GitHubCopilotChat/0.35.0",
63
+ "editor-version": "vscode/1.107.0",
64
+ "editor-plugin-version": "copilot-chat/0.35.0",
65
+ "copilot-integration-id": "vscode-chat"
66
+ });
67
+ function asNumber(value) {
68
+ if (typeof value === "number" && Number.isFinite(value)) return value;
69
+ if (typeof value === "string" && value.trim() !== "") {
70
+ const parsed = Number(value);
71
+ return Number.isFinite(parsed) ? parsed : void 0;
72
+ }
73
+ return void 0;
74
+ }
75
+ function clampPercent(value) {
76
+ return Math.max(0, Math.min(100, value));
77
+ }
78
+ function remainingToUsed(percentRemaining) {
79
+ const value = asNumber(percentRemaining);
80
+ if (value === void 0 || value < 0 || value > 100) return void 0;
81
+ return clampPercent(Math.round((100 - value) * 100) / 100);
82
+ }
83
+
53
84
  // src/client.mjs
54
85
  var name = "quota-watch-client";
55
86
  var inject = [];
56
87
  var CARD_SELECTOR = "[data-dsh-quota-watch-card]";
57
- var COLLAPSE_KEY = "dsh-quota-watch:collapsed";
58
88
  var FETCH_TIMEOUT_MS = 15e3;
59
89
  var COPY = {
60
90
  zh: {
61
91
  title: "\u5957\u9910\u76D1\u63A7",
62
92
  refresh: "\u5237\u65B0",
63
- collapse: "\u6536\u8D77",
64
- expand: "\u5C55\u5F00",
65
93
  loading: "\u8BFB\u53D6\u4E2D",
66
94
  updated: "\u66F4\u65B0",
67
- notConfigured: "\u672A\u914D\u7F6E",
68
- noPlan: "\u6682\u65E0\u5957\u9910\u7A97\u53E3",
69
95
  failed: "\u67E5\u8BE2\u5931\u8D25",
70
96
  stale: "\u6570\u636E\u53EF\u80FD\u5DF2\u8FC7\u671F",
71
97
  requestFailed: "\u5237\u65B0\u5931\u8D25\uFF0C\u663E\u793A\u4E0A\u6B21\u6210\u529F\u6570\u636E",
72
98
  available: "\u53EF\u7528\u989D\u5EA6",
73
99
  used: "\u672C\u671F\u5DF2\u7528",
74
- balanceUnavailable: "\u4F59\u989D\u6682\u4E0D\u53EF\u7528",
75
100
  reset: "\u91CD\u7F6E",
76
- windows: { "5h": "5 \u5C0F\u65F6", week: "\u6BCF\u5468", month: "\u6BCF\u6708" }
101
+ todayTokens: "\u4ECA\u65E5 tokens",
102
+ todayCalls: "\u4ECA\u65E5\u8C03\u7528",
103
+ windows: {
104
+ "5h": "5 \u5C0F\u65F6\u7A97\u53E3 \xB7 \u5DF2\u7528",
105
+ week: "\u6BCF\u5468\u7A97\u53E3 \xB7 \u5DF2\u7528",
106
+ month: "\u6BCF\u6708\u7A97\u53E3 \xB7 \u5DF2\u7528",
107
+ mcp: "MCP\uFF08\u6708\uFF09\xB7 \u5DF2\u7528"
108
+ }
77
109
  },
78
110
  en: {
79
111
  title: "Quota Watch",
80
112
  refresh: "Refresh",
81
- collapse: "Collapse",
82
- expand: "Expand",
83
113
  loading: "Loading",
84
114
  updated: "Updated",
85
- notConfigured: "Not configured",
86
- noPlan: "No plan windows",
87
115
  failed: "Query failed",
88
116
  stale: "Data may be stale",
89
117
  requestFailed: "Refresh failed; showing last successful data",
90
118
  available: "Available credits",
91
119
  used: "Used this cycle",
92
- balanceUnavailable: "Balance unavailable",
93
120
  reset: "Resets",
94
- windows: { "5h": "5 hours", week: "Weekly", month: "Monthly" }
121
+ todayTokens: "Today tokens",
122
+ todayCalls: "Calls today",
123
+ windows: {
124
+ "5h": "5-hour window \xB7 used",
125
+ week: "Weekly window \xB7 used",
126
+ month: "Monthly window \xB7 used",
127
+ mcp: "MCP (month) \xB7 used"
128
+ }
95
129
  }
96
130
  };
97
131
  var STYLE_TEXT = `
98
132
  [data-dsh-quota-watch-card] { box-sizing: border-box; width: 100%; margin: 0 0 8px; color: var(--dsw-alias-label-primary, inherit); font: inherit; }
99
133
  [data-dsh-quota-watch-card] *, [data-dsh-quota-watch-card] *::before, [data-dsh-quota-watch-card] *::after { box-sizing: border-box; }
100
- .dqw-card { border: 1px solid var(--dsw-alias-border-secondary, rgba(128,128,128,.35)); border-radius: 10px; background: var(--dsw-alias-bg-base, rgba(128,128,128,.08)); padding: 8px; display: flex; flex-direction: column; gap: 7px; }
134
+ .dqw-card { border: 1px solid var(--dsw-alias-border-secondary, rgba(128,128,128,.35)); border-radius: 10px; background: var(--dsw-alias-bg-base, rgba(128,128,128,.08)); padding: 8px; display: flex; flex-direction: column; gap: 6px; }
101
135
  .dqw-head { display: flex; align-items: center; gap: 4px; min-width: 0; }
102
136
  .dqw-title { flex: 1; min-width: 0; margin: 0; font-size: 12px; font-weight: 600; line-height: 18px; }
103
137
  .dqw-button { border: 0; border-radius: 6px; background: transparent; color: inherit; cursor: pointer; font: inherit; font-size: 11px; line-height: 18px; padding: 1px 4px; }
104
138
  .dqw-button:hover { background: var(--dsw-alias-bg-hover, rgba(128,128,128,.16)); }
105
139
  .dqw-button:disabled { cursor: wait; opacity: .6; }
106
140
  .dqw-meta { margin: 0; font-size: 10px; line-height: 14px; opacity: .7; }
107
- .dqw-provider { border-top: 1px solid var(--dsw-alias-border-secondary, rgba(128,128,128,.25)); padding-top: 6px; display: flex; flex-direction: column; gap: 4px; }
108
- .dqw-provider-title { margin: 0; font-size: 11px; font-weight: 600; line-height: 16px; }
141
+ .dqw-body { display: flex; flex-direction: column; }
142
+ .dqw-row-summary { display: flex; align-items: center; gap: 6px; padding: 7px 6px; margin: 1px -2px 2px; border-radius: 8px; }
143
+ .dqw-row-summary[data-expandable] { cursor: pointer; }
144
+ .dqw-row-summary[data-expandable]:hover { background: var(--dsw-alias-bg-hover, rgba(128,128,128,.12)); }
145
+ .dqw-row-summary:focus-visible { outline: 1px solid var(--dsw-alias-button-primary-fill, #5b8def); outline-offset: -1px; }
146
+ .dqw-row-summary.is-error { cursor: default; }
147
+ .dqw-label { font-size: 11px; font-weight: 600; width: 52px; flex: none; }
148
+ .dqw-bar { display: block; flex: 1; height: 4px; overflow: hidden; border-radius: 4px; background: var(--dsw-alias-bg-tertiary, rgba(128,128,128,.2)); }
149
+ .dqw-bar-fill { display: block; height: 100%; border-radius: inherit; background: var(--dsw-alias-button-primary-fill, #5b8def); }
150
+ .dqw-bar-fill.warn { background: var(--dsw-alias-label-warning, #d29922); }
151
+ .dqw-bar-fill.danger { background: var(--dsw-alias-label-danger, #c93c3c); }
152
+ .dqw-pct { font-size: 10.5px; line-height: 14px; font-variant-numeric: tabular-nums; width: 30px; text-align: right; flex: none; }
153
+ .dqw-extra { font-size: 10px; line-height: 14px; opacity: .72; font-variant-numeric: tabular-nums; flex: none; }
154
+ .dqw-chev { font-size: 9px; line-height: 14px; opacity: .6; flex: none; width: 10px; text-align: center; }
155
+ .dqw-stale-mark { flex: none; font-size: 9px; line-height: 14px; color: var(--dsw-alias-label-warning, #b46900); cursor: help; }
156
+ .dqw-errtext { font-size: 10px; line-height: 14px; opacity: .7; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
157
+ .dqw-detail { padding: 2px 6px 6px; display: flex; flex-direction: column; gap: 7px; }
158
+ .dqw-bignums { display: flex; gap: 6px; }
159
+ .dqw-big { flex: 1; border: 1px solid var(--dsw-alias-border-secondary, rgba(128,128,128,.25)); border-radius: 8px; padding: 6px 8px; }
160
+ .dqw-big .dqw-big-value { font-size: 16px; font-weight: 650; line-height: 20px; font-variant-numeric: tabular-nums; }
161
+ .dqw-big .dqw-big-label { font-size: 9.5px; line-height: 13px; opacity: .65; }
109
162
  .dqw-row { display: flex; flex-direction: column; gap: 2px; }
110
163
  .dqw-row-head { display: flex; justify-content: space-between; gap: 5px; font-size: 10px; line-height: 14px; }
111
- .dqw-bar { display: block; height: 4px; overflow: hidden; border-radius: 4px; background: var(--dsw-alias-bg-tertiary, rgba(128,128,128,.2)); }
112
- .dqw-bar-fill { display: block; height: 100%; border-radius: inherit; background: var(--dsw-alias-button-primary-fill, #5b8def); }
113
164
  .dqw-muted { margin: 0; font-size: 10px; line-height: 14px; opacity: .7; }
165
+ .dqw-models { margin: 0; font-size: 10px; line-height: 15px; opacity: .8; border-top: 1px dashed var(--dsw-alias-border-secondary, rgba(128,128,128,.25)); padding-top: 6px; }
114
166
  .dqw-error { margin: 0; font-size: 10px; line-height: 14px; color: var(--dsw-alias-label-warning, #b46900); }
115
167
  .dqw-credit { margin: 0; font-size: 10px; line-height: 14px; font-variant-numeric: tabular-nums; }
116
168
  `;
@@ -121,6 +173,13 @@ function formatNumber(value, locale) {
121
173
  if (typeof value !== "number" || !Number.isFinite(value)) return "";
122
174
  return new Intl.NumberFormat(locale === "zh" ? "zh-CN" : "en-US", { maximumFractionDigits: 1 }).format(value);
123
175
  }
176
+ function formatCompact(value, locale) {
177
+ if (typeof value !== "number" || !Number.isFinite(value)) return "";
178
+ return new Intl.NumberFormat(locale === "zh" ? "zh-CN" : "en-US", {
179
+ notation: "compact",
180
+ maximumFractionDigits: 1
181
+ }).format(value);
182
+ }
124
183
  function formatPercent(value, locale) {
125
184
  if (typeof value !== "number" || !Number.isFinite(value)) return "";
126
185
  const clamped = Math.max(0, Math.min(100, value));
@@ -155,101 +214,47 @@ function progress(doc, label, percent, locale) {
155
214
  bar.setAttribute("aria-valuemax", "100");
156
215
  bar.setAttribute("aria-valuenow", String(Math.max(0, Math.min(100, percent))));
157
216
  const fill = doc.createElement("span");
158
- fill.className = "dqw-bar-fill";
217
+ fill.className = `dqw-bar-fill${percent > 95 ? " danger" : percent > 80 ? " warn" : ""}`;
159
218
  fill.style.width = `${Math.max(0, Math.min(100, percent))}%`;
160
219
  bar.append(fill);
161
220
  row.append(head, bar);
162
221
  return row;
163
222
  }
164
- function providerSection(doc, key, heading) {
165
- const section = doc.createElement("section");
166
- section.className = "dqw-provider";
167
- section.dataset.provider = key;
168
- section.append(text(doc, "h4", "dqw-provider-title", heading));
169
- const content = doc.createElement("div");
170
- content.dataset.role = "content";
171
- content.className = "dqw-content";
172
- section.append(content);
173
- return section;
174
- }
175
- function appendStaleError(doc, content, provider, copy) {
176
- if (provider.status === "stale") content.append(text(doc, "p", "dqw-error", `${copy.stale}: ${provider.error ?? ""}`));
177
- else if (provider.status === "error") content.append(text(doc, "p", "dqw-error", provider.error ?? copy.failed));
178
- }
179
- function renderGlm(doc, section, provider, copy, locale) {
180
- const content = section.querySelector('[data-role="content"]');
181
- content.replaceChildren();
182
- appendStaleError(doc, content, provider, copy);
183
- if (provider.status === "missing") {
184
- content.append(text(doc, "p", "dqw-muted", copy.notConfigured));
185
- return;
186
- }
187
- if (provider.status === "loading") {
188
- content.append(text(doc, "p", "dqw-muted", copy.loading));
189
- return;
190
- }
191
- if (!provider.plan?.windows?.length) {
192
- if (provider.status !== "error") content.append(text(doc, "p", "dqw-muted", copy.noPlan));
193
- return;
194
- }
195
- const planName = provider.plan.planName;
196
- if (planName) content.append(text(doc, "p", "dqw-muted", planName));
197
- for (const window of provider.plan.windows) {
198
- const label = window.name || copy.windows[window.key] || window.key;
199
- if (typeof window.percent === "number" && Number.isFinite(window.percent)) {
200
- content.append(progress(doc, label, window.percent, locale));
201
- } else {
202
- content.append(text(doc, "p", "dqw-muted", label));
203
- }
204
- const reset = formatTime(window.resetsAt, locale);
205
- if (reset) content.append(text(doc, "p", "dqw-muted", `${copy.reset}: ${reset}`));
206
- }
223
+ function glmRowModel(provider) {
224
+ if (provider?.status === "missing") return null;
225
+ if (provider?.status === "error") return { kind: "error", label: "GLM", error: provider.error };
226
+ const windows = provider?.plan?.windows;
227
+ if (!Array.isArray(windows) || windows.length === 0) return null;
228
+ const five = windows.find((window) => window?.key === "5h");
229
+ const tokens = provider?.usage?.status === "ready" || provider?.usage?.status === "stale" ? provider?.usage?.totalTokens : void 0;
230
+ return {
231
+ kind: "data",
232
+ label: "GLM",
233
+ percent: five?.percent,
234
+ stale: provider.status === "stale",
235
+ error: provider.error,
236
+ extra: tokens
237
+ };
207
238
  }
208
- function renderCopilot(doc, section, provider, copy, locale) {
209
- const content = section.querySelector('[data-role="content"]');
210
- content.replaceChildren();
211
- appendStaleError(doc, content, provider, copy);
212
- if (provider.status === "missing") {
213
- content.append(text(doc, "p", "dqw-muted", copy.notConfigured));
214
- return;
215
- }
216
- if (provider.status === "loading") {
217
- content.append(text(doc, "p", "dqw-muted", copy.loading));
218
- return;
219
- }
220
- const quota = provider.quota;
221
- if (quota?.planName) content.append(text(doc, "p", "dqw-muted", quota.planName));
222
- if (quota?.balanceAvailable === true) {
223
- content.append(text(doc, "p", "dqw-credit", `${copy.available}: ${formatNumber(quota.remaining, locale)} / ${formatNumber(quota.entitlement, locale)}`));
224
- if (typeof quota.percentRemaining === "number") {
225
- content.append(progress(doc, copy.available, quota.percentRemaining, locale));
226
- }
227
- } else {
228
- content.append(text(doc, "p", "dqw-muted", copy.balanceUnavailable));
229
- }
230
- if (typeof quota?.creditsUsed === "number") {
231
- content.append(text(doc, "p", "dqw-muted", `${copy.used}: ${formatNumber(quota.creditsUsed, locale)}`));
232
- }
233
- const reset = formatTime(quota?.resetsAt, locale);
234
- if (reset) content.append(text(doc, "p", "dqw-muted", `${copy.reset}: ${reset}`));
239
+ function copilotRowModel(provider) {
240
+ if (provider?.status === "missing") return null;
241
+ if (provider?.status === "error") return { kind: "error", label: "Copilot", error: provider.error };
242
+ const quota = provider?.quota;
243
+ if (quota?.balanceAvailable !== true) return null;
244
+ const used = remainingToUsed(quota.percentRemaining);
245
+ if (used === void 0) return null;
246
+ return {
247
+ kind: "data",
248
+ label: "Copilot",
249
+ percent: used,
250
+ stale: provider.status === "stale",
251
+ error: provider.error
252
+ };
235
253
  }
236
254
  function footArea(doc) {
237
- const sidebar = doc.querySelector('[data-pane="sidebar"], [class*="sidebarCol"]');
255
+ const sidebar = doc.querySelector('[class*="sidebarCol"]');
238
256
  return sidebar?.querySelector('[class*="footArea"]') ?? void 0;
239
257
  }
240
- function readCollapsed(win) {
241
- try {
242
- return win.localStorage.getItem(COLLAPSE_KEY) === "1";
243
- } catch {
244
- return false;
245
- }
246
- }
247
- function writeCollapsed(win, value) {
248
- try {
249
- win.localStorage.setItem(COLLAPSE_KEY, value ? "1" : "0");
250
- } catch {
251
- }
252
- }
253
258
  async function fetchJson(fetchImpl, path, method) {
254
259
  const response = await fetchImpl(path, {
255
260
  ...method === "POST" ? { method: "POST" } : {},
@@ -287,40 +292,138 @@ function mountQuotaCard({
287
292
  refreshButton.title = copy.refresh;
288
293
  refreshButton.setAttribute("aria-label", copy.refresh);
289
294
  refreshButton.dataset.action = "refresh";
290
- const collapseButton = text(doc, "button", "dqw-button", "\u2212");
291
- collapseButton.type = "button";
292
- collapseButton.dataset.action = "collapse";
295
+ header.append(refreshButton);
293
296
  const body = doc.createElement("div");
294
297
  body.className = "dqw-body";
295
298
  const lastUpdated = text(doc, "p", "dqw-meta", copy.loading);
296
299
  lastUpdated.dataset.role = "updated";
297
- const glmSection = providerSection(doc, "glm", "GLM Coding Plan");
298
- const copilotSection = providerSection(doc, "copilot", "GitHub Copilot");
299
- body.append(lastUpdated, glmSection, copilotSection);
300
- header.append(refreshButton, collapseButton);
300
+ body.append(lastUpdated);
301
301
  card.append(header, body);
302
302
  container.append(style, card);
303
- let collapsed = readCollapsed(win);
304
303
  let snapshot;
305
304
  let requestSequence = 0;
306
305
  let timer;
307
306
  let disposed = false;
308
307
  let queuedPlace = false;
309
- const applyCollapsed = () => {
310
- body.hidden = collapsed;
311
- collapseButton.textContent = collapsed ? "+" : "\u2212";
312
- collapseButton.title = collapsed ? copy.expand : copy.collapse;
313
- collapseButton.setAttribute("aria-label", collapseButton.title);
314
- collapseButton.setAttribute("aria-expanded", String(!collapsed));
308
+ let openKey;
309
+ const fillClass = (percent) => percent > 95 ? "dqw-bar-fill danger" : percent > 80 ? "dqw-bar-fill warn" : "dqw-bar-fill";
310
+ const renderProvider = (key, model, provider) => {
311
+ if (model === null) return null;
312
+ const row = doc.createElement("div");
313
+ row.className = "dqw-row-summary";
314
+ row.dataset.dshQuotaWatchRow = key;
315
+ row.append(text(doc, "span", "dqw-label", model.label));
316
+ if (model.kind === "error") {
317
+ row.classList.add("is-error");
318
+ row.append(text(doc, "span", "dqw-errtext", model.error ? `${copy.failed}: ${model.error}` : copy.failed));
319
+ return [row];
320
+ }
321
+ row.setAttribute("role", "button");
322
+ row.tabIndex = 0;
323
+ row.setAttribute("aria-expanded", String(openKey === key));
324
+ row.dataset.expandable = "";
325
+ if (typeof model.percent === "number" && Number.isFinite(model.percent)) {
326
+ const bar = doc.createElement("span");
327
+ bar.className = "dqw-bar";
328
+ bar.setAttribute("role", "progressbar");
329
+ bar.setAttribute("aria-valuemin", "0");
330
+ bar.setAttribute("aria-valuemax", "100");
331
+ bar.setAttribute("aria-valuenow", String(Math.max(0, Math.min(100, model.percent))));
332
+ const fill = text(doc, "span", fillClass(model.percent));
333
+ fill.style.width = `${Math.max(0, Math.min(100, model.percent))}%`;
334
+ bar.append(fill);
335
+ row.append(bar);
336
+ }
337
+ if (model.stale) {
338
+ const mark = text(doc, "span", "dqw-stale-mark", "\u26A0");
339
+ mark.dataset.dshQuotaWatchStale = "";
340
+ mark.title = model.error ?? copy.stale;
341
+ row.append(mark);
342
+ }
343
+ row.append(text(doc, "span", "dqw-pct", typeof model.percent === "number" ? formatPercent(model.percent, locale) : "\u2014"));
344
+ if (model.extra !== void 0) {
345
+ const extra = text(doc, "span", "dqw-extra", formatCompact(model.extra, locale));
346
+ extra.dataset.dshQuotaWatchExtra = "";
347
+ row.append(extra);
348
+ }
349
+ row.append(text(doc, "span", "dqw-chev", openKey === key ? "\u25BE" : "\u25B8"));
350
+ const detail = doc.createElement("div");
351
+ detail.className = "dqw-detail";
352
+ detail.dataset.dshQuotaWatchDetail = key;
353
+ if (openKey === key) renderDetail(detail, key, provider);
354
+ return [row, detail];
355
+ };
356
+ function renderDetail(detail, key, provider) {
357
+ detail.replaceChildren();
358
+ if (provider?.status === "stale") {
359
+ detail.append(text(doc, "p", "dqw-error", `${copy.stale}${provider.error ? `: ${provider.error}` : ""}`));
360
+ }
361
+ if (key === "glm") {
362
+ const usage = provider?.usage;
363
+ const numbers = doc.createElement("div");
364
+ numbers.className = "dqw-bignums";
365
+ const tokensBig = doc.createElement("div");
366
+ tokensBig.className = "dqw-big";
367
+ tokensBig.append(
368
+ text(doc, "div", "dqw-big-value", formatCompact(usage?.totalTokens, locale)),
369
+ text(doc, "div", "dqw-big-label", copy.todayTokens)
370
+ );
371
+ const callsBig = doc.createElement("div");
372
+ callsBig.className = "dqw-big";
373
+ callsBig.append(
374
+ text(doc, "div", "dqw-big-value", formatNumber(usage?.totalCalls, locale)),
375
+ text(doc, "div", "dqw-big-label", copy.todayCalls)
376
+ );
377
+ numbers.append(tokensBig, callsBig);
378
+ detail.append(numbers);
379
+ for (const window of provider?.plan?.windows ?? []) {
380
+ const label = copy.windows[window?.key] ?? window?.key;
381
+ if (typeof window?.percent === "number" && Number.isFinite(window.percent)) {
382
+ detail.append(progress(doc, label, window.percent, locale));
383
+ } else {
384
+ detail.append(text(doc, "p", "dqw-muted", label));
385
+ }
386
+ const reset2 = formatTime(window?.resetsAt, locale);
387
+ if (reset2) detail.append(text(doc, "p", "dqw-muted", `${copy.reset}: ${reset2}`));
388
+ }
389
+ const models = Array.isArray(usage?.models) && usage.models.length > 0 ? usage.models.map((model) => `${model.name} ${formatCompact(model.tokens, locale)}`).join(" \xB7 ") : void 0;
390
+ if (models) detail.append(text(doc, "p", "dqw-models", models));
391
+ const updated = formatTime(provider?.updatedAt ? new Date(provider.updatedAt).toISOString() : "", locale);
392
+ if (updated) detail.append(text(doc, "p", "dqw-meta", `${copy.updated}: ${updated}`));
393
+ return;
394
+ }
395
+ const quota = provider?.quota;
396
+ if (quota?.balanceAvailable === true) {
397
+ detail.append(text(doc, "p", "dqw-credit", `${copy.available}: ${formatNumber(quota.remaining, locale)} / ${formatNumber(quota.entitlement, locale)}`));
398
+ const used = remainingToUsed(quota.percentRemaining);
399
+ if (used !== void 0) detail.append(progress(doc, copy.used, used, locale));
400
+ if (typeof quota.creditsUsed === "number" && quota.creditsUsed >= 0) {
401
+ detail.append(text(doc, "p", "dqw-muted", `${copy.used}: ${formatNumber(quota.creditsUsed, locale)}`));
402
+ }
403
+ } else {
404
+ detail.append(text(doc, "p", "dqw-muted", copy.stale));
405
+ }
406
+ const reset = formatTime(quota?.resetsAt, locale);
407
+ if (reset) detail.append(text(doc, "p", "dqw-muted", `${copy.reset}: ${reset}`));
408
+ }
409
+ const renderAll = () => {
410
+ const providers = snapshot?.providers ?? [];
411
+ const glm = providers.find((provider) => provider?.key === "glm");
412
+ const copilot = providers.find((provider) => provider?.key === "copilot");
413
+ const glmParts = renderProvider("glm", glmRowModel(glm), glm);
414
+ const copilotParts = renderProvider("copilot", copilotRowModel(copilot), copilot);
415
+ const fragment = doc.createDocumentFragment();
416
+ fragment.append(lastUpdated);
417
+ if (glmParts) fragment.append(...glmParts);
418
+ if (copilotParts) fragment.append(...copilotParts);
419
+ body.replaceChildren(fragment);
420
+ container.hidden = glmParts === null && copilotParts === null;
421
+ const updated = formatTime(snapshot?.updatedAt ? new Date(snapshot.updatedAt).toISOString() : "", locale);
422
+ lastUpdated.textContent = updated ? `${copy.updated}: ${updated}` : copy.loading;
315
423
  };
316
424
  const render = (next) => {
317
425
  snapshot = next;
318
- const glm = next?.providers?.find((provider) => provider.key === "glm");
319
- const copilot = next?.providers?.find((provider) => provider.key === "copilot");
320
- if (glm) renderGlm(doc, glmSection, glm, copy, locale);
321
- if (copilot) renderCopilot(doc, copilotSection, copilot, copy, locale);
322
- const updated = formatTime(next?.updatedAt ? new Date(next.updatedAt).toISOString() : "", locale);
323
- lastUpdated.textContent = updated ? `${copy.updated}: ${updated}` : copy.loading;
426
+ renderAll();
324
427
  };
325
428
  const showTransportError = () => {
326
429
  if (snapshot) lastUpdated.textContent = copy.requestFailed;
@@ -360,7 +463,6 @@ function mountQuotaCard({
360
463
  const observer = new win.MutationObserver(schedulePlace);
361
464
  observer.observe(doc.body, { childList: true, subtree: true });
362
465
  place();
363
- applyCollapsed();
364
466
  const startPolling = () => {
365
467
  if (timer !== void 0 || doc.visibilityState === "hidden" || disposed) return;
366
468
  void poll(false);
@@ -379,13 +481,25 @@ function mountQuotaCard({
379
481
  const onRefresh = () => {
380
482
  void poll(true);
381
483
  };
382
- const onCollapse = () => {
383
- collapsed = !collapsed;
384
- writeCollapsed(win, collapsed);
385
- applyCollapsed();
484
+ const toggleRow = (key) => {
485
+ openKey = openKey === key ? void 0 : key;
486
+ renderAll();
487
+ };
488
+ const onBodyClick = (event) => {
489
+ const row = event.target?.closest?.("[data-dsh-quota-watch-row][data-expandable]");
490
+ if (row) toggleRow(row.dataset.dshQuotaWatchRow);
491
+ };
492
+ const onBodyKeydown = (event) => {
493
+ if (event.key !== "Enter" && event.key !== " ") return;
494
+ const row = event.target?.closest?.("[data-dsh-quota-watch-row][data-expandable]");
495
+ if (row) {
496
+ event.preventDefault();
497
+ toggleRow(row.dataset.dshQuotaWatchRow);
498
+ }
386
499
  };
387
500
  refreshButton.addEventListener("click", onRefresh);
388
- collapseButton.addEventListener("click", onCollapse);
501
+ body.addEventListener("click", onBodyClick);
502
+ body.addEventListener("keydown", onBodyKeydown);
389
503
  doc.addEventListener("visibilitychange", onVisibilityChange);
390
504
  startPolling();
391
505
  return () => {
@@ -394,7 +508,8 @@ function mountQuotaCard({
394
508
  observer.disconnect();
395
509
  doc.removeEventListener("visibilitychange", onVisibilityChange);
396
510
  refreshButton.removeEventListener("click", onRefresh);
397
- collapseButton.removeEventListener("click", onCollapse);
511
+ body.removeEventListener("click", onBodyClick);
512
+ body.removeEventListener("keydown", onBodyKeydown);
398
513
  container.remove();
399
514
  };
400
515
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iasiv5/dsh-quota-watch",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "description": "DSH Web sidebar monitor for GLM Coding Plan quotas and GitHub Copilot credits",
5
5
  "type": "module",
6
6
  "main": "./src/host.mjs",
package/src/client.mjs CHANGED
@@ -1,68 +1,90 @@
1
1
  import { CLIENT_POLL_INTERVAL_MS, CLIENT_ROUTES } from './shared.mjs'
2
+ import { remainingToUsed } from './core/adapters.mjs'
2
3
 
3
4
  export const name = 'quota-watch-client'
4
5
  export const inject = []
5
6
 
6
7
  const CARD_SELECTOR = '[data-dsh-quota-watch-card]'
7
- const COLLAPSE_KEY = 'dsh-quota-watch:collapsed'
8
8
  const FETCH_TIMEOUT_MS = 15_000
9
9
 
10
10
  const COPY = {
11
11
  zh: {
12
12
  title: '套餐监控',
13
13
  refresh: '刷新',
14
- collapse: '收起',
15
- expand: '展开',
16
14
  loading: '读取中',
17
15
  updated: '更新',
18
- notConfigured: '未配置',
19
- noPlan: '暂无套餐窗口',
20
16
  failed: '查询失败',
21
17
  stale: '数据可能已过期',
22
18
  requestFailed: '刷新失败,显示上次成功数据',
23
19
  available: '可用额度',
24
20
  used: '本期已用',
25
- balanceUnavailable: '余额暂不可用',
26
21
  reset: '重置',
27
- windows: { '5h': '5 小时', week: '每周', month: '每月' },
22
+ todayTokens: '今日 tokens',
23
+ todayCalls: '今日调用',
24
+ windows: {
25
+ '5h': '5 小时窗口 · 已用',
26
+ week: '每周窗口 · 已用',
27
+ month: '每月窗口 · 已用',
28
+ mcp: 'MCP(月)· 已用',
29
+ },
28
30
  },
29
31
  en: {
30
32
  title: 'Quota Watch',
31
33
  refresh: 'Refresh',
32
- collapse: 'Collapse',
33
- expand: 'Expand',
34
34
  loading: 'Loading',
35
35
  updated: 'Updated',
36
- notConfigured: 'Not configured',
37
- noPlan: 'No plan windows',
38
36
  failed: 'Query failed',
39
37
  stale: 'Data may be stale',
40
38
  requestFailed: 'Refresh failed; showing last successful data',
41
39
  available: 'Available credits',
42
40
  used: 'Used this cycle',
43
- balanceUnavailable: 'Balance unavailable',
44
41
  reset: 'Resets',
45
- windows: { '5h': '5 hours', week: 'Weekly', month: 'Monthly' },
42
+ todayTokens: 'Today tokens',
43
+ todayCalls: 'Calls today',
44
+ windows: {
45
+ '5h': '5-hour window · used',
46
+ week: 'Weekly window · used',
47
+ month: 'Monthly window · used',
48
+ mcp: 'MCP (month) · used',
49
+ },
46
50
  },
47
51
  }
48
52
 
49
53
  const STYLE_TEXT = `
50
54
  [data-dsh-quota-watch-card] { box-sizing: border-box; width: 100%; margin: 0 0 8px; color: var(--dsw-alias-label-primary, inherit); font: inherit; }
51
55
  [data-dsh-quota-watch-card] *, [data-dsh-quota-watch-card] *::before, [data-dsh-quota-watch-card] *::after { box-sizing: border-box; }
52
- .dqw-card { border: 1px solid var(--dsw-alias-border-secondary, rgba(128,128,128,.35)); border-radius: 10px; background: var(--dsw-alias-bg-base, rgba(128,128,128,.08)); padding: 8px; display: flex; flex-direction: column; gap: 7px; }
56
+ .dqw-card { border: 1px solid var(--dsw-alias-border-secondary, rgba(128,128,128,.35)); border-radius: 10px; background: var(--dsw-alias-bg-base, rgba(128,128,128,.08)); padding: 8px; display: flex; flex-direction: column; gap: 6px; }
53
57
  .dqw-head { display: flex; align-items: center; gap: 4px; min-width: 0; }
54
58
  .dqw-title { flex: 1; min-width: 0; margin: 0; font-size: 12px; font-weight: 600; line-height: 18px; }
55
59
  .dqw-button { border: 0; border-radius: 6px; background: transparent; color: inherit; cursor: pointer; font: inherit; font-size: 11px; line-height: 18px; padding: 1px 4px; }
56
60
  .dqw-button:hover { background: var(--dsw-alias-bg-hover, rgba(128,128,128,.16)); }
57
61
  .dqw-button:disabled { cursor: wait; opacity: .6; }
58
62
  .dqw-meta { margin: 0; font-size: 10px; line-height: 14px; opacity: .7; }
59
- .dqw-provider { border-top: 1px solid var(--dsw-alias-border-secondary, rgba(128,128,128,.25)); padding-top: 6px; display: flex; flex-direction: column; gap: 4px; }
60
- .dqw-provider-title { margin: 0; font-size: 11px; font-weight: 600; line-height: 16px; }
63
+ .dqw-body { display: flex; flex-direction: column; }
64
+ .dqw-row-summary { display: flex; align-items: center; gap: 6px; padding: 7px 6px; margin: 1px -2px 2px; border-radius: 8px; }
65
+ .dqw-row-summary[data-expandable] { cursor: pointer; }
66
+ .dqw-row-summary[data-expandable]:hover { background: var(--dsw-alias-bg-hover, rgba(128,128,128,.12)); }
67
+ .dqw-row-summary:focus-visible { outline: 1px solid var(--dsw-alias-button-primary-fill, #5b8def); outline-offset: -1px; }
68
+ .dqw-row-summary.is-error { cursor: default; }
69
+ .dqw-label { font-size: 11px; font-weight: 600; width: 52px; flex: none; }
70
+ .dqw-bar { display: block; flex: 1; height: 4px; overflow: hidden; border-radius: 4px; background: var(--dsw-alias-bg-tertiary, rgba(128,128,128,.2)); }
71
+ .dqw-bar-fill { display: block; height: 100%; border-radius: inherit; background: var(--dsw-alias-button-primary-fill, #5b8def); }
72
+ .dqw-bar-fill.warn { background: var(--dsw-alias-label-warning, #d29922); }
73
+ .dqw-bar-fill.danger { background: var(--dsw-alias-label-danger, #c93c3c); }
74
+ .dqw-pct { font-size: 10.5px; line-height: 14px; font-variant-numeric: tabular-nums; width: 30px; text-align: right; flex: none; }
75
+ .dqw-extra { font-size: 10px; line-height: 14px; opacity: .72; font-variant-numeric: tabular-nums; flex: none; }
76
+ .dqw-chev { font-size: 9px; line-height: 14px; opacity: .6; flex: none; width: 10px; text-align: center; }
77
+ .dqw-stale-mark { flex: none; font-size: 9px; line-height: 14px; color: var(--dsw-alias-label-warning, #b46900); cursor: help; }
78
+ .dqw-errtext { font-size: 10px; line-height: 14px; opacity: .7; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
79
+ .dqw-detail { padding: 2px 6px 6px; display: flex; flex-direction: column; gap: 7px; }
80
+ .dqw-bignums { display: flex; gap: 6px; }
81
+ .dqw-big { flex: 1; border: 1px solid var(--dsw-alias-border-secondary, rgba(128,128,128,.25)); border-radius: 8px; padding: 6px 8px; }
82
+ .dqw-big .dqw-big-value { font-size: 16px; font-weight: 650; line-height: 20px; font-variant-numeric: tabular-nums; }
83
+ .dqw-big .dqw-big-label { font-size: 9.5px; line-height: 13px; opacity: .65; }
61
84
  .dqw-row { display: flex; flex-direction: column; gap: 2px; }
62
85
  .dqw-row-head { display: flex; justify-content: space-between; gap: 5px; font-size: 10px; line-height: 14px; }
63
- .dqw-bar { display: block; height: 4px; overflow: hidden; border-radius: 4px; background: var(--dsw-alias-bg-tertiary, rgba(128,128,128,.2)); }
64
- .dqw-bar-fill { display: block; height: 100%; border-radius: inherit; background: var(--dsw-alias-button-primary-fill, #5b8def); }
65
86
  .dqw-muted { margin: 0; font-size: 10px; line-height: 14px; opacity: .7; }
87
+ .dqw-models { margin: 0; font-size: 10px; line-height: 15px; opacity: .8; border-top: 1px dashed var(--dsw-alias-border-secondary, rgba(128,128,128,.25)); padding-top: 6px; }
66
88
  .dqw-error { margin: 0; font-size: 10px; line-height: 14px; color: var(--dsw-alias-label-warning, #b46900); }
67
89
  .dqw-credit { margin: 0; font-size: 10px; line-height: 14px; font-variant-numeric: tabular-nums; }
68
90
  `
@@ -76,6 +98,14 @@ function formatNumber(value, locale) {
76
98
  return new Intl.NumberFormat(locale === 'zh' ? 'zh-CN' : 'en-US', { maximumFractionDigits: 1 }).format(value)
77
99
  }
78
100
 
101
+ function formatCompact(value, locale) {
102
+ if (typeof value !== 'number' || !Number.isFinite(value)) return ''
103
+ return new Intl.NumberFormat(locale === 'zh' ? 'zh-CN' : 'en-US', {
104
+ notation: 'compact',
105
+ maximumFractionDigits: 1,
106
+ }).format(value)
107
+ }
108
+
79
109
  function formatPercent(value, locale) {
80
110
  if (typeof value !== 'number' || !Number.isFinite(value)) return ''
81
111
  const clamped = Math.max(0, Math.min(100, value))
@@ -113,102 +143,55 @@ function progress(doc, label, percent, locale) {
113
143
  bar.setAttribute('aria-valuemax', '100')
114
144
  bar.setAttribute('aria-valuenow', String(Math.max(0, Math.min(100, percent))))
115
145
  const fill = doc.createElement('span')
116
- fill.className = 'dqw-bar-fill'
146
+ fill.className = `dqw-bar-fill${percent > 95 ? ' danger' : percent > 80 ? ' warn' : ''}`
117
147
  fill.style.width = `${Math.max(0, Math.min(100, percent))}%`
118
148
  bar.append(fill)
119
149
  row.append(head, bar)
120
150
  return row
121
151
  }
122
152
 
123
- function providerSection(doc, key, heading) {
124
- const section = doc.createElement('section')
125
- section.className = 'dqw-provider'
126
- section.dataset.provider = key
127
- section.append(text(doc, 'h4', 'dqw-provider-title', heading))
128
- const content = doc.createElement('div')
129
- content.dataset.role = 'content'
130
- content.className = 'dqw-content'
131
- section.append(content)
132
- return section
133
- }
134
-
135
- function appendStaleError(doc, content, provider, copy) {
136
- if (provider.status === 'stale') content.append(text(doc, 'p', 'dqw-error', `${copy.stale}: ${provider.error ?? ''}`))
137
- else if (provider.status === 'error') content.append(text(doc, 'p', 'dqw-error', provider.error ?? copy.failed))
138
- }
139
-
140
- function renderGlm(doc, section, provider, copy, locale) {
141
- const content = section.querySelector('[data-role="content"]')
142
- content.replaceChildren()
143
- appendStaleError(doc, content, provider, copy)
144
- if (provider.status === 'missing') {
145
- content.append(text(doc, 'p', 'dqw-muted', copy.notConfigured))
146
- return
147
- }
148
- if (provider.status === 'loading') {
149
- content.append(text(doc, 'p', 'dqw-muted', copy.loading))
150
- return
151
- }
152
- if (!provider.plan?.windows?.length) {
153
- if (provider.status !== 'error') content.append(text(doc, 'p', 'dqw-muted', copy.noPlan))
154
- return
155
- }
156
- const planName = provider.plan.planName
157
- if (planName) content.append(text(doc, 'p', 'dqw-muted', planName))
158
- for (const window of provider.plan.windows) {
159
- const label = window.name || copy.windows[window.key] || window.key
160
- if (typeof window.percent === 'number' && Number.isFinite(window.percent)) {
161
- content.append(progress(doc, label, window.percent, locale))
162
- } else {
163
- content.append(text(doc, 'p', 'dqw-muted', label))
164
- }
165
- const reset = formatTime(window.resetsAt, locale)
166
- if (reset) content.append(text(doc, 'p', 'dqw-muted', `${copy.reset}: ${reset}`))
153
+ /** GLM summary row view model: fixed 5h-window percent plus today's compact tokens. */
154
+ function glmRowModel(provider) {
155
+ if (provider?.status === 'missing') return null
156
+ if (provider?.status === 'error') return { kind: 'error', label: 'GLM', error: provider.error }
157
+ const windows = provider?.plan?.windows
158
+ if (!Array.isArray(windows) || windows.length === 0) return null
159
+ const five = windows.find((window) => window?.key === '5h')
160
+ const tokens = provider?.usage?.status === 'ready' || provider?.usage?.status === 'stale'
161
+ ? provider?.usage?.totalTokens
162
+ : undefined
163
+ return {
164
+ kind: 'data',
165
+ label: 'GLM',
166
+ percent: five?.percent,
167
+ stale: provider.status === 'stale',
168
+ error: provider.error,
169
+ extra: tokens,
167
170
  }
168
171
  }
169
172
 
170
- function renderCopilot(doc, section, provider, copy, locale) {
171
- const content = section.querySelector('[data-role="content"]')
172
- content.replaceChildren()
173
- appendStaleError(doc, content, provider, copy)
174
- if (provider.status === 'missing') {
175
- content.append(text(doc, 'p', 'dqw-muted', copy.notConfigured))
176
- return
177
- }
178
- if (provider.status === 'loading') {
179
- content.append(text(doc, 'p', 'dqw-muted', copy.loading))
180
- return
173
+ /** Copilot summary row view model: used% inverted from the reported remaining percent. */
174
+ function copilotRowModel(provider) {
175
+ if (provider?.status === 'missing') return null
176
+ if (provider?.status === 'error') return { kind: 'error', label: 'Copilot', error: provider.error }
177
+ const quota = provider?.quota
178
+ if (quota?.balanceAvailable !== true) return null
179
+ const used = remainingToUsed(quota.percentRemaining)
180
+ if (used === undefined) return null
181
+ return {
182
+ kind: 'data',
183
+ label: 'Copilot',
184
+ percent: used,
185
+ stale: provider.status === 'stale',
186
+ error: provider.error,
181
187
  }
182
- const quota = provider.quota
183
- if (quota?.planName) content.append(text(doc, 'p', 'dqw-muted', quota.planName))
184
- if (quota?.balanceAvailable === true) {
185
- content.append(text(doc, 'p', 'dqw-credit', `${copy.available}: ${formatNumber(quota.remaining, locale)} / ${formatNumber(quota.entitlement, locale)}`))
186
- if (typeof quota.percentRemaining === 'number') {
187
- content.append(progress(doc, copy.available, quota.percentRemaining, locale))
188
- }
189
- } else {
190
- content.append(text(doc, 'p', 'dqw-muted', copy.balanceUnavailable))
191
- }
192
- if (typeof quota?.creditsUsed === 'number') {
193
- content.append(text(doc, 'p', 'dqw-muted', `${copy.used}: ${formatNumber(quota.creditsUsed, locale)}`))
194
- }
195
- const reset = formatTime(quota?.resetsAt, locale)
196
- if (reset) content.append(text(doc, 'p', 'dqw-muted', `${copy.reset}: ${reset}`))
197
188
  }
198
189
 
199
190
  function footArea(doc) {
200
- const sidebar = doc.querySelector('[data-pane="sidebar"], [class*="sidebarCol"]')
191
+ const sidebar = doc.querySelector('[class*="sidebarCol"]')
201
192
  return sidebar?.querySelector('[class*="footArea"]') ?? undefined
202
193
  }
203
194
 
204
- function readCollapsed(win) {
205
- try { return win.localStorage.getItem(COLLAPSE_KEY) === '1' } catch { return false }
206
- }
207
-
208
- function writeCollapsed(win, value) {
209
- try { win.localStorage.setItem(COLLAPSE_KEY, value ? '1' : '0') } catch { /* Storage may be unavailable. */ }
210
- }
211
-
212
195
  async function fetchJson(fetchImpl, path, method) {
213
196
  const response = await fetchImpl(path, {
214
197
  ...(method === 'POST' ? { method: 'POST' } : {}),
@@ -247,42 +230,146 @@ export function mountQuotaCard({
247
230
  refreshButton.title = copy.refresh
248
231
  refreshButton.setAttribute('aria-label', copy.refresh)
249
232
  refreshButton.dataset.action = 'refresh'
250
- const collapseButton = text(doc, 'button', 'dqw-button', '−')
251
- collapseButton.type = 'button'
252
- collapseButton.dataset.action = 'collapse'
233
+ header.append(refreshButton)
253
234
  const body = doc.createElement('div')
254
235
  body.className = 'dqw-body'
255
236
  const lastUpdated = text(doc, 'p', 'dqw-meta', copy.loading)
256
237
  lastUpdated.dataset.role = 'updated'
257
- const glmSection = providerSection(doc, 'glm', 'GLM Coding Plan')
258
- const copilotSection = providerSection(doc, 'copilot', 'GitHub Copilot')
259
- body.append(lastUpdated, glmSection, copilotSection)
260
- header.append(refreshButton, collapseButton)
238
+ body.append(lastUpdated)
261
239
  card.append(header, body)
262
240
  container.append(style, card)
263
- let collapsed = readCollapsed(win)
264
241
  let snapshot
265
242
  let requestSequence = 0
266
243
  let timer
267
244
  let disposed = false
268
245
  let queuedPlace = false
246
+ let openKey
247
+
248
+ const fillClass = (percent) => (percent > 95 ? 'dqw-bar-fill danger' : percent > 80 ? 'dqw-bar-fill warn' : 'dqw-bar-fill')
249
+
250
+ /** Build [row, detail] fragments for one provider, or null when it must not render. */
251
+ const renderProvider = (key, model, provider) => {
252
+ if (model === null) return null
253
+ const row = doc.createElement('div')
254
+ row.className = 'dqw-row-summary'
255
+ row.dataset.dshQuotaWatchRow = key
256
+ row.append(text(doc, 'span', 'dqw-label', model.label))
257
+ if (model.kind === 'error') {
258
+ row.classList.add('is-error')
259
+ row.append(text(doc, 'span', 'dqw-errtext', model.error ? `${copy.failed}: ${model.error}` : copy.failed))
260
+ return [row]
261
+ }
262
+ row.setAttribute('role', 'button')
263
+ row.tabIndex = 0
264
+ row.setAttribute('aria-expanded', String(openKey === key))
265
+ row.dataset.expandable = ''
266
+ if (typeof model.percent === 'number' && Number.isFinite(model.percent)) {
267
+ const bar = doc.createElement('span')
268
+ bar.className = 'dqw-bar'
269
+ bar.setAttribute('role', 'progressbar')
270
+ bar.setAttribute('aria-valuemin', '0')
271
+ bar.setAttribute('aria-valuemax', '100')
272
+ bar.setAttribute('aria-valuenow', String(Math.max(0, Math.min(100, model.percent))))
273
+ const fill = text(doc, 'span', fillClass(model.percent))
274
+ fill.style.width = `${Math.max(0, Math.min(100, model.percent))}%`
275
+ bar.append(fill)
276
+ row.append(bar)
277
+ }
278
+ if (model.stale) {
279
+ const mark = text(doc, 'span', 'dqw-stale-mark', '⚠')
280
+ mark.dataset.dshQuotaWatchStale = ''
281
+ mark.title = model.error ?? copy.stale
282
+ row.append(mark)
283
+ }
284
+ row.append(text(doc, 'span', 'dqw-pct', typeof model.percent === 'number' ? formatPercent(model.percent, locale) : '—'))
285
+ if (model.extra !== undefined) {
286
+ const extra = text(doc, 'span', 'dqw-extra', formatCompact(model.extra, locale))
287
+ extra.dataset.dshQuotaWatchExtra = ''
288
+ row.append(extra)
289
+ }
290
+ row.append(text(doc, 'span', 'dqw-chev', openKey === key ? '▾' : '▸'))
291
+ const detail = doc.createElement('div')
292
+ detail.className = 'dqw-detail'
293
+ detail.dataset.dshQuotaWatchDetail = key
294
+ if (openKey === key) renderDetail(detail, key, provider)
295
+ return [row, detail]
296
+ }
297
+
298
+ function renderDetail(detail, key, provider) {
299
+ detail.replaceChildren()
300
+ if (provider?.status === 'stale') {
301
+ detail.append(text(doc, 'p', 'dqw-error', `${copy.stale}${provider.error ? `: ${provider.error}` : ''}`))
302
+ }
303
+ if (key === 'glm') {
304
+ const usage = provider?.usage
305
+ const numbers = doc.createElement('div')
306
+ numbers.className = 'dqw-bignums'
307
+ const tokensBig = doc.createElement('div')
308
+ tokensBig.className = 'dqw-big'
309
+ tokensBig.append(
310
+ text(doc, 'div', 'dqw-big-value', formatCompact(usage?.totalTokens, locale)),
311
+ text(doc, 'div', 'dqw-big-label', copy.todayTokens),
312
+ )
313
+ const callsBig = doc.createElement('div')
314
+ callsBig.className = 'dqw-big'
315
+ callsBig.append(
316
+ text(doc, 'div', 'dqw-big-value', formatNumber(usage?.totalCalls, locale)),
317
+ text(doc, 'div', 'dqw-big-label', copy.todayCalls),
318
+ )
319
+ numbers.append(tokensBig, callsBig)
320
+ detail.append(numbers)
321
+ for (const window of provider?.plan?.windows ?? []) {
322
+ const label = copy.windows[window?.key] ?? window?.key
323
+ if (typeof window?.percent === 'number' && Number.isFinite(window.percent)) {
324
+ detail.append(progress(doc, label, window.percent, locale))
325
+ } else {
326
+ detail.append(text(doc, 'p', 'dqw-muted', label))
327
+ }
328
+ const reset = formatTime(window?.resetsAt, locale)
329
+ if (reset) detail.append(text(doc, 'p', 'dqw-muted', `${copy.reset}: ${reset}`))
330
+ }
331
+ const models = Array.isArray(usage?.models) && usage.models.length > 0
332
+ ? usage.models.map((model) => `${model.name} ${formatCompact(model.tokens, locale)}`).join(' · ')
333
+ : undefined
334
+ if (models) detail.append(text(doc, 'p', 'dqw-models', models))
335
+ const updated = formatTime(provider?.updatedAt ? new Date(provider.updatedAt).toISOString() : '', locale)
336
+ if (updated) detail.append(text(doc, 'p', 'dqw-meta', `${copy.updated}: ${updated}`))
337
+ return
338
+ }
339
+ const quota = provider?.quota
340
+ if (quota?.balanceAvailable === true) {
341
+ detail.append(text(doc, 'p', 'dqw-credit', `${copy.available}: ${formatNumber(quota.remaining, locale)} / ${formatNumber(quota.entitlement, locale)}`))
342
+ const used = remainingToUsed(quota.percentRemaining)
343
+ if (used !== undefined) detail.append(progress(doc, copy.used, used, locale))
344
+ if (typeof quota.creditsUsed === 'number' && quota.creditsUsed >= 0) {
345
+ detail.append(text(doc, 'p', 'dqw-muted', `${copy.used}: ${formatNumber(quota.creditsUsed, locale)}`))
346
+ }
347
+ } else {
348
+ detail.append(text(doc, 'p', 'dqw-muted', copy.stale))
349
+ }
350
+ const reset = formatTime(quota?.resetsAt, locale)
351
+ if (reset) detail.append(text(doc, 'p', 'dqw-muted', `${copy.reset}: ${reset}`))
352
+ }
269
353
 
270
- const applyCollapsed = () => {
271
- body.hidden = collapsed
272
- collapseButton.textContent = collapsed ? '+' : '−'
273
- collapseButton.title = collapsed ? copy.expand : copy.collapse
274
- collapseButton.setAttribute('aria-label', collapseButton.title)
275
- collapseButton.setAttribute('aria-expanded', String(!collapsed))
354
+ const renderAll = () => {
355
+ const providers = snapshot?.providers ?? []
356
+ const glm = providers.find((provider) => provider?.key === 'glm')
357
+ const copilot = providers.find((provider) => provider?.key === 'copilot')
358
+ const glmParts = renderProvider('glm', glmRowModel(glm), glm)
359
+ const copilotParts = renderProvider('copilot', copilotRowModel(copilot), copilot)
360
+ const fragment = doc.createDocumentFragment()
361
+ fragment.append(lastUpdated)
362
+ if (glmParts) fragment.append(...glmParts)
363
+ if (copilotParts) fragment.append(...copilotParts)
364
+ body.replaceChildren(fragment)
365
+ container.hidden = glmParts === null && copilotParts === null
366
+ const updated = formatTime(snapshot?.updatedAt ? new Date(snapshot.updatedAt).toISOString() : '', locale)
367
+ lastUpdated.textContent = updated ? `${copy.updated}: ${updated}` : copy.loading
276
368
  }
277
369
 
278
370
  const render = (next) => {
279
371
  snapshot = next
280
- const glm = next?.providers?.find((provider) => provider.key === 'glm')
281
- const copilot = next?.providers?.find((provider) => provider.key === 'copilot')
282
- if (glm) renderGlm(doc, glmSection, glm, copy, locale)
283
- if (copilot) renderCopilot(doc, copilotSection, copilot, copy, locale)
284
- const updated = formatTime(next?.updatedAt ? new Date(next.updatedAt).toISOString() : '', locale)
285
- lastUpdated.textContent = updated ? `${copy.updated}: ${updated}` : copy.loading
372
+ renderAll()
286
373
  }
287
374
 
288
375
  const showTransportError = () => {
@@ -327,7 +414,6 @@ export function mountQuotaCard({
327
414
  const observer = new win.MutationObserver(schedulePlace)
328
415
  observer.observe(doc.body, { childList: true, subtree: true })
329
416
  place()
330
- applyCollapsed()
331
417
 
332
418
  const startPolling = () => {
333
419
  if (timer !== undefined || doc.visibilityState === 'hidden' || disposed) return
@@ -343,13 +429,25 @@ export function mountQuotaCard({
343
429
  else startPolling()
344
430
  }
345
431
  const onRefresh = () => { void poll(true) }
346
- const onCollapse = () => {
347
- collapsed = !collapsed
348
- writeCollapsed(win, collapsed)
349
- applyCollapsed()
432
+ const toggleRow = (key) => {
433
+ openKey = openKey === key ? undefined : key
434
+ renderAll()
435
+ }
436
+ const onBodyClick = (event) => {
437
+ const row = event.target?.closest?.('[data-dsh-quota-watch-row][data-expandable]')
438
+ if (row) toggleRow(row.dataset.dshQuotaWatchRow)
439
+ }
440
+ const onBodyKeydown = (event) => {
441
+ if (event.key !== 'Enter' && event.key !== ' ') return
442
+ const row = event.target?.closest?.('[data-dsh-quota-watch-row][data-expandable]')
443
+ if (row) {
444
+ event.preventDefault()
445
+ toggleRow(row.dataset.dshQuotaWatchRow)
446
+ }
350
447
  }
351
448
  refreshButton.addEventListener('click', onRefresh)
352
- collapseButton.addEventListener('click', onCollapse)
449
+ body.addEventListener('click', onBodyClick)
450
+ body.addEventListener('keydown', onBodyKeydown)
353
451
  doc.addEventListener('visibilitychange', onVisibilityChange)
354
452
  startPolling()
355
453
 
@@ -359,7 +457,8 @@ export function mountQuotaCard({
359
457
  observer.disconnect()
360
458
  doc.removeEventListener('visibilitychange', onVisibilityChange)
361
459
  refreshButton.removeEventListener('click', onRefresh)
362
- collapseButton.removeEventListener('click', onCollapse)
460
+ body.removeEventListener('click', onBodyClick)
461
+ body.removeEventListener('keydown', onBodyKeydown)
363
462
  container.remove()
364
463
  }
365
464
  }
@@ -49,7 +49,7 @@ function toIso(value) {
49
49
  }
50
50
 
51
51
  function glmPercent(row, kind) {
52
- if (kind === 'CREDIT_LIMIT') {
52
+ if (kind === 'CREDIT_LIMIT' || kind === 'TIME_LIMIT') {
53
53
  const current = asNumber(row.currentValue)
54
54
  const total = asNumber(row.usage)
55
55
  if (current !== undefined && total !== undefined && total > 0) {
@@ -74,6 +74,45 @@ export function buildGlmProbe({ provider, apiKey } = {}) {
74
74
  }
75
75
  }
76
76
 
77
+ const GLM_USAGE_PATH = '/api/monitor/usage/model-usage'
78
+
79
+ /** Build a GLM model-usage request over a formatted local-time interval. */
80
+ export function buildGlmUsageProbe({ provider, apiKey, startTime, endTime } = {}) {
81
+ const host = GLM_HOSTS[provider]
82
+ const key = asString(apiKey)
83
+ const from = asString(startTime)
84
+ const to = asString(endTime)
85
+ if (host === undefined || key === undefined || from === undefined || to === undefined) return undefined
86
+ return {
87
+ url: `https://${host}${GLM_USAGE_PATH}?startTime=${encodeURIComponent(from)}&endTime=${encodeURIComponent(to)}`,
88
+ headers: {
89
+ authorization: key,
90
+ 'accept-language': 'en-US,en',
91
+ },
92
+ }
93
+ }
94
+
95
+ /** Format a Date as the provider's local-time `yyyy-MM-dd HH:mm:ss` interval stamp. */
96
+ export function formatGlmIntervalTime(date) {
97
+ if (!(date instanceof Date) || Number.isNaN(date.getTime())) return undefined
98
+ const pad = (value) => String(value).padStart(2, '0')
99
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
100
+ }
101
+
102
+ /** The host-local calendar day so far, as a model-usage query interval. */
103
+ export function usageDayWindow(now = new Date()) {
104
+ const end = formatGlmIntervalTime(now)
105
+ if (end === undefined) return undefined
106
+ return { startTime: `${end.slice(0, 10)} 00:00:00`, endTime: end }
107
+ }
108
+
109
+ /** Normalize a provider-reported remaining percentage to used, without other fields. */
110
+ export function remainingToUsed(percentRemaining) {
111
+ const value = asNumber(percentRemaining)
112
+ if (value === undefined || value < 0 || value > 100) return undefined
113
+ return clampPercent(Math.round((100 - value) * 100) / 100)
114
+ }
115
+
77
116
  /** Normalize GLM's provider-defined quota windows without relying on row order. */
78
117
  export function parseGlmPlan(status, body) {
79
118
  if (status !== 200) return undefined
@@ -87,13 +126,20 @@ export function parseGlmPlan(status, body) {
87
126
  const row = asRecord(candidate)
88
127
  if (row === undefined) continue
89
128
  const kind = asString(row.type)
90
- if (kind === 'TIME_LIMIT') continue
91
- if (kind !== 'TOKENS_LIMIT' && kind !== 'CREDIT_LIMIT') continue
129
+ if (kind !== 'TOKENS_LIMIT' && kind !== 'CREDIT_LIMIT' && kind !== 'TIME_LIMIT') continue
130
+ const percent = glmPercent(row, kind)
131
+ const resetsAt = toIso(row.nextResetTime)
132
+ if (kind === 'TIME_LIMIT') {
133
+ windows.push({
134
+ key: 'mcp',
135
+ ...(percent !== undefined ? { percent } : {}),
136
+ ...(resetsAt !== undefined ? { resetsAt } : {}),
137
+ })
138
+ continue
139
+ }
92
140
  const unit = asNumber(row.unit)
93
141
  const key = GLM_WINDOW_KEYS[unit]
94
142
  if (key === undefined) continue
95
- const percent = glmPercent(row, kind)
96
- const resetsAt = toIso(row.nextResetTime)
97
143
  windows.push({
98
144
  key,
99
145
  ...(percent !== undefined ? { percent } : {}),
@@ -106,6 +152,29 @@ export function parseGlmPlan(status, body) {
106
152
  return { ...(planName !== undefined ? { planName } : {}), windows }
107
153
  }
108
154
 
155
+ /** Normalize GLM's provider-reported usage totals for a queried interval. */
156
+ export function parseGlmUsage(status, body) {
157
+ if (status !== 200) return undefined
158
+ const root = asRecord(body)
159
+ if (root?.success !== true) return undefined
160
+ const data = asRecord(root.data)
161
+ const total = asRecord(data?.totalUsage)
162
+ const totalTokens = asNumber(total?.totalTokensUsage)
163
+ const totalCalls = asNumber(total?.totalModelCallCount)
164
+ if (totalTokens === undefined || totalTokens < 0) return undefined
165
+ if (totalCalls === undefined || totalCalls < 0) return undefined
166
+ const list = Array.isArray(total.modelSummaryList) ? total.modelSummaryList : []
167
+ const models = []
168
+ for (const candidate of list) {
169
+ const row = asRecord(candidate)
170
+ const name = asString(row?.modelName)
171
+ const tokens = asNumber(row?.totalTokens)
172
+ if (name === undefined || tokens === undefined || tokens < 0) continue
173
+ models.push({ name, tokens })
174
+ }
175
+ return { totalTokens, totalCalls, ...(models.length > 0 ? { models } : {}) }
176
+ }
177
+
109
178
  /** Build the Copilot user-quota request using the raw GitHub OAuth grant. */
110
179
  export function buildCopilotProbe({ githubOAuthToken } = {}) {
111
180
  const token = asString(githubOAuthToken)
package/src/host.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { credentialKey, credentialRef, isCredentialRefName } from '@deepseek-ai/dsh-credentials'
2
- import { buildCopilotProbe, buildGlmProbe, parseCopilotUser, parseGlmPlan } from './core/adapters.mjs'
2
+ import { buildCopilotProbe, buildGlmProbe, buildGlmUsageProbe, parseCopilotUser, parseGlmPlan, parseGlmUsage, usageDayWindow } from './core/adapters.mjs'
3
3
  import {
4
4
  DEFAULT_POLL_INTERVAL_SEC,
5
5
  MAX_POLL_INTERVAL_SEC,
@@ -133,6 +133,7 @@ export function createQuotaService(ctx, options = {}) {
133
133
  ...(provider.error !== undefined ? { error: provider.error } : {}),
134
134
  ...(provider.plan !== undefined ? { plan: provider.plan } : {}),
135
135
  ...(provider.quota !== undefined ? { quota: provider.quota } : {}),
136
+ ...(provider.usage !== undefined ? { usage: provider.usage } : {}),
136
137
  }
137
138
  }),
138
139
  }
@@ -161,6 +162,52 @@ export function createQuotaService(ctx, options = {}) {
161
162
  })
162
163
  }
163
164
 
165
+ function markGlmPlanSuccess(plan) {
166
+ const previous = providers.get('glm')
167
+ const at = now()
168
+ providers.set('glm', {
169
+ key: 'glm',
170
+ displayName: 'GLM Coding Plan',
171
+ status: 'ready',
172
+ credential: 'configured',
173
+ updatedAt: at,
174
+ checkedAt: at,
175
+ plan,
176
+ ...(previous?.usage !== undefined ? { usage: previous.usage } : {}),
177
+ })
178
+ }
179
+
180
+ function markGlmUsageSuccess(usage) {
181
+ const previous = providers.get('glm')
182
+ if (previous === undefined || previous.key !== 'glm') return
183
+ const at = now()
184
+ providers.set('glm', {
185
+ ...previous,
186
+ status: 'ready',
187
+ credential: 'configured',
188
+ updatedAt: at,
189
+ checkedAt: at,
190
+ usage: { status: 'ready', updatedAt: at, ...usage },
191
+ })
192
+ }
193
+
194
+ function markGlmUsageFailure(error) {
195
+ const previous = providers.get('glm')
196
+ if (previous === undefined || previous.key !== 'glm') return
197
+ const lastGood = previous.usage?.status === 'ready' || previous.usage?.status === 'stale'
198
+ ? previous.usage
199
+ : undefined
200
+ providers.set('glm', {
201
+ ...previous,
202
+ checkedAt: now(),
203
+ usage: {
204
+ ...(lastGood !== undefined ? lastGood : {}),
205
+ status: lastGood !== undefined ? 'stale' : 'error',
206
+ error: safeProbeError(error),
207
+ },
208
+ })
209
+ }
210
+
164
211
  function markFailure(key, displayName, error) {
165
212
  const previous = providers.get(key)
166
213
  const checkedAt = now()
@@ -206,9 +253,26 @@ export function createQuotaService(ctx, options = {}) {
206
253
  const { status, body } = await fetchBody(spec)
207
254
  const plan = parseGlmPlan(status, body)
208
255
  if (plan === undefined) throw Object.assign(new Error(), { code: 'UNRECOGNIZED_RESPONSE' })
209
- markSuccess('glm', 'GLM Coding Plan', { plan })
256
+ markGlmPlanSuccess(plan)
210
257
  } catch (error) {
211
258
  markFailure('glm', 'GLM Coding Plan', error)
259
+ return
260
+ }
261
+ await probeGlmUsage(provider, apiKey)
262
+ }
263
+
264
+ async function probeGlmUsage(provider, apiKey) {
265
+ const window = usageDayWindow(new Date(now()))
266
+ if (window === undefined) return
267
+ try {
268
+ const spec = buildGlmUsageProbe({ provider, apiKey, startTime: window.startTime, endTime: window.endTime })
269
+ if (spec === undefined) throw Object.assign(new Error(), { code: 'UNRECOGNIZED_RESPONSE' })
270
+ const { status, body } = await fetchBody(spec)
271
+ const usage = parseGlmUsage(status, body)
272
+ if (usage === undefined) throw Object.assign(new Error(), { code: 'UNRECOGNIZED_RESPONSE' })
273
+ markGlmUsageSuccess(usage)
274
+ } catch (error) {
275
+ markGlmUsageFailure(error)
212
276
  }
213
277
  }
214
278