@dungsil/dsh-plugin-ui-usage 0.1.0 → 0.2.0

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 ADDED
@@ -0,0 +1,20 @@
1
+ # Usage Dashboard · 사용량 대시보드
2
+
3
+ ![사용량 대시보드의 라이트 모드와 다크 모드를 꼭짓점 대각선으로 나눈 미리보기](./assets/light-dark-preview.webp)
4
+
5
+ DSH Web 사이드바에서 세션에 기록된 토큰 사용량, 일별 추이와 제공자·모델별 사용량을 확인합니다. 청구서나 제공자 계정의 잔액 조회 화면은 아닙니다.
6
+
7
+ 위 이미지는 최신 빌드의 실제 플러그인 컴포넌트에 예시 사용량 응답과 공식 DSH 테마·UI primitives를 제공한 독립 브라우저 화면을 각각 캡처해 합성했습니다. 실제 DSH 세션 화면은 아니며, 모델·날짜·수치는 모두 예시입니다.
8
+
9
+ ## 사용 방법
10
+
11
+ 1. DSH 플러그인 설치 화면에서 `@dungsil/dsh-plugin-ui-usage`를 설치하고 활성화합니다.
12
+ 2. 사이드바의 **사용량**을 엽니다. **최근 7일**, **최근 30일**, **전체 기간** 중 조회 범위를 선택합니다.
13
+ 3. 일별 막대를 눌러 해당 날짜의 토큰 수를 확인합니다. 최신 기록을 다시 읽으려면 **새로고침**을 누릅니다.
14
+
15
+ ## 집계 범위와 호환성
16
+
17
+ - DSH `0.2.1-rc.5` 이상 `0.3.0` 미만의 Web UI를 지원합니다. 한국어·영어·중국어(간체)와 DSH 테마를 따릅니다.
18
+ - 세션에 기록된 호출만 집계하며 하위 에이전트와 재시도를 포함합니다. 분기에서 상속된 기록은 한 번만 셉니다. 삭제한 세션과 제목 생성 등 세션 밖 호출은 제외합니다.
19
+ - 입력은 캐시 읽기·쓰기를 제외하고, 추론 토큰은 출력에 포함합니다. 제공자가 전체 토큰 수를 보고하면 해당 값을 사용합니다. 토큰 수 미보고 호출이나 읽지 못한 세션은 별도로 알립니다.
20
+ - 날짜는 브라우저 시간대를 사용합니다. 전체 기간을 선택해도 일별 차트는 최근 30일을 표시합니다. 자동 실시간 갱신 대신 새로고침으로 기록을 다시 조회합니다.
Binary file
package/dist/client.js CHANGED
@@ -1,8 +1,8 @@
1
1
  window.__ModuleLoader__.load({ id: "@dungsil/dsh-plugin-ui-usage", factory(require) { const module = { exports: {} }; const exports = module.exports;
2
2
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
3
3
  let react = require("react");
4
- let _deepseek_ai_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives");
5
4
  let react_jsx_runtime = require("react/jsx-runtime");
5
+ let _deepseek_ai_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives");
6
6
  // ../../tools/ui/src/client.ts
7
7
  function useLocaleSnapshot(locale) {
8
8
  const subscribe = (0, react.useCallback)((listener) => locale.subscribe(listener), [locale]);
@@ -25,6 +25,7 @@ const copy = {
25
25
  month: "Last 30 days",
26
26
  all: "All time",
27
27
  refresh: "Refresh",
28
+ refreshing: "Refreshing…",
28
29
  loading: "Loading usage…",
29
30
  error: "Could not load usage. Try refreshing.",
30
31
  empty: "No recorded model calls in this period.",
@@ -52,6 +53,7 @@ const copy = {
52
53
  month: "최근 30일",
53
54
  all: "전체 기간",
54
55
  refresh: "새로고침",
56
+ refreshing: "새로고침 중…",
55
57
  loading: "사용량을 불러오는 중입니다…",
56
58
  error: "사용량을 불러오지 못했습니다. 새로고침해 주세요.",
57
59
  empty: "이 기간에 기록된 모델 호출이 없습니다.",
@@ -79,6 +81,7 @@ const copy = {
79
81
  month: "最近 30 天",
80
82
  all: "全部时间",
81
83
  refresh: "刷新",
84
+ refreshing: "正在刷新…",
82
85
  loading: "正在加载用量…",
83
86
  error: "无法加载用量,请刷新重试。",
84
87
  empty: "此时间范围内没有记录的模型调用。",
@@ -111,6 +114,7 @@ const inject = [
111
114
  "locale",
112
115
  "slots"
113
116
  ];
117
+ const cachedViews = /* @__PURE__ */ new WeakMap();
114
118
  const styles = `
115
119
  .dsh-usage-page { box-sizing: border-box; height: 100%; overflow: auto; padding: 24px; padding-inline-start: max(24px, var(--dsh-frame-leading-clearance, 0px)); }
116
120
  .dsh-usage-page > .dsh-usage { max-width: 960px; margin: 0 auto; }
@@ -118,6 +122,10 @@ const styles = `
118
122
  .dsh-usage { container-type: inline-size; overflow-wrap: anywhere; color: var(--dsw-alias-label-primary); font: 14px/22px var(--dsw-font-family, system-ui); }
119
123
  .dsh-usage-toolbar { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; }
120
124
  .dsh-usage-status { display: flex; align-items: center; gap: 6px; }
125
+ @keyframes dsh-usage-spin { to { transform: rotate(360deg); } }
126
+ @media (prefers-reduced-motion: no-preference) {
127
+ .dsh-usage-refresh[aria-busy=true] svg { animation: dsh-usage-spin .8s linear infinite; }
128
+ }
121
129
  .dsh-usage-muted, .dsh-usage dt { color: var(--dsw-alias-label-secondary); font-size: 12px; }
122
130
  .dsh-usage h3 { margin: 24px 0 12px; font-size: 14px; font-weight: 500; }
123
131
  .dsh-usage dl { margin: 0; }
@@ -152,30 +160,47 @@ function Usage({ ctx }) {
152
160
  const locale = useLocaleSnapshot(ctx.locale);
153
161
  const text = localizedCopy(locale.active);
154
162
  const number = new Intl.NumberFormat(locale.active);
163
+ const tokens = new Intl.NumberFormat(locale.active, {
164
+ notation: "compact",
165
+ maximumFractionDigits: 1
166
+ });
155
167
  const [period, setPeriod] = (0, react.useState)(7);
156
168
  const [periodOpen, setPeriodOpen] = (0, react.useState)(false);
157
169
  const [refresh, setRefresh] = (0, react.useState)(0);
158
- const [view, setView] = (0, react.useState)();
159
- const [busy, setBusy] = (0, react.useState)(true);
160
- const [error, setError] = (0, react.useState)(false);
161
- const [selectedDay, setSelectedDay] = (0, react.useState)("");
170
+ const refreshRequested = (0, react.useRef)(false);
162
171
  const connection = ctx.get("connection");
172
+ const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
173
+ const cacheKey = JSON.stringify([period, timeZone]);
174
+ let cache = cachedViews.get(connection);
175
+ if (!cache) cachedViews.set(connection, cache = /* @__PURE__ */ new Map());
176
+ const [view, setView] = (0, react.useState)(() => cache.get(cacheKey));
177
+ const [busy, setBusy] = (0, react.useState)(() => !cache.has(cacheKey));
178
+ const [error, setError] = (0, react.useState)(false);
179
+ const [selectedDay, setSelectedDay] = (0, react.useState)(() => view?.days.at(-1)?.day ?? "");
163
180
  (0, react.useEffect)(() => {
181
+ const cached = cache.get(cacheKey);
182
+ setError(false);
183
+ setView(cached);
184
+ if (cached && !refreshRequested.current) {
185
+ setBusy(false);
186
+ setSelectedDay((day) => cached.days.some((entry) => entry.day === day) ? day : cached.days.at(-1)?.day ?? "");
187
+ return;
188
+ }
189
+ refreshRequested.current = false;
164
190
  const controller = new AbortController();
165
191
  setBusy(true);
166
- setError(false);
167
- setView(void 0);
168
192
  (async () => {
169
193
  try {
170
194
  const result = await connection.rpc.call(channel, method, {
171
195
  days: period,
172
- timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone
196
+ timeZone
173
197
  }, controller.signal);
174
198
  if (controller.signal.aborted) return;
175
199
  if (!result.ok) throw new Error("Usage unavailable");
176
200
  const next = result.value;
201
+ cache.set(cacheKey, next);
177
202
  setView(next);
178
- setSelectedDay(next.days.at(-1)?.day ?? "");
203
+ setSelectedDay((day) => next.days.some((entry) => entry.day === day) ? day : next.days.at(-1)?.day ?? "");
179
204
  } catch {
180
205
  if (!controller.signal.aborted) setError(true);
181
206
  } finally {
@@ -186,6 +211,9 @@ function Usage({ ctx }) {
186
211
  }, [
187
212
  connection,
188
213
  period,
214
+ timeZone,
215
+ cache,
216
+ cacheKey,
189
217
  refresh
190
218
  ]);
191
219
  const metrics = [
@@ -243,13 +271,18 @@ function Usage({ ctx }) {
243
271
  })
244
272
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
245
273
  variant: "outline",
274
+ className: "dsh-usage-refresh",
275
+ "aria-busy": busy,
246
276
  icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconRefreshOutlineRegular, {}),
247
277
  disabled: busy,
248
- onClick: () => setRefresh((value) => value + 1),
249
- children: text.refresh
278
+ onClick: () => {
279
+ refreshRequested.current = true;
280
+ setRefresh((value) => value + 1);
281
+ },
282
+ children: busy ? text.refreshing : text.refresh
250
283
  })]
251
284
  }),
252
- busy && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
285
+ busy && !view && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
253
286
  role: "status",
254
287
  className: "dsh-usage-status",
255
288
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.StateDot, { state: "ongoing" }), text.loading]
@@ -263,8 +296,12 @@ function Usage({ ctx }) {
263
296
  className: "dsh-usage-summary",
264
297
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("dt", { children: text.total }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("dd", {
265
298
  className: "dsh-usage-total",
266
- children: number.format(view.totals.total)
267
- })] }), metrics.map((key) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("dt", { children: text[key] }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("dd", { children: number.format(view.totals[key]) })] }, key))]
299
+ title: number.format(view.totals.total),
300
+ children: tokens.format(view.totals.total)
301
+ })] }), metrics.map((key) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("dt", { children: text[key] }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("dd", {
302
+ title: number.format(view.totals[key]),
303
+ children: tokens.format(view.totals[key])
304
+ })] }, key))]
268
305
  }),
269
306
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
270
307
  className: "dsh-usage-meta dsh-usage-muted",
@@ -319,7 +356,8 @@ function Usage({ ctx }) {
319
356
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
320
357
  className: "dsh-usage-detail",
321
358
  "aria-live": "polite",
322
- children: selected && `${date(selected.day)} · ${text.total}: ${number.format(selected.total)}`
359
+ title: selected && number.format(selected.total),
360
+ children: selected && `${date(selected.day)} · ${text.total}: ${tokens.format(selected.total)}`
323
361
  }),
324
362
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h3", { children: text.models }),
325
363
  view.models.map((row) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -330,8 +368,14 @@ function Usage({ ctx }) {
330
368
  row.provider || text.unknown,
331
369
  " / ",
332
370
  row.model || text.unknown
333
- ] }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: number.format(row.total) })]
334
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("dl", { children: ["calls", ...metrics].map((key) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("dt", { children: text[key] }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("dd", { children: number.format(row[key]) })] }, key)) })]
371
+ ] }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
372
+ title: number.format(row.total),
373
+ children: tokens.format(row.total)
374
+ })]
375
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("dl", { children: ["calls", ...metrics].map((key) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("dt", { children: text[key] }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("dd", {
376
+ title: number.format(row[key]),
377
+ children: key === "calls" ? number.format(row[key]) : tokens.format(row[key])
378
+ })] }, key)) })]
335
379
  }, JSON.stringify([row.provider, row.model])))
336
380
  ] }),
337
381
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("p", {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dungsil/dsh-plugin-ui-usage",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Recorded session token usage dashboard for DeepSeek Harness.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -11,6 +11,7 @@
11
11
  "files": [
12
12
  "dist",
13
13
  "locale",
14
+ "assets",
14
15
  "cordis.patch.yml"
15
16
  ],
16
17
  "type": "module",
@@ -37,7 +38,9 @@
37
38
  "@dungsil/dsh-tsdown": "0.0.0",
38
39
  "@dungsil/dsh-ui": "0.0.0",
39
40
  "@types/react": "18.3.31",
41
+ "happy-dom": "21.0.4",
40
42
  "react": "18.3.1",
43
+ "react-dom": "18.3.1",
41
44
  "tsdown": "0.23.4"
42
45
  },
43
46
  "peerDependencies": {