dsh-token-use 0.3.1 → 0.3.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.en.md CHANGED
@@ -13,7 +13,7 @@ Four tabs across the top of the panel: **Overview / By model / By project / Conf
13
13
  - **Overview** — the cost and totals cards, the trend chart, and a **usage heatmap** (last 12 months, one cell per day, coloured by total tokens or by cost; hovering shows that day's tokens, cost and calls **instantly**, without the native tooltip's one-second delay). Range and model filters apply here.
14
14
  - **By model** — every model's usage and amount, with the same range query (day / month / year / date range / all).
15
15
  - **By project** — every project's usage and amount, same range query.
16
- - **Configuration** — versions (`dsh-service`, `dsh`, `dsh-base` and this plugin: installed vs the newest release **in the channel you run**, rc/alpha/stable), runtime facts (platform version, Node version, PID, port, uptime, `DSH_HOME`), a **health check** and a confirmed **one-click restart**.
16
+ - **Configuration** — versions (`dsh-service`, `dsh`, `dsh-base` and this plugin: installed vs the newest release **in the channel you run**, rc/alpha/stable), runtime facts (platform version, Node version, PID, port, uptime, `DSH_HOME`), a **health check**, a confirmed **one-click restart** and a confirmed **shutdown**.
17
17
  - **Self-check** (the health check button) reports three sections: **endpoint** (latency, version, history scan, price data, buckets, last update), **client bundles** (each plugin probed at the exact revisioned URL from this page's boot manifest, i.e. what the browser really loads) and **harness** (profile integrity — `node_modules`, lockfile, boot graph; plugin package integrity — entry file, `dsh.bundle.patch`, client bundle; dependency resolution; single cordis instance at one version; service bundle vs the running CLI). When a freshly installed plugin refuses to start, this names the package, the missing file and the fix.
18
18
  - **Cost card** — spend estimated live from DeepSeek's published prices × the usage recorded here, with peak/off-peak rates. Every card (totals, trend, by model, by project) carries an amount.
19
19
  - **Daily price book** — the official pricing page is fetched once a day at **12:00** and cached locally, so restarts keep it and an offline machine falls back to a built-in snapshot.
@@ -25,9 +25,13 @@ Four tabs across the top of the panel: **Overview / By model / By project / Conf
25
25
  - **Unit switch** — 亿 / 万 / 千 in Chinese, B / M / K in English; remembered per browser. The tab bar and the unit switch stay pinned at the top, and the tab you were on is remembered.
26
26
  - **Detail tables** — by model and by project (the day dimension lives in the heatmap), each with an amount column (hover a row for its rates) and a total column.
27
27
 
28
- ### One-click restart
28
+ ### One-click restart / shutdown
29
29
 
30
- The button on the configuration tab restarts the dsh web process serving the page. It replays the current command line (`process.argv` plus the working directory), so custom ports and flags survive; it asks for confirmation first, and the page reconnects and reloads itself about 10-30 seconds later. The script lands in `$DSH_HOME/dsh-token-use/restart.sh` and its output in `restart.log`. When a launch cannot be replayed (no executable entry point), the button is disabled with a reason instead.
30
+ Both actions at the bottom of the configuration tab share one **full-screen transition**: a power ring with phase copy (preparing → stopping the process → recovering → new instance ready), drawn over the whole page without disturbing the rest of the host.
31
+
32
+ - **One-click restart** — restarts the dsh web process serving the page. It replays the current command line (`process.argv` plus the working directory), so custom ports and flags survive; it asks for confirmation first, and the page reconnects and reloads itself about 10-30 seconds later. The script lands in `$DSH_HOME/dsh-token-use/restart.sh` and its output in `restart.log`.
33
+ - **Shutdown** — stops that process and does **not** bring it back; the transition ends on "you can close this page now", and you start `dsh web` again from a terminal when you need it. Its confirm button is a solid red danger button so it cannot be hit by accident.
34
+ - When a launch cannot be replayed (no executable entry point), the restart button is disabled with a reason instead.
31
35
 
32
36
  ## The amounts are estimates
33
37
 
@@ -78,7 +82,7 @@ Every project's usage and amount, same range query.
78
82
 
79
83
  ### Configuration
80
84
 
81
- Versions (compared inside your release channel), runtime facts, the health check and the one-click restart.
85
+ Versions (compared inside your release channel), runtime facts, the health check, the one-click restart and the shutdown — the last two ask for confirmation and run a full-screen transition.
82
86
 
83
87
  ![Configuration tab](assets/tab-config.jpg)
84
88
 
@@ -104,7 +108,7 @@ dsh plugin --profile web add /path/to/dsh-token-use
104
108
  - The host side **never polls and sets no periodic timer** other than the daily price refresh: it folds usage in O(1) increments from the `session/event` bus (one dictionary addition and one multiplication per `assistant/message`).
105
109
  - History is rebuilt **once** at boot by streaming `$DSH_HOME/sessions/**/session.jsonl.zstd` (native zstd from `node:zlib`), yielding the event loop between files (`scheduler.yield()`) so session handling is never blocked. A per-session sequence watermark de-duplicates the rebuild against live events, whatever order they arrive in.
106
110
  - **Price refresh**: one HTTPS GET per day at 12:00 local time (15 s timeout, retried an hour later on failure), stored atomically in `$DSH_HOME/dsh-token-use/pricing.json` (up to 30 snapshots). A restart reads the cache synchronously and does not re-fetch; a failed fetch falls back to the built-in snapshot, so the panel always shows numbers.
107
- - Four routes are exposed (loopback only, `no-store`): `GET /dsh-token-use` for the snapshot, `GET /dsh-token-use/config` for versions and runtime facts, `GET /dsh-token-use/health` for the harness self-check, and `POST /dsh-token-use/restart` for the confirmed restart.
111
+ - Five routes are exposed (loopback only, `no-store`): `GET /dsh-token-use` for the snapshot, `GET /dsh-token-use/config` for versions and runtime facts, `GET /dsh-token-use/health` for the harness self-check, `POST /dsh-token-use/restart` for the confirmed restart and `POST /dsh-token-use/shutdown` for the confirmed shutdown (neither POST answers a GET).
108
112
  - **How the self-check probes**: client bundles are fetched at the revisioned URL from this page's boot manifest (a plugin URL embeds a build revision, so only the manifest can address it); the harness walk reads the profile directory, resolves each real entry file through `exports["."]` / `exports["./client"]` / `main`, and walks up from every plugin directory to spot duplicate `@deepseek-ai/cordis` copies — all read-only, never loading or executing the plugins it inspects. The report carries structured fields plus paths and versions; the panel renders the wording in your language.
109
113
  - **Version checks** read the registry's abbreviated packument (`versions` + `dist-tags`) and compare against the highest release **in the channel you run** (rc / alpha / stable) — `@deepseek-ai/dsh-web-app` keeps a stale `0.0.1-rc.1` on its `latest` tag, so a plain `latest` comparison would report a false upgrade. The answer is cached for 6 hours in `$DSH_HOME/dsh-token-use/versions.json`.
110
114
 
@@ -124,6 +128,8 @@ curl http://127.0.0.1:3080/dsh-token-use/config
124
128
  curl http://127.0.0.1:3080/dsh-token-use/health
125
129
  # restart the service (the configuration tab's button; degrades after 2 s and replays the same command)
126
130
  curl -X POST http://127.0.0.1:3080/dsh-token-use/restart
131
+ # shut it down (the configuration tab's button; stops and does not come back)
132
+ curl -X POST http://127.0.0.1:3080/dsh-token-use/shutdown
127
133
  ```
128
134
 
129
135
  Every bucket carries a `cost` (the estimate); `pricing` holds the current price book, when it was fetched and when it refreshes next; `modelPricing` maps each observed model to its rates or to the reason it carries no amount. `trend` spans the last 366 days — the chart takes the last 7/30/90 of it and the heatmap takes the whole run.
package/README.md CHANGED
@@ -13,7 +13,7 @@ DeepSeek Harness 实时 Token 用量与消费金额插件:安装后在 **设
13
13
  - **概览**:金额与总量卡片、用量趋势图、**使用热力图**(近 12 个月,一格一天,可按「总计 / 金额」着色;悬停**即时**弹出当天「总计 / 金额 / 调用次数」,无原生 tooltip 的 1 秒延迟)。支持范围与模型筛选。
14
14
  - **模型统计**:所有模型的用量与金额明细表,支持范围查询(按天 / 按月 / 按年 / 时间段 / 全部)。
15
15
  - **项目统计**:所有项目的用量与金额明细表,同样支持范围查询。
16
- - **配置信息**:版本信息(`dsh-service`、`dsh`、`dsh-base`、本插件各自的已安装版本 vs 该版本**通道**(rc/alpha/stable)的最新版)、运行环境(平台版本、Node 版本、进程 PID、监听端口、已运行时长、`DSH_HOME`),以及**健康检查**与**一键重启**(带二次确认)。
16
+ - **配置信息**:版本信息(`dsh-service`、`dsh`、`dsh-base`、本插件各自的已安装版本 vs 该版本**通道**(rc/alpha/stable)的最新版)、运行环境(平台版本、Node 版本、进程 PID、监听端口、已运行时长、`DSH_HOME`),以及**健康检查**、**一键重启**与**关机**(后两者都带二次确认)。
17
17
  - **健康检查(自检)**:分三段报告——**接口**(响应耗时、接口版本、历史扫描、定价数据、数据桶、数据更新时间)、**客户端 bundle**(逐个插件探测浏览器实际能否加载,用的是当前页面启动清单里带版本号的真实 URL)、**Harness 自检**(profile 完整性:`node_modules` / 锁文件 / 启动图;插件包完整性:入口文件、`dsh.bundle.patch`、前端 bundle 是否齐全;依赖解析;cordis 是否单实例同版本;服务端 bundle 与正在运行的 CLI 版本是否一致)。装完插件起不来时,这里会直接指出是哪个包、缺什么文件、怎么修。
18
18
  - **金额卡片**:按 DeepSeek 官方定价 × 本地用量实时估算消费金额(含高峰/空闲时段倍率),每张卡片(总量、趋势、按模型、按项目)都带金额字段。
19
19
  - **官方定价自动更新**:每天 **12:00** 抓取一次官方定价页并缓存到本地,重启不丢、断网可用内置快照兜底。
@@ -25,9 +25,13 @@ DeepSeek Harness 实时 Token 用量与消费金额插件:安装后在 **设
25
25
  - **单位切换**:中文(亿 / 万 / 千)与英文(B / M / K)一键切换,记忆选择;tab 与单位切换栏常驻顶部,每个 tab 记忆上次停留位置。
26
26
  - **明细表**:按模型、按项目两张表(按日期维度由热力图承担),含金额列(悬停显示该模型单价)与总计列。
27
27
 
28
- ### 一键重启
28
+ ### 一键重启 / 关机
29
29
 
30
- 「配置信息」里的重启按钮会重启正在提供页面的 dsh web 进程:它按当前进程的启动命令(`process.argv` + 工作目录)原样重新拉起,因此自定义端口、参数都能保持;点击后需在弹窗二次确认,页面断开约 10-30 秒后自动重连并刷新。脚本写在 `$DSH_HOME/dsh-token-use/restart.sh`,日志在 `$DSH_HOME/dsh-token-use/restart.log`。若启动方式无法原样重放(拿不到可执行的入口),按钮会禁用并说明原因。
30
+ 「配置信息」底部的两个动作都走同一个**全屏过渡**:电源环 + 阶段文案(准备 → 正在结束进程 → 恢复中 → 新实例已就绪),占满页面、不遮挡宿主其它区域。
31
+
32
+ - **一键重启**:重启正在提供页面的 dsh web 进程。它按当前进程的启动命令(`process.argv` + 工作目录)原样重新拉起,因此自定义端口、参数都能保持;点击后弹窗二次确认,页面断开约 10-30 秒,恢复后自动重新加载。脚本写在 `$DSH_HOME/dsh-token-use/restart.sh`,日志在 `$DSH_HOME/dsh-token-use/restart.log`。
33
+ - **关机**:停止该进程且**不会自动拉起**,过渡结束时提示「可以关闭此页面了」,需要时在终端重新运行 `dsh web`。确认按钮是红色实心,避免误点。
34
+ - 若启动方式无法原样重放(拿不到可执行的入口),重启按钮会禁用并说明原因。
31
35
 
32
36
 
33
37
  ## 金额是估算值
@@ -79,7 +83,7 @@ DeepSeek Harness 实时 Token 用量与消费金额插件:安装后在 **设
79
83
 
80
84
  ### 配置信息
81
85
 
82
- 版本信息(按通道对比最新版)、运行环境、健康检查与一键重启。
86
+ 版本信息(按通道对比最新版)、运行环境、健康检查、一键重启与关机(均带二次确认,动作时有全屏过渡动画)。
83
87
 
84
88
  ![配置信息 tab](assets/tab-config.jpg)
85
89
 
@@ -105,7 +109,7 @@ dsh plugin --profile web add /解压路径/dsh-token-use
105
109
  - 宿主侧 **不轮询、不写盘、不加定时器**(除了每天一次定价刷新):通过 `session/event` 事件总线做 O(1) 增量累加(每条 `assistant/message` 一次字典加法 + 一次金额乘法)。
106
110
  - 启动时做 **一次性**历史重建:流式解压 `$DSH_HOME/sessions/**/session.jsonl.zstd`(`node:zlib` 原生 zstd),每读一个文件主动让出事件循环(`scheduler.yield()`),不阻塞会话处理;重建与实时事件用「会话 seq 水位」去重,任意先后顺序都不会重复计数。
107
111
  - **定价刷新**:每天 12:00 一次 HTTPS GET(15 秒超时,失败 1 小时后重试),结果写入 `$DSH_HOME/dsh-token-use/pricing.json`(原子替换,最多保留 30 份快照);进程重启时同步读缓存,不重复联网;联网失败自动退回内置快照,面板照常显示。
108
- - 只暴露四个只读/受控接口(仅回环可访问,内存快照,`no-store`):`GET /dsh-token-use` 数据快照、`GET /dsh-token-use/config` 版本与运行环境、`GET /dsh-token-use/health` Harness 自检、`POST /dsh-token-use/restart` 重启服务(需前端二次确认)。
112
+ - 只暴露五个只读/受控接口(仅回环可访问,内存快照,`no-store`):`GET /dsh-token-use` 数据快照、`GET /dsh-token-use/config` 版本与运行环境、`GET /dsh-token-use/health` Harness 自检、`POST /dsh-token-use/restart` 重启服务、`POST /dsh-token-use/shutdown` 关机(后两个都需前端二次确认,且不接受 GET)。
109
113
  - **自检怎么探**:客户端 bundle 用当前页面启动清单里的 `rev` 拉真实 URL(插件 URL 带构建版本号,只有启动清单拼得对);Harness 自检读 profile 目录、按 `exports["."]` / `exports["./client"]` / `main` 找真实入口、从各插件目录向上解析 `@deepseek-ai/cordis` 判断是否重复——全程只读,不加载也不执行被检查的插件。报告里只有结构化字段与路径/版本,文案由前端按语言渲染。
110
114
  - **版本检查**:`/config` 读 npm registry 的精简 packument(`versions` + `dist-tags`),按**当前版本所属通道**(rc / alpha / stable)取该通道最高版来对比——`@deepseek-ai/dsh-web-app` 的 `latest` 标签长期停在 `0.0.1-rc.1`,按通道比较才不会误报。结果缓存 6 小时,写在 `$DSH_HOME/dsh-token-use/versions.json`。
111
115
 
@@ -125,6 +129,8 @@ curl http://127.0.0.1:3080/dsh-token-use/config
125
129
  curl http://127.0.0.1:3080/dsh-token-use/health
126
130
  # 重启服务(配置信息 tab 的按钮;2 秒后断开并原样拉起)
127
131
  curl -X POST http://127.0.0.1:3080/dsh-token-use/restart
132
+ # 关机(配置信息 tab 的按钮;停掉且不再拉起)
133
+ curl -X POST http://127.0.0.1:3080/dsh-token-use/shutdown
128
134
  ```
129
135
 
130
136
  响应里每个桶都带 `cost`(估算金额),`pricing` 是当前价目表、抓取时间与下次刷新时间,`modelPricing` 是各模型匹配到的单价或未计价原因;`trend` 覆盖最近 366 天(趋势图取尾部 7/30/90 天,热力图用整段)。
Binary file
Binary file
Binary file
Binary file
Binary file
package/client/client.js CHANGED
@@ -40,8 +40,31 @@ window.__ModuleLoader__.load({ id: "dsh-token-use", factory: (require) => {
40
40
  } catch (error) {
41
41
  ui = {};
42
42
  }
43
+ /**
44
+ * `react-dom` is one of the shell's static modules, so the power overlay can be
45
+ * portalled straight into `document.body`. Without it the overlay still renders
46
+ * (position: fixed) — just inside the settings dialog's DOM.
47
+ */
48
+ var reactDom = null;
49
+ try {
50
+ reactDom = require("react-dom");
51
+ } catch (error) {
52
+ reactDom = null;
53
+ }
54
+ var portalTo = reactDom !== null && typeof reactDom.createPortal === "function"
55
+ ? function (node) { return reactDom.createPortal(node, document.body); }
56
+ : null;
57
+ /**
58
+ * React takes more than a plain function as an element type: the shell wraps
59
+ * some atoms (Button) in `memo`/`forwardRef`, which are objects. Gating on
60
+ * "function" quietly dropped those to the fallback, so the gate is "anything
61
+ * React can render".
62
+ */
63
+ var renderable = function (value) {
64
+ return typeof value === "function" || (typeof value === "object" && value !== null);
65
+ };
43
66
  var component = function (name, fallback) {
44
- return typeof ui[name] === "function" ? ui[name] : fallback;
67
+ return renderable(ui[name]) ? ui[name] : fallback;
45
68
  };
46
69
  /**
47
70
  * Tooltip is the right layer for the ⓘ: it positions in viewport coordinates,
@@ -53,19 +76,25 @@ window.__ModuleLoader__.load({ id: "dsh-token-use", factory: (require) => {
53
76
  var stateDot = component("StateDot", null);
54
77
  var menu = component("Menu", null);
55
78
  var modal = component("Modal", null);
79
+ /**
80
+ * Icon atoms are named `Icon<Name>Outline<Weight>` — `Regular` is the 1px
81
+ * stroke weight, `Medium` the 1.3px one. Each artwork already defaults to the
82
+ * box size its context wants (16px for controls, 14px for inline marks), so no
83
+ * size prop is passed.
84
+ */
56
85
  var icon = function (name) {
57
- return typeof ui[name] === "function" ? ui[name] : null;
86
+ return renderable(ui[name]) ? ui[name] : null;
58
87
  };
59
- var iconQuestion = icon("IconQuestionOutline14");
60
- var iconChevron = icon("IconChevronDownOutline14");
61
- var iconRefresh = icon("IconRefreshOutline14");
62
- var iconGauge = icon("IconGaugeOutline16");
63
- var iconTrend = icon("IconDataOutline16");
64
- var iconCheck = icon("IconCheckOutline16");
65
- var iconWarn = icon("IconWarningOutline16");
66
- var iconClock = icon("IconClockOutline16");
67
- var iconLoading = icon("IconLoadingOutline16");
68
- var iconApi = icon("IconApiOutline14");
88
+ var iconQuestion = icon("IconQuestionOutlineRegular");
89
+ var iconChevron = icon("IconChevronDownOutlineRegular");
90
+ var iconRefresh = icon("IconRefreshOutlineRegular");
91
+ var iconGauge = icon("IconGaugeOutlineRegular");
92
+ var iconTrend = icon("IconDataOutlineRegular");
93
+ var iconCheck = icon("IconCheckOutlineRegular");
94
+ var iconWarn = icon("IconWarningOutlineRegular");
95
+ var iconClock = icon("IconClockOutlineRegular");
96
+ var iconLoading = icon("IconLoadingOutlineRegular");
97
+ var iconApi = icon("IconApiOutlineRegular");
69
98
  /** Chip and Button degrade into styled native controls on an older shell. */
70
99
  var chip = component("Pill", function (props) {
71
100
  return h("button", { type: "button", className: "dshtu_seg", "data-on": props.active === true, onClick: props.onClick, disabled: props.disabled === true }, props.children);
@@ -164,6 +193,25 @@ window.__ModuleLoader__.load({ id: "dsh-token-use", factory: (require) => {
164
193
  versionChecked: "检查于 {time}",
165
194
  versionRefresh: "重新检查",
166
195
  versionRefreshing: "检查中…",
196
+ versionFailed: "本次检查未成功:{names}(显示的是上次结果)",
197
+ versionFailedTitle: "失败原因:{reason}",
198
+ shutdown: "关机",
199
+ shutdownTitle: "关闭 dsh web 服务?",
200
+ shutdownBody: "将停止正在提供本页面的 dsh web 进程(PID {pid}),且不会自动重启。关闭后本页面将无法使用,需要时请在终端重新运行 `dsh web`。",
201
+ shutdownConfirm: "确认关机",
202
+ shutdownFailed: "关机失败:{error}",
203
+ cancel: "取消",
204
+ powerRestartTitle: "正在重启 DeepSeek Harness…",
205
+ powerShutdownTitle: "正在关机 DeepSeek Harness…",
206
+ powerPrepareRestart: "正在准备重启…",
207
+ powerPrepareShutdown: "正在准备关机…",
208
+ powerStopping: "正在结束进程,即将断开连接",
209
+ powerRecovering: "正在恢复…",
210
+ powerReloaded: "新实例已就绪,正在刷新页面",
211
+ powerStopped: "服务已关闭",
212
+ powerStoppedNote: "可以关闭此页面了;需要时请在终端重新运行 `dsh web` 启动服务。",
213
+ powerTimeout: "重启超时,请手动刷新页面",
214
+ powerTimeoutNote: "服务未在预期时间内恢复,可能需要手动启动。日志:{log}",
167
215
  healthCheck: "健康检查",
168
216
  healthRunning: "检查中…",
169
217
  healthOk: "接口正常",
@@ -324,6 +372,25 @@ window.__ModuleLoader__.load({ id: "dsh-token-use", factory: (require) => {
324
372
  versionChecked: "Checked {time}",
325
373
  versionRefresh: "Check again",
326
374
  versionRefreshing: "Checking…",
375
+ versionFailed: "This check failed for {names} — showing the last successful result",
376
+ versionFailedTitle: "Reason: {reason}",
377
+ shutdown: "Shut down",
378
+ shutdownTitle: "Shut down the dsh web service?",
379
+ shutdownBody: "This stops the dsh web process serving this page (PID {pid}) and will not start it again. The page stops working until you run `dsh web` in a terminal.",
380
+ shutdownConfirm: "Shut down",
381
+ shutdownFailed: "Shutdown failed: {error}",
382
+ cancel: "Cancel",
383
+ powerRestartTitle: "Restarting DeepSeek Harness…",
384
+ powerShutdownTitle: "Shutting DeepSeek Harness down…",
385
+ powerPrepareRestart: "Preparing to restart…",
386
+ powerPrepareShutdown: "Preparing to shut down…",
387
+ powerStopping: "Stopping the process — the connection is about to drop",
388
+ powerRecovering: "Coming back…",
389
+ powerReloaded: "The new instance is up — reloading",
390
+ powerStopped: "The service is down",
391
+ powerStoppedNote: "You can close this page; run `dsh web` in a terminal when you need it again.",
392
+ powerTimeout: "The restart timed out — reload the page manually",
393
+ powerTimeoutNote: "The service did not come back in time and may need to be started by hand. Log: {log}",
327
394
  healthCheck: "Health check",
328
395
  healthRunning: "Checking…",
329
396
  healthOk: "Endpoint healthy",
@@ -401,7 +468,7 @@ window.__ModuleLoader__.load({ id: "dsh-token-use", factory: (require) => {
401
468
  hintServiceVersion: "a service bundle older or newer than the running CLI is a common cause of failed plugin startups"
402
469
  };
403
470
 
404
- var css = ".dshtu_wrap{max-width:860px;display:flex;flex-direction:column;gap:14px;padding:2px 2px 4px;color:var(--dsw-alias-label-primary);font-size:13px}.dshtu_card{background:var(--dsw-alias-bg-layer-3);border-radius:14px;box-shadow:var(--dsw-elevation-stroke);padding:14px 16px}.dshtu_bar{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.dshtu_bar label{color:var(--dsw-alias-label-tertiary);font-size:12px}.dshtu_bar .dshtu_select,.dshtu_bar .dshtu_input{border:.5px solid var(--dsw-alias-border-l4);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:inherit;border-radius:8px;padding:4px 8px;font-size:12.5px}.dshtu_bar .dshtu_select{width:168px;text-overflow:ellipsis;white-space:nowrap}.dshtu_chips{display:inline-flex;align-items:center;gap:6px;flex-wrap:wrap}.dshtu_subBar{margin-top:12px}.dshtu_pick{display:inline-flex;align-items:center;gap:4px;max-width:230px}.dshtu_pickLabel{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dshtu_metaIcon{display:inline-flex;align-items:center;margin-right:5px;vertical-align:-2px;color:var(--dsw-alias-label-tertiary)}.dshtu_cardTitle{display:inline-flex;align-items:center}.dshtu_foot{display:flex;align-items:center;gap:6px}.dshtu_statLabel{display:inline-flex;align-items:center;gap:3px;white-space:nowrap}.dshtu_field{display:inline-flex;align-items:center;gap:6px;white-space:nowrap}.dshtu_seg{display:inline-flex;border:.5px solid var(--dsw-alias-border-l4);border-radius:8px;overflow:hidden}.dshtu_seg button{border:0;background:0 0;color:var(--dsw-alias-label-secondary);font:inherit;cursor:pointer;padding:4px 10px;font-size:12.5px}.dshtu_seg button[data-on=true]{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}.dshtu_totalsMeta{margin-top:12px}.dshtu_grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:10px;margin-top:8px}.dshtu_stat{background:var(--dsw-alias-bg-module-platform);border-radius:10px;padding:10px 12px}.dshtu_stat b{display:block;font-size:16px;line-height:24px;font-variant-numeric:tabular-nums}.dshtu_stat span{color:var(--dsw-alias-label-tertiary);font-size:12px}.dshtu_stat[data-main=true]{background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 12%,var(--dsw-alias-bg-module-platform))}.dshtu_stat[data-main=true] b{color:var(--dsw-alias-state-business-primary);font-size:20px}.dshtu_table{width:100%;border-collapse:collapse;font-variant-numeric:tabular-nums}.dshtu_table th,.dshtu_table td{text-align:right;padding:6px 8px;border-bottom:.5px solid var(--dsw-alias-border-l2);font-weight:400}.dshtu_table th:first-child,.dshtu_table td:first-child{text-align:left}.dshtu_table th{color:var(--dsw-alias-label-tertiary);font-size:12px;white-space:nowrap}.dshtu_table td:first-child{overflow-wrap:anywhere;max-width:300px;color:var(--dsw-alias-label-secondary)}.dshtu_meta{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}.dshtu_err{color:var(--dsw-alias-state-error-primary);display:flex;align-items:center;gap:10px}.dshtu_chart{width:100%;height:260px}.dshtu_info{display:inline-flex;align-items:center;justify-content:center;margin-left:6px;color:var(--dsw-alias-label-tertiary);cursor:help}.dshtu_info:hover{color:var(--dsw-alias-state-business-primary)}.dshtu_pop{display:block;max-width:420px;font-size:12px;font-weight:400;line-height:18px;text-align:left;white-space:normal}.dshtu_pop .dshtu_sep{margin:0 5px;opacity:.55}[role=tooltip]:has(.dshtu_pop){background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);padding:10px 12px;border:0;border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,.16)}.dshtu_nd{color:var(--dsw-alias-label-tertiary)}.dshtu_range{display:inline-flex;align-items:center;gap:6px;white-space:nowrap}.dshtu_range input{width:132px}.dshtu_range em{font-style:normal;color:var(--dsw-alias-label-tertiary)}.dshtu_tabRow{display:flex;align-items:center;gap:10px}.dshtu_tabs{display:flex;align-items:center;gap:2px;flex:1;flex-wrap:wrap}.dshtu_tabs button{border:0;background:0 0;font:inherit;color:var(--dsw-alias-label-secondary);cursor:pointer;padding:5px 10px 6px;border-bottom:2px solid transparent;font-size:13px}.dshtu_tabs button:hover{color:var(--dsw-alias-label-primary)}.dshtu_tabs button[data-on=true]{color:var(--dsw-alias-label-primary);border-bottom-color:var(--dsw-alias-state-business-primary);font-weight:500}.dshtu_heatCard{position:relative;display:flex;flex-direction:column;gap:10px}.dshtu_heatHead{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.dshtu_heatScroll{overflow-x:auto;padding-bottom:4px}.dshtu_heatBody{display:flex;gap:3px}.dshtu_heatDays{display:flex;flex-direction:column;gap:3px;width:17px}.dshtu_heatDay{height:11px;font-size:10px;line-height:11px;color:var(--dsw-alias-label-tertiary)}.dshtu_heat{display:flex;gap:3px}.dshtu_heatCol{display:flex;flex-direction:column;gap:3px}.dshtu_heatColHead{position:relative;height:13px}.dshtu_heatColLabel{position:absolute;left:0;top:0;font-size:10px;line-height:13px;color:var(--dsw-alias-label-tertiary);white-space:nowrap}.dshtu_heatCell{width:11px;height:11px;border-radius:2px;background:var(--dsw-alias-bg-module-platform)}.dshtu_heatCell[data-future=true]{background:0 0}.dshtu_heatCell[data-day]:hover{outline:1.5px solid var(--dsw-alias-state-business-primary);outline-offset:1px}.dshtu_heatTip{position:absolute;z-index:6;transform:translate(-50%,-100%);pointer-events:none;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);border-radius:8px;box-shadow:0 6px 20px rgba(0,0,0,.18);padding:6px 9px;font-size:11.5px;line-height:17px;white-space:nowrap;display:flex;flex-direction:column}.dshtu_heatTip b{font-weight:500;margin-bottom:1px}.dshtu_heatTipRow{color:var(--dsw-alias-label-secondary)}.dshtu_heatLegend{display:inline-flex;align-items:center;gap:4px;font-size:11px;color:var(--dsw-alias-label-tertiary)}.dshtu_heatLegend i{width:11px;height:11px;border-radius:2px;display:inline-block}.dshtu_kv{display:grid;grid-template-columns:minmax(96px,auto) 1fr;gap:6px 14px;margin-top:8px;font-size:12.5px}.dshtu_kv dt{color:var(--dsw-alias-label-tertiary)}.dshtu_kv dd{margin:0;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.dshtu_acts{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:10px}.dshtu_health{display:grid;grid-template-columns:minmax(96px,auto) 1fr;gap:6px 14px;margin-top:12px;padding-top:10px;border-top:.5px solid var(--dsw-alias-border-l2);font-size:12.5px}.dshtu_health dt{color:var(--dsw-alias-label-tertiary)}.dshtu_health dd{margin:0;font-variant-numeric:tabular-nums}.dshtu_stack{display:flex;flex-direction:column;gap:14px}.dshtu_tblBar{margin-top:12px}.dshtu_checkList{margin:8px 0 0;padding:0;list-style:none;display:flex;flex-direction:column;gap:4px}.dshtu_head{display:flex;align-items:center;gap:6px;margin-bottom:8px}.dshtu_head h3{margin:0;font-size:13px;font-weight:500}.dshtu_slim .dshtu_bar{margin:0}.dshtu_state{display:inline-flex;align-items:center;gap:6px;font-size:12.5px}.dshtu_state[data-tone=ok]{color:var(--dsw-alias-state-success-primary)}.dshtu_state[data-tone=warn]{color:var(--dsw-alias-state-warning-primary)}.dshtu_state[data-tone=bad]{color:var(--dsw-alias-state-error-primary)}.dshtu_state[data-tone=idle]{color:var(--dsw-alias-label-tertiary)}.dshtu_modalBody{font-size:12.5px;line-height:20px;color:var(--dsw-alias-label-secondary);max-width:420px}.dshtu_modalFoot{display:flex;justify-content:flex-end;gap:8px}.dshtu_verCell{font-variant-numeric:tabular-nums;white-space:nowrap}";
471
+ var css = ".dshtu_wrap{max-width:860px;display:flex;flex-direction:column;gap:14px;padding:2px 2px 4px;color:var(--dsw-alias-label-primary);font-size:13px}.dshtu_card{background:var(--dsw-alias-bg-layer-3);border-radius:14px;box-shadow:var(--dsw-elevation-stroke);padding:14px 16px}.dshtu_bar{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.dshtu_bar label{color:var(--dsw-alias-label-tertiary);font-size:12px}.dshtu_bar .dshtu_select,.dshtu_bar .dshtu_input{border:.5px solid var(--dsw-alias-border-l4);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:inherit;border-radius:8px;padding:4px 8px;font-size:12.5px}.dshtu_bar .dshtu_select{width:168px;text-overflow:ellipsis;white-space:nowrap}.dshtu_chips{display:inline-flex;align-items:center;gap:6px;flex-wrap:wrap}.dshtu_subBar{margin-top:12px}.dshtu_pick{display:inline-flex;align-items:center;gap:4px;max-width:230px}.dshtu_pickLabel{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.dshtu_metaIcon{display:inline-flex;align-items:center;margin-right:5px;vertical-align:-2px;color:var(--dsw-alias-label-tertiary)}.dshtu_cardTitle{display:inline-flex;align-items:center}.dshtu_foot{display:flex;align-items:center;gap:6px}.dshtu_statLabel{display:inline-flex;align-items:center;gap:3px;white-space:nowrap}.dshtu_field{display:inline-flex;align-items:center;gap:6px;white-space:nowrap}.dshtu_seg{display:inline-flex;border:.5px solid var(--dsw-alias-border-l4);border-radius:8px;overflow:hidden}.dshtu_seg button{border:0;background:0 0;color:var(--dsw-alias-label-secondary);font:inherit;cursor:pointer;padding:4px 10px;font-size:12.5px}.dshtu_seg button[data-on=true]{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}.dshtu_totalsMeta{margin-top:12px}.dshtu_grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:10px;margin-top:8px}.dshtu_stat{background:var(--dsw-alias-bg-module-platform);border-radius:10px;padding:10px 12px}.dshtu_stat b{display:block;font-size:16px;line-height:24px;font-variant-numeric:tabular-nums}.dshtu_stat span{color:var(--dsw-alias-label-tertiary);font-size:12px}.dshtu_stat[data-main=true]{background:color-mix(in srgb,var(--dsw-alias-state-business-primary) 12%,var(--dsw-alias-bg-module-platform))}.dshtu_stat[data-main=true] b{color:var(--dsw-alias-state-business-primary);font-size:20px}.dshtu_table{width:100%;border-collapse:collapse;font-variant-numeric:tabular-nums}.dshtu_table th,.dshtu_table td{text-align:right;padding:6px 8px;border-bottom:.5px solid var(--dsw-alias-border-l2);font-weight:400}.dshtu_table th:first-child,.dshtu_table td:first-child{text-align:left}.dshtu_table th{color:var(--dsw-alias-label-tertiary);font-size:12px;white-space:nowrap}.dshtu_table td:first-child{overflow-wrap:anywhere;max-width:300px;color:var(--dsw-alias-label-secondary)}.dshtu_meta{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px}.dshtu_err{color:var(--dsw-alias-state-error-primary);display:flex;align-items:center;gap:10px}.dshtu_chart{width:100%;height:260px}.dshtu_info{display:inline-flex;align-items:center;justify-content:center;margin-left:6px;color:var(--dsw-alias-label-tertiary);cursor:help}.dshtu_info:hover{color:var(--dsw-alias-state-business-primary)}.dshtu_pop{display:block;max-width:420px;font-size:12px;font-weight:400;line-height:18px;text-align:left;white-space:normal}.dshtu_pop .dshtu_sep{margin:0 5px;opacity:.55}[role=tooltip]:has(.dshtu_pop){background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);padding:10px 12px;border:0;border-radius:10px;box-shadow:0 8px 24px rgba(0,0,0,.16)}.dshtu_nd{color:var(--dsw-alias-label-tertiary)}.dshtu_range{display:inline-flex;align-items:center;gap:6px;white-space:nowrap}.dshtu_range input{width:132px}.dshtu_range em{font-style:normal;color:var(--dsw-alias-label-tertiary)}.dshtu_tabRow{display:flex;align-items:center;gap:10px}.dshtu_tabs{display:flex;align-items:center;gap:2px;flex:1;flex-wrap:wrap}.dshtu_tabs button{border:0;background:0 0;font:inherit;color:var(--dsw-alias-label-secondary);cursor:pointer;padding:5px 10px 6px;border-bottom:2px solid transparent;font-size:13px}.dshtu_tabs button:hover{color:var(--dsw-alias-label-primary)}.dshtu_tabs button[data-on=true]{color:var(--dsw-alias-label-primary);border-bottom-color:var(--dsw-alias-state-business-primary);font-weight:500}.dshtu_heatCard{position:relative;display:flex;flex-direction:column;gap:10px}.dshtu_heatHead{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.dshtu_heatScroll{overflow-x:auto;padding-bottom:4px}.dshtu_heatBody{display:flex;gap:3px}.dshtu_heatDays{display:flex;flex-direction:column;gap:3px;width:17px}.dshtu_heatDay{height:11px;font-size:10px;line-height:11px;color:var(--dsw-alias-label-tertiary)}.dshtu_heat{display:flex;gap:3px}.dshtu_heatCol{display:flex;flex-direction:column;gap:3px}.dshtu_heatColHead{position:relative;height:13px}.dshtu_heatColLabel{position:absolute;left:0;top:0;font-size:10px;line-height:13px;color:var(--dsw-alias-label-tertiary);white-space:nowrap}.dshtu_heatCell{width:11px;height:11px;border-radius:2px;background:var(--dsw-alias-bg-module-platform)}.dshtu_heatCell[data-future=true]{background:0 0}.dshtu_heatCell[data-day]:hover{outline:1.5px solid var(--dsw-alias-state-business-primary);outline-offset:1px}.dshtu_heatTip{position:absolute;z-index:6;transform:translate(-50%,-100%);pointer-events:none;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);border-radius:8px;box-shadow:0 6px 20px rgba(0,0,0,.18);padding:6px 9px;font-size:11.5px;line-height:17px;white-space:nowrap;display:flex;flex-direction:column}.dshtu_heatTip b{font-weight:500;margin-bottom:1px}.dshtu_heatTipRow{color:var(--dsw-alias-label-secondary)}.dshtu_heatLegend{display:inline-flex;align-items:center;gap:4px;font-size:11px;color:var(--dsw-alias-label-tertiary)}.dshtu_heatLegend i{width:11px;height:11px;border-radius:2px;display:inline-block}.dshtu_kv{display:grid;grid-template-columns:minmax(96px,auto) 1fr;gap:6px 14px;margin-top:8px;font-size:12.5px}.dshtu_kv dt{color:var(--dsw-alias-label-tertiary)}.dshtu_kv dd{margin:0;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.dshtu_acts{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:10px}.dshtu_health{display:grid;grid-template-columns:minmax(96px,auto) 1fr;gap:6px 14px;margin-top:12px;padding-top:10px;border-top:.5px solid var(--dsw-alias-border-l2);font-size:12.5px}.dshtu_health dt{color:var(--dsw-alias-label-tertiary)}.dshtu_health dd{margin:0;font-variant-numeric:tabular-nums}.dshtu_stack{display:flex;flex-direction:column;gap:14px}.dshtu_tblBar{margin-top:12px}.dshtu_checkList{margin:8px 0 0;padding:0;list-style:none;display:flex;flex-direction:column;gap:4px}.dshtu_head{display:flex;align-items:center;gap:6px;margin-bottom:8px}.dshtu_head h3{margin:0;font-size:13px;font-weight:500}.dshtu_slim .dshtu_bar{margin:0}.dshtu_state{display:inline-flex;align-items:center;gap:6px;font-size:12.5px}.dshtu_state[data-tone=ok]{color:var(--dsw-alias-state-success-primary)}.dshtu_state[data-tone=warn]{color:var(--dsw-alias-state-warn-primary)}.dshtu_state[data-tone=bad]{color:var(--dsw-alias-state-error-primary)}.dshtu_state[data-tone=idle]{color:var(--dsw-alias-label-tertiary)}.dshtu_modalBody{font-size:12.5px;line-height:20px;color:var(--dsw-alias-label-secondary);max-width:420px}.dshtu_modalFoot{display:flex;justify-content:flex-end;gap:8px}.dshtu_verCell{font-variant-numeric:tabular-nums;white-space:nowrap}.dshtu_acts{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:12px}.dshtu_acts button{flex:none}.dshtu_danger{color:var(--dsw-alias-state-error-primary)}.dshtu_danger:hover{background:var(--dsw-alias-interactive-bg-hover-danger)}.dshtu_dangerFill{background:var(--dsw-alias-state-error-primary)!important;color:#fff!important;border-color:transparent!important}.dshtu_dangerFill:hover{background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 86%,#000)!important}.dshtu_warnBtn{color:var(--dsw-alias-state-warn-primary)}.dshtu_warnBtn:hover{background:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 10%,transparent)}.dshtu_head .dshtu_grow{flex:1}.dshtu_power{position:fixed;inset:0;z-index:2147483000;display:flex;align-items:center;justify-content:center;background:color-mix(in srgb,var(--dsw-alias-bg-layer-1) 92%,#000);backdrop-filter:blur(7px);animation:dshtu-power-in .3s ease both}.dshtu_powerBox{display:flex;flex-direction:column;align-items:center;gap:14px;padding:36px 44px;text-align:center;max-width:min(560px,80vw)}.dshtu_ring{position:relative;width:120px;height:120px}.dshtu_ring svg{position:absolute;inset:0;width:100%;height:100%}.dshtu_ringTrack{fill:none;stroke:color-mix(in srgb,var(--dsw-alias-label-primary) 14%,transparent);stroke-width:5}.dshtu_ringArc{fill:none;stroke:var(--dsw-alias-state-business-primary);stroke-width:5;stroke-linecap:round;stroke-dasharray:74 260;transform-origin:60px 60px;animation:dshtu-ring-spin 1.15s linear infinite}.dshtu_glyph{fill:none;stroke:var(--dsw-alias-label-primary);stroke-width:6;stroke-linecap:round;opacity:.9;animation:dshtu-glyph-pulse 1.8s ease-in-out infinite}.dshtu_power[data-mode=shutdown] .dshtu_ringArc{stroke:var(--dsw-alias-state-error-primary)}.dshtu_power[data-mode=shutdown] .dshtu_glyph{stroke:var(--dsw-alias-state-error-primary)}.dshtu_powerTitle{font-size:17px;font-weight:600;letter-spacing:.2px}.dshtu_powerPhase{font-size:13px;color:var(--dsw-alias-label-secondary);min-height:20px}.dshtu_powerBar{width:240px;height:3px;border-radius:2px;overflow:hidden;background:color-mix(in srgb,var(--dsw-alias-label-primary) 12%,transparent)}.dshtu_powerBar i{display:block;width:38%;height:100%;border-radius:2px;background:var(--dsw-alias-state-business-primary);animation:dshtu-bar-slide 1.35s ease-in-out infinite}.dshtu_power[data-mode=shutdown] .dshtu_powerBar i{background:var(--dsw-alias-state-error-primary)}.dshtu_powerNote{font-size:12.5px;line-height:20px;color:var(--dsw-alias-label-secondary);max-width:420px}.dshtu_powerNote code{background:var(--dsw-alias-bg-module-platform);border-radius:5px;padding:2px 6px;font-size:12px}.dshtu_power[data-phase=stopped] .dshtu_ringArc,.dshtu_power[data-phase=timeout] .dshtu_ringArc{animation:none;stroke-dasharray:none;opacity:.45}.dshtu_power[data-phase=stopped] .dshtu_powerBar,.dshtu_power[data-phase=timeout] .dshtu_powerBar,.dshtu_power[data-phase=reloaded] .dshtu_powerBar{visibility:hidden}.dshtu_power[data-phase=stopped] .dshtu_glyph,.dshtu_power[data-phase=timeout] .dshtu_glyph{animation:none}@keyframes dshtu-power-in{from{opacity:0}to{opacity:1}}@keyframes dshtu-ring-spin{to{transform:rotate(360deg)}}@keyframes dshtu-glyph-pulse{0%,100%{opacity:.9}50%{opacity:.45}}@keyframes dshtu-bar-slide{0%{transform:translateX(-110%)}100%{transform:translateX(370%)}}";
405
472
 
406
473
  function trimNum(v, decimals) {
407
474
  var f = v.toFixed(decimals);
@@ -1202,6 +1269,60 @@ window.__ModuleLoader__.load({ id: "dsh-token-use", factory: (require) => {
1202
1269
  }).reduce(function (flat, pair) { return flat.concat(pair); }, []))));
1203
1270
  }
1204
1271
 
1272
+ /** A power symbol for the shutdown button — the shell ships no power icon. */
1273
+ function powerGlyph(size) {
1274
+ return h("svg", {
1275
+ width: size || 16,
1276
+ height: size || 16,
1277
+ viewBox: "0 0 24 24",
1278
+ fill: "none",
1279
+ stroke: "currentColor",
1280
+ strokeWidth: 2,
1281
+ strokeLinecap: "round"
1282
+ },
1283
+ h("path", { d: "M12 3v9" }),
1284
+ h("path", { d: "M6.6 6.8a8 8 0 1 0 10.8 0" }));
1285
+ }
1286
+
1287
+ /**
1288
+ * Fullscreen transition for restart and shutdown: a power ring that keeps
1289
+ * turning while the old process winds down, the phase it is in, and — once it
1290
+ * is over — what to do next. Nothing here is a fake progress bar: the phases
1291
+ * follow what the browser can actually observe (the request landed, the old
1292
+ * process stopped answering, a *new* instance answered).
1293
+ */
1294
+ function PowerOverlay(props) {
1295
+ var t = props.t;
1296
+ var power = props.power;
1297
+ if (power === null || power === undefined) return null;
1298
+ var mode = power.mode === "shutdown" ? "shutdown" : "restart";
1299
+ var phase = power.phase;
1300
+ var phaseText = phase === "prepare"
1301
+ ? (mode === "restart" ? t("powerPrepareRestart") : t("powerPrepareShutdown"))
1302
+ : phase === "stopping" ? t("powerStopping")
1303
+ : phase === "recovering" ? t("powerRecovering")
1304
+ : phase === "reloaded" ? t("powerReloaded")
1305
+ : phase === "stopped" ? t("powerStopped")
1306
+ : t("powerTimeout");
1307
+ var note = phase === "stopped" ? t("powerStoppedNote")
1308
+ : phase === "timeout" ? t("powerTimeoutNote").replace("{log}", power.log === null || power.log === undefined ? "—" : power.log)
1309
+ : null;
1310
+ var node = h("div", { className: "dshtu_power", "data-mode": mode, "data-phase": phase, role: "status", "aria-live": "polite" },
1311
+ h("div", { className: "dshtu_powerBox" },
1312
+ h("div", { className: "dshtu_ring" },
1313
+ h("svg", { viewBox: "0 0 120 120" },
1314
+ h("circle", { className: "dshtu_ringTrack", cx: 60, cy: 60, r: 52 }),
1315
+ h("circle", { className: "dshtu_ringArc", cx: 60, cy: 60, r: 52 })),
1316
+ h("svg", { viewBox: "0 0 120 120" },
1317
+ h("path", { className: "dshtu_glyph", d: "M60 30 V58" }),
1318
+ h("path", { className: "dshtu_glyph", d: "M41 41a27 27 0 1 0 38 0" }))),
1319
+ h("div", { className: "dshtu_powerTitle" }, mode === "restart" ? t("powerRestartTitle") : t("powerShutdownTitle")),
1320
+ h("div", { className: "dshtu_powerPhase" }, phaseText),
1321
+ h("div", { className: "dshtu_powerBar" }, h("i", null)),
1322
+ note === null ? null : h("div", { className: "dshtu_powerNote" }, note)));
1323
+ return portalTo === null ? node : portalTo(node);
1324
+ }
1325
+
1205
1326
  /** `已安装 / 最新 / 状态` for one tracked package. */
1206
1327
  function versionRow(t, entry) {
1207
1328
  var tone = entry.status === "current" ? "ok" : (entry.status === "outdated" ? "warn" : "idle");
@@ -1217,10 +1338,11 @@ window.__ModuleLoader__.load({ id: "dsh-token-use", factory: (require) => {
1217
1338
  h("td", { className: "dshtu_verCell" }, entry.installed === null ? "—" : entry.installed),
1218
1339
  h("td", { className: "dshtu_verCell" }, entry.latest === null ? "—" : entry.latest),
1219
1340
  // The status stays a real table cell so its column lines up with the
1220
- // header; the inline-flex badge lives inside it.
1341
+ // header; the inline-flex badge lives inside it. A failed lookup never
1342
+ // mixes into this label — it is reported under the table instead.
1221
1343
  h("td", null, h("span", { className: "dshtu_state", "data-tone": tone },
1222
1344
  glyph === null ? null : h(glyph, {}),
1223
- entry.error === null ? label : label + "(" + entry.error + ")")));
1345
+ label)));
1224
1346
  }
1225
1347
 
1226
1348
  /** Uptime as `2d 3h` / `45m`, whichever reads better at this scale. */
@@ -1242,6 +1364,11 @@ window.__ModuleLoader__.load({ id: "dsh-token-use", factory: (require) => {
1242
1364
  return h("div", { className: "dshtu_card dshtu_meta" }, props.error === null ? t("loading") : t("error") + ": " + props.error);
1243
1365
  }
1244
1366
  var runtime = config.platform || {};
1367
+ // Packages whose last registry lookup failed: their row keeps the previous
1368
+ // answer, and the reason is reported once, under the table.
1369
+ var failed = (config.packages || []).filter(function (entry) { return entry.error !== null && entry.error !== undefined; });
1370
+ // Cap what reaches the tooltip: a cached error string can be arbitrary.
1371
+ var reasonOf = function (entry) { return String(entry.error).replace(/\s+/g, " ").slice(0, 140); };
1245
1372
  var health = props.health;
1246
1373
  var rows = [];
1247
1374
  if (health && health.state === "ok") {
@@ -1262,7 +1389,17 @@ window.__ModuleLoader__.load({ id: "dsh-token-use", factory: (require) => {
1262
1389
  var healthLabel = health === null ? null : health.state === "ok" ? t("healthOk") : health.state === "error" ? t("healthFail") + ":" + health.error : t("healthRunning");
1263
1390
  return h("div", { className: "dshtu_stack" },
1264
1391
  h("div", { className: "dshtu_card" },
1265
- h("div", { className: "dshtu_head" }, iconApi === null ? null : h(iconApi, {}), h("h3", null, t("configVersions"))),
1392
+ h("div", { className: "dshtu_head" },
1393
+ iconApi === null ? null : h(iconApi, {}),
1394
+ h("h3", null, t("configVersions")),
1395
+ h("span", { className: "dshtu_grow" }),
1396
+ h(button, {
1397
+ variant: "ghost",
1398
+ size: "sm",
1399
+ disabled: props.refreshing === true,
1400
+ icon: iconRefresh === null ? null : h(iconRefresh, {}),
1401
+ onClick: props.onRefresh
1402
+ }, props.refreshing === true ? t("versionRefreshing") : t("versionRefresh"))),
1266
1403
  h("table", { className: "dshtu_table" },
1267
1404
  h("thead", null, h("tr", null,
1268
1405
  h("th", null, t("versionPackage")),
@@ -1270,16 +1407,18 @@ window.__ModuleLoader__.load({ id: "dsh-token-use", factory: (require) => {
1270
1407
  h("th", null, t("versionLatest")),
1271
1408
  h("th", null, t("versionState")))),
1272
1409
  h("tbody", null, (config.packages || []).map(function (entry) { return versionRow(t, entry); }))),
1273
- h("div", { className: "dshtu_bar dshtu_tblBar" },
1274
- h("span", { className: "dshtu_meta" }, config.checkedAt > 0 ? t("versionChecked").replace("{time}", fmtTime(config.checkedAt)) : ""),
1275
- h("span", { style: { flex: "1" } }),
1276
- h(button, {
1277
- variant: "outline",
1278
- size: "sm",
1279
- disabled: props.refreshing === true,
1280
- icon: iconRefresh === null ? null : h(iconRefresh, {}),
1281
- onClick: props.onRefresh
1282
- }, props.refreshing === true ? t("versionRefreshing") : t("versionRefresh")))),
1410
+ h("div", { className: "dshtu_meta", style: { marginTop: "10px" } },
1411
+ config.checkedAt > 0 ? t("versionChecked").replace("{time}", fmtTime(config.checkedAt)) : ""),
1412
+ failed.length === 0 ? null : h("div", {
1413
+ className: "dshtu_state",
1414
+ "data-tone": "warn",
1415
+ style: { marginTop: "8px" },
1416
+ // The reason rides the title, not the label: a lookup failure must not
1417
+ // be mistaken for a package's version verdict.
1418
+ title: t("versionFailedTitle").replace("{reason}", failed.map(function (entry) { return entry.label + ": " + reasonOf(entry); }).join(" / "))
1419
+ },
1420
+ iconWarn === null ? null : h(iconWarn, {}),
1421
+ h("span", null, t("versionFailed").replace("{names}", failed.map(function (entry) { return entry.label; }).join("、"))))),
1283
1422
  h("div", { className: "dshtu_card" },
1284
1423
  h("div", { className: "dshtu_head" }, iconGauge === null ? null : h(iconGauge, {}), h("h3", null, t("configRuntime"))),
1285
1424
  h("dl", { className: "dshtu_kv" }, [
@@ -1303,19 +1442,30 @@ window.__ModuleLoader__.load({ id: "dsh-token-use", factory: (require) => {
1303
1442
  config.restartable === false
1304
1443
  ? h("span", { className: "dshtu_meta" }, t("restartBlocked"))
1305
1444
  : h(button, {
1306
- variant: "primary",
1445
+ variant: "outline",
1307
1446
  size: "sm",
1447
+ className: "dshtu_warnBtn",
1308
1448
  icon: iconRefresh === null ? null : h(iconRefresh, {}),
1309
- disabled: props.restart !== null && props.restart.state !== "error",
1449
+ disabled: props.busy === true,
1310
1450
  onClick: props.onRestart
1311
1451
  }, t("restart")),
1452
+ config.shutdownable === false
1453
+ ? null
1454
+ : h(button, {
1455
+ variant: "outline",
1456
+ size: "sm",
1457
+ className: "dshtu_danger",
1458
+ icon: powerGlyph(16),
1459
+ disabled: props.busy === true,
1460
+ onClick: props.onShutdown
1461
+ }, t("shutdown")),
1312
1462
  healthLabel === null ? null : h("span", { className: "dshtu_state", "data-tone": tone },
1313
1463
  health.state === "running" && iconLoading !== null ? h(iconLoading, {}) : null,
1314
1464
  healthLabel)),
1315
- props.restart === null ? null : h("div", { className: "dshtu_meta", style: { marginTop: "10px" } },
1316
- props.restart.state === "pending" ? t("restartPending")
1317
- : props.restart.state === "done" ? t("restartDone")
1318
- : t("restartFailed").replace("{error}", props.restart.error))),
1465
+ props.powerError === null || props.powerError === undefined ? null : h("div", { className: "dshtu_meta", style: { marginTop: "10px" } },
1466
+ props.powerError.kind === "shutdown"
1467
+ ? t("shutdownFailed").replace("{error}", props.powerError.error)
1468
+ : t("restartFailed").replace("{error}", props.powerError.error))),
1319
1469
  h(HealthPanel, {
1320
1470
  key: "health",
1321
1471
  t: t,
@@ -1361,8 +1511,11 @@ window.__ModuleLoader__.load({ id: "dsh-token-use", factory: (require) => {
1361
1511
  var _p = useState(null), configError = _p[0], setConfigError = _p[1];
1362
1512
  var _q = useState(0), configTick = _q[0], setConfigTick = _q[1];
1363
1513
  var _r = useState(null), health = _r[0], setHealth = _r[1];
1364
- var _s = useState(null), restart = _s[0], setRestart = _s[1];
1514
+ var _s = useState(null), powerError = _s[0], setPowerError = _s[1];
1365
1515
  var _t = useState(false), confirming = _t[0], setConfirming = _t[1];
1516
+ // The fullscreen power transition: `{ mode, phase, log }` or null when idle.
1517
+ var _x = useState(null), power = _x[0], setPower = _x[1];
1518
+ var _y = useState(false), confirmingShutdown = _y[0], setConfirmingShutdown = _y[1];
1366
1519
  var _u = useState(null), harness = _u[0], setHarness = _u[1];
1367
1520
  var _v = useState(null), bundles = _v[0], setBundles = _v[1];
1368
1521
  var _w = useState(false), refreshing = _w[0], setRefreshing = _w[1];
@@ -1541,9 +1694,17 @@ window.__ModuleLoader__.load({ id: "dsh-token-use", factory: (require) => {
1541
1694
  * had time to go down (the script waits two seconds), so the first success
1542
1695
  * cannot be the old process answering.
1543
1696
  */
1697
+ /**
1698
+ * Restart, watched from the browser. Once the old process is gone it cannot
1699
+ * report anything, so the phases are what the page can actually observe: the
1700
+ * request landed, the socket stopped answering, and finally a *different*
1701
+ * instance answered (a new `startedAt`) — only then is the page reloaded.
1702
+ */
1544
1703
  var doRestart = function () {
1545
1704
  setConfirming(false);
1546
- setRestart({ state: "pending" });
1705
+ setPowerError(null);
1706
+ var previousStartedAt = data === null || data === undefined ? null : data.startedAt;
1707
+ setPower({ mode: "restart", phase: "prepare", log: null });
1547
1708
  fetch("/dsh-token-use/restart", { method: "POST", headers: { accept: "application/json" } })
1548
1709
  .then(function (res) {
1549
1710
  return res.json().then(function (json) {
@@ -1551,25 +1712,80 @@ window.__ModuleLoader__.load({ id: "dsh-token-use", factory: (require) => {
1551
1712
  return json;
1552
1713
  });
1553
1714
  })
1554
- .then(function () {
1555
- var attempts = 0;
1715
+ .then(function (json) {
1716
+ setPower({ mode: "restart", phase: "stopping", log: json.logFile || null });
1556
1717
  setTimeout(function () {
1718
+ setPower({ mode: "restart", phase: "recovering", log: json.logFile || null });
1719
+ var deadline = Date.now() + 90000;
1557
1720
  var timer = setInterval(function () {
1558
- attempts++;
1721
+ if (Date.now() > deadline) {
1722
+ clearInterval(timer);
1723
+ setPower({ mode: "restart", phase: "timeout", log: json.logFile || null });
1724
+ return;
1725
+ }
1559
1726
  fetch("/dsh-token-use", { cache: "no-store" })
1560
- .then(function (res) {
1561
- if (!res.ok) return;
1562
- clearInterval(timer);
1563
- setRestart({ state: "done" });
1564
- setTimeout(function () { location.reload(); }, 800);
1727
+ .then(function (res) { return res.ok ? res.json() : null; })
1728
+ .then(function (next) {
1729
+ if (next === null) return;
1730
+ if (previousStartedAt === null || next.startedAt !== previousStartedAt) {
1731
+ clearInterval(timer);
1732
+ setPower({ mode: "restart", phase: "reloaded", log: null });
1733
+ setTimeout(function () { location.reload(); }, 900);
1734
+ }
1565
1735
  })
1566
1736
  .catch(function () {});
1567
- if (attempts > 90) clearInterval(timer);
1568
- }, 2000);
1737
+ }, 1500);
1738
+ }, 6000);
1739
+ })
1740
+ .catch(function (err) {
1741
+ setPower(null);
1742
+ setPowerError({ kind: "restart", error: String(err) });
1743
+ });
1744
+ };
1745
+
1746
+ /**
1747
+ * Shutdown: the same transition, but it ends with the service staying down.
1748
+ * A refused request is the success signal here, so the first failure (or the
1749
+ * deadline) settles the overlay on 「服务已关闭」.
1750
+ */
1751
+ var doShutdown = function () {
1752
+ setConfirmingShutdown(false);
1753
+ setPowerError(null);
1754
+ setPower({ mode: "shutdown", phase: "prepare", log: null });
1755
+ fetch("/dsh-token-use/shutdown", { method: "POST", headers: { accept: "application/json" } })
1756
+ .then(function (res) {
1757
+ return res.json().then(function (json) {
1758
+ if (!res.ok) throw new Error(json && json.error ? json.error : "http " + res.status);
1759
+ return json;
1760
+ });
1761
+ })
1762
+ .then(function (json) {
1763
+ setPower({ mode: "shutdown", phase: "stopping", log: json.logFile || null });
1764
+ setTimeout(function () {
1765
+ setPower({ mode: "shutdown", phase: "recovering", log: json.logFile || null });
1766
+ var deadline = Date.now() + 45000;
1767
+ var timer = setInterval(function () {
1768
+ fetch("/dsh-token-use", { cache: "no-store" })
1769
+ .then(function (res) {
1770
+ if (!res.ok) throw new Error("down");
1771
+ return res.json();
1772
+ })
1773
+ .then(function () {
1774
+ if (Date.now() > deadline) {
1775
+ clearInterval(timer);
1776
+ setPower({ mode: "shutdown", phase: "stopped", log: null });
1777
+ }
1778
+ })
1779
+ .catch(function () {
1780
+ clearInterval(timer);
1781
+ setPower({ mode: "shutdown", phase: "stopped", log: null });
1782
+ });
1783
+ }, 1500);
1569
1784
  }, 6000);
1570
1785
  })
1571
1786
  .catch(function (err) {
1572
- setRestart({ state: "error", error: String(err) });
1787
+ setPower(null);
1788
+ setPowerError({ kind: "shutdown", error: String(err) });
1573
1789
  });
1574
1790
  };
1575
1791
 
@@ -1620,6 +1836,11 @@ window.__ModuleLoader__.load({ id: "dsh-token-use", factory: (require) => {
1620
1836
  }
1621
1837
  metaParts.push(t("updated").replace("{time}", fmtTime(data.updatedAt)));
1622
1838
  var scanState = data.scan && data.scan.error ? "error" : (data.scan && data.scan.done ? "done" : "ongoing");
1839
+ /** The pid both confirmation dialogs name, when the config tab has loaded it. */
1840
+ var pidText = function () {
1841
+ return config === null || config === undefined || config.platform === undefined ? "?"
1842
+ : String(config.platform.pid);
1843
+ };
1623
1844
  var rangeItems = [
1624
1845
  { id: "day", label: t("filterDay") },
1625
1846
  { id: "month", label: t("filterMonth") },
@@ -1719,11 +1940,13 @@ window.__ModuleLoader__.load({ id: "dsh-token-use", factory: (require) => {
1719
1940
  config: config,
1720
1941
  error: configError,
1721
1942
  health: health,
1722
- restart: restart,
1723
1943
  harness: harness,
1724
1944
  bundles: bundles,
1725
1945
  onHealth: runHealth,
1726
1946
  onRestart: function () { setConfirming(true); },
1947
+ onShutdown: function () { setConfirmingShutdown(true); },
1948
+ busy: power !== null,
1949
+ powerError: powerError,
1727
1950
  refreshing: refreshing,
1728
1951
  onRefresh: refreshConfig
1729
1952
  })];
@@ -1736,10 +1959,21 @@ window.__ModuleLoader__.load({ id: "dsh-token-use", factory: (require) => {
1736
1959
  title: t("restartTitle"),
1737
1960
  closeLabel: t("close"),
1738
1961
  footer: h("div", { className: "dshtu_modalFoot" },
1739
- h(button, { variant: "ghost", size: "sm", onClick: function () { setConfirming(false); } }, t("restartCancel")),
1962
+ h(button, { variant: "ghost", size: "sm", onClick: function () { setConfirming(false); } }, t("cancel")),
1740
1963
  h(button, { variant: "primary", size: "sm", onClick: doRestart }, t("restartConfirm")))
1741
1964
  }, h("div", { className: "dshtu_modalBody" },
1742
- t("restartBody").replace("{pid}", config === null || config.platform === undefined ? "?" : String(config.platform.pid)))),
1965
+ t("restartBody").replace("{pid}", pidText()))),
1966
+ modal === null ? null : h(modal, {
1967
+ open: confirmingShutdown,
1968
+ onClose: function () { setConfirmingShutdown(false); },
1969
+ title: t("shutdownTitle"),
1970
+ closeLabel: t("close"),
1971
+ footer: h("div", { className: "dshtu_modalFoot" },
1972
+ h(button, { variant: "ghost", size: "sm", onClick: function () { setConfirmingShutdown(false); } }, t("cancel")),
1973
+ h(button, { variant: "primary", size: "sm", className: "dshtu_dangerFill", onClick: doShutdown }, t("shutdownConfirm")))
1974
+ }, h("div", { className: "dshtu_modalBody" },
1975
+ t("shutdownBody").replace("{pid}", pidText()))),
1976
+ h(PowerOverlay, { key: "power", t: t, power: power }),
1743
1977
  h("div", { className: "dshtu_foot" },
1744
1978
  stateDot === null ? null : h(stateDot, { state: scanState, size: 8 }),
1745
1979
  h("span", { className: "dshtu_meta" }, metaParts.join(" · "))));
package/lib/index.js CHANGED
@@ -24,7 +24,8 @@ import { join } from 'node:path'
24
24
  import { zstdDecompressSync } from 'node:zlib'
25
25
  import { scheduler } from 'node:timers/promises'
26
26
  import { PricingStore } from './pricing.js'
27
- import { VersionStore, installedVersion, restartScript, scheduleRestart } from './versions.js'
27
+ import { VersionStore, installedVersion } from './versions.js'
28
+ import { powerCapability, scheduleRestart, scheduleShutdown } from './power.js'
28
29
  import { checkHarness } from './health.js'
29
30
 
30
31
  export const name = 'dsh-token-use'
@@ -790,14 +791,13 @@ export class UsageTracker {
790
791
  // `?refresh=1` is the panel's re-check button: it skips the six-hour cache.
791
792
  const force = new URL(req.url, 'http://localhost').searchParams.get('refresh') === '1'
792
793
  const report = await versions.describe({ port, dshHome: this.dshHome, force })
793
- const planned = restartScript({ pid: process.pid, argv: process.argv, cwd: process.cwd(), logFile: 'probe' })
794
794
  res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
795
795
  res.end(JSON.stringify({
796
796
  ok: true,
797
797
  name,
798
798
  version: VERSION,
799
799
  endpoint: this.endpoint,
800
- restartable: planned !== undefined,
800
+ ...powerCapability(),
801
801
  ...report,
802
802
  }))
803
803
  }
@@ -863,6 +863,33 @@ export class UsageTracker {
863
863
  res.end(JSON.stringify({ ok: true, delaySec: 2, logFile: scheduled.logFile, script: scheduled.script }))
864
864
  }
865
865
 
866
+ /** Shutdown: stop the service for good, after the panel's confirmation. */
867
+ handleShutdown(req, res) {
868
+ res.setHeader('cache-control', 'no-store')
869
+ if (req.method !== 'POST') {
870
+ res.writeHead(405, { 'content-type': 'application/json; charset=utf-8' })
871
+ res.end(JSON.stringify({ ok: false, error: 'method not allowed' }))
872
+ return
873
+ }
874
+ if (!this.trusted(req)) {
875
+ this.deny(res)
876
+ return
877
+ }
878
+ const scheduled = scheduleShutdown(this.dshHome)
879
+ if (scheduled === undefined) {
880
+ res.writeHead(409, { 'content-type': 'application/json; charset=utf-8' })
881
+ res.end(JSON.stringify({ ok: false, error: 'this process cannot be stopped from here' }))
882
+ return
883
+ }
884
+ if (scheduled.error !== undefined) {
885
+ res.writeHead(500, { 'content-type': 'application/json; charset=utf-8' })
886
+ res.end(JSON.stringify({ ok: false, error: scheduled.error }))
887
+ return
888
+ }
889
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
890
+ res.end(JSON.stringify({ ok: true, graceSec: scheduled.graceSeconds, logFile: scheduled.logFile }))
891
+ }
892
+
866
893
  handle(req, res) {
867
894
  res.setHeader('cache-control', 'no-store')
868
895
  if (req.method !== 'GET') {
@@ -959,6 +986,7 @@ export function apply(ctx, config = {}) {
959
986
  { path: `${endpoint}/config`, handler: (req, res) => tracker.handleConfig(req, res, versions) },
960
987
  { path: `${endpoint}/health`, handler: (req, res) => tracker.handleHealth(req, res) },
961
988
  { path: `${endpoint}/restart`, handler: (req, res) => tracker.handleRestart(req, res) },
989
+ { path: `${endpoint}/shutdown`, handler: (req, res) => tracker.handleShutdown(req, res) },
962
990
  ]
963
991
  const disposers = routes.map((route) => host.webServer.register({ kind: 'exact', ...route }))
964
992
  return () => {
package/lib/power.js ADDED
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Process lifecycle for the configuration tab: restart and shutdown.
3
+ *
4
+ * Both actions change the page under the user's feet, so the work happens in a
5
+ * detached shell script rather than inline: the HTTP response has to reach the
6
+ * browser first, and the script must outlive the process it is about to stop.
7
+ *
8
+ * Relaunching replays the running command line (`process.execPath` + `argv` +
9
+ * cwd) instead of guessing a CLI invocation, so custom ports, flags and working
10
+ * directories survive. Shutdown simply stops and never starts again — the user
11
+ * brings the service back by hand, which the panel says in as many words.
12
+ *
13
+ * @module dsh-token-use/power
14
+ */
15
+ import { spawn } from 'node:child_process'
16
+ import { mkdirSync, writeFileSync } from 'node:fs'
17
+ import { join } from 'node:path'
18
+
19
+ /** How long the script waits before touching the process (ms, in whole seconds). */
20
+ const GRACE_SECONDS = 2
21
+ /** How long the script waits for a graceful exit before escalating. */
22
+ const STOP_TIMEOUT_SECONDS = 30
23
+
24
+ /** Single-quote a shell word so paths with spaces survive. */
25
+ function shellQuote(value) {
26
+ return `'${String(value).replaceAll("'", `'\\''`)}'`
27
+ }
28
+
29
+ /** The stop half both scripts share: TERM, wait, then KILL. */
30
+ function stopCommands(pid) {
31
+ return [
32
+ `kill -TERM ${pid} 2>/dev/null`,
33
+ 'i=0',
34
+ `while kill -0 ${pid} 2>/dev/null && [ "$i" -lt ${STOP_TIMEOUT_SECONDS} ]; do sleep 1; i=$((i+1)); done`,
35
+ `kill -9 ${pid} 2>/dev/null`,
36
+ ]
37
+ }
38
+
39
+ /**
40
+ * The detached restart script: wait for the response to land, stop the process
41
+ * that serves this request, then start the very same command again from the
42
+ * same directory.
43
+ *
44
+ * @returns the script text, or undefined when the running command is unusable.
45
+ */
46
+ export function restartScript({ pid, argv, cwd: workingDir, logFile }) {
47
+ const entry = argv[1]
48
+ const rest = argv.slice(2)
49
+ if (typeof entry !== 'string' || entry.length === 0 || typeof pid !== 'number') return undefined
50
+ const command = [process.execPath, entry, ...rest].map(shellQuote).join(' ')
51
+ return [
52
+ '#!/bin/sh',
53
+ '# Written by dsh-token-use: restart the dsh web service on demand.',
54
+ `sleep ${GRACE_SECONDS}`,
55
+ ...stopCommands(pid),
56
+ 'sleep 1',
57
+ `cd ${shellQuote(workingDir)} || exit 1`,
58
+ `nohup ${command} >>${shellQuote(logFile)} 2>&1 </dev/null &`,
59
+ '',
60
+ ].join('\n')
61
+ }
62
+
63
+ /**
64
+ * The detached shutdown script: stop the process and leave it stopped. There is
65
+ * no relaunch, because "关闭" has to mean what it says.
66
+ *
67
+ * @returns the script text, or undefined when the running pid is unusable.
68
+ */
69
+ export function shutdownScript({ pid, logFile }) {
70
+ if (typeof pid !== 'number' || Number.isNaN(pid)) return undefined
71
+ return [
72
+ '#!/bin/sh',
73
+ '# Written by dsh-token-use: stop the dsh web service and stay down.',
74
+ `sleep ${GRACE_SECONDS}`,
75
+ ...stopCommands(pid),
76
+ `echo "dsh-token-use: stopped pid ${pid} at $(date)" >>${shellQuote(logFile)}`,
77
+ '',
78
+ ].join('\n')
79
+ }
80
+
81
+ /** Write a script and run it detached, so it survives the process it stops. */
82
+ function fire(dshHome, name, script) {
83
+ if (script === undefined) return undefined
84
+ const dir = join(dshHome, 'dsh-token-use')
85
+ const scriptFile = join(dir, `${name}.sh`)
86
+ try {
87
+ mkdirSync(dir, { recursive: true })
88
+ writeFileSync(scriptFile, script, { mode: 0o700 })
89
+ } catch (error) {
90
+ return { error: String(error?.message ?? error) }
91
+ }
92
+ const child = spawn('sh', [scriptFile], { detached: true, stdio: 'ignore' })
93
+ child.unref()
94
+ return { script: scriptFile, logFile: join(dir, `${name}.log`), graceSeconds: GRACE_SECONDS }
95
+ }
96
+
97
+ /**
98
+ * Restart the service.
99
+ * @returns `{ script, logFile, graceSeconds }`, or undefined when the current
100
+ * command line cannot be replayed, or `{ error }` when the script cannot be written.
101
+ */
102
+ export function scheduleRestart(dshHome) {
103
+ const logFile = join(dshHome, 'dsh-token-use', 'restart.log')
104
+ return fire(dshHome, 'restart', restartScript({
105
+ pid: process.pid,
106
+ argv: process.argv,
107
+ cwd: process.cwd(),
108
+ logFile,
109
+ }))
110
+ }
111
+
112
+ /**
113
+ * Shut the service down. `restartable` in the config report tells the panel
114
+ * whether this is offered at all.
115
+ */
116
+ export function scheduleShutdown(dshHome) {
117
+ const logFile = join(dshHome, 'dsh-token-use', 'shutdown.log')
118
+ return fire(dshHome, 'shutdown', shutdownScript({ pid: process.pid, logFile }))
119
+ }
120
+
121
+ /** Whether this launch can be restarted / shut down at all. */
122
+ export function powerCapability() {
123
+ return {
124
+ restartable: restartScript({ pid: process.pid, argv: process.argv, cwd: process.cwd(), logFile: 'probe' }) !== undefined,
125
+ shutdownable: shutdownScript({ pid: process.pid, logFile: 'probe' }) !== undefined,
126
+ }
127
+ }
package/lib/versions.js CHANGED
@@ -13,7 +13,7 @@
13
13
  *
14
14
  * @module dsh-token-use/versions
15
15
  */
16
- import { execFileSync, spawn } from 'node:child_process'
16
+ import { execFileSync } from 'node:child_process'
17
17
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
18
18
  import { createRequire } from 'node:module'
19
19
  import { arch, platform as platformName, release, type as osType } from 'node:os'
@@ -224,17 +224,39 @@ export class VersionStore {
224
224
  /**
225
225
  * The registry's install metadata (versions + dist-tags) — the abbreviated
226
226
  * packument, which is a fraction of the full document's size.
227
+ *
228
+ * `accept-encoding: identity` is deliberate: a proxy in front of a local
229
+ * machine can hand back a compressed body that never gets decoded (seen in the
230
+ * wild as brotli bytes), and these documents are small enough that asking for
231
+ * them uncompressed costs nothing. The body is read as text and validated
232
+ * before parsing, so a bad response produces a short diagnostic instead of a
233
+ * JSON error quoting raw binary.
234
+ *
235
+ * @param name - package name.
236
+ * @param variant - `abbreviated` (default) or `full` for the retry.
227
237
  */
228
- async fetchPackument(name) {
238
+ async fetchPackument(name, variant = 'abbreviated') {
229
239
  const url = `${this.registry}/${name.replace('/', '%2f')}`
230
240
  const response = await fetch(url, {
231
- headers: { accept: 'application/vnd.npm.install-v1+json', 'user-agent': USER_AGENT },
241
+ headers: {
242
+ accept: variant === 'full' ? 'application/json' : 'application/vnd.npm.install-v1+json',
243
+ 'accept-encoding': 'identity',
244
+ 'user-agent': USER_AGENT,
245
+ },
232
246
  signal: AbortSignal.timeout(this.timeoutMs),
233
247
  })
234
248
  if (!response.ok) throw new Error(`http ${response.status}`)
235
- const body = await response.json()
249
+ const type = response.headers.get('content-type') ?? ''
250
+ const text = await response.text()
251
+ if (!type.includes('json')) throw new Error(`content-type ${type.length > 0 ? type : '(none)'}, ${text.length} bytes`)
252
+ let body
253
+ try {
254
+ body = JSON.parse(text)
255
+ } catch {
256
+ throw new Error(`${text.length} bytes that are not JSON`)
257
+ }
236
258
  const versions = Object.keys(body?.versions ?? {})
237
- if (versions.length === 0) throw new Error('no versions in registry response')
259
+ if (versions.length === 0) throw new Error('no versions in the response')
238
260
  return { versions, tags: body['dist-tags'] ?? {} }
239
261
  }
240
262
 
@@ -254,18 +276,9 @@ export class VersionStore {
254
276
  const packages = { ...this.cache.packages }
255
277
  const errors = {}
256
278
  await Promise.all(names.map(async (name) => {
257
- try {
258
- const { versions, tags } = await this.fetchPackument(name)
259
- const next = typeof tags.next === 'string' ? tags.next : undefined
260
- packages[name] = {
261
- versions: versions.slice(-40),
262
- tags,
263
- next: next ?? null,
264
- latest: highestInChannel(versions, channelOf(next ?? '')) ?? highestInChannel(versions, 'stable') ?? versions[versions.length - 1],
265
- }
266
- } catch (error) {
267
- errors[name] = String(error?.message ?? error)
268
- }
279
+ const record = await this.lookup(name)
280
+ if (typeof record === 'string') errors[name] = record
281
+ else packages[name] = record
269
282
  }))
270
283
  this.cache = { checkedAt: Date.now(), packages, errors }
271
284
  this.persist()
@@ -277,6 +290,30 @@ export class VersionStore {
277
290
  }
278
291
  }
279
292
 
293
+ /**
294
+ * One package's registry record, or a short reason string when the lookup
295
+ * failed. A second attempt with the full document rides out the transient
296
+ * failures a proxy can introduce.
297
+ */
298
+ async lookup(name) {
299
+ let first
300
+ for (const variant of ['abbreviated', 'full']) {
301
+ try {
302
+ const { versions, tags } = await this.fetchPackument(name, variant)
303
+ const next = typeof tags.next === 'string' ? tags.next : undefined
304
+ return {
305
+ versions: versions.slice(-40),
306
+ tags,
307
+ next: next ?? null,
308
+ latest: highestInChannel(versions, channelOf(next ?? '')) ?? highestInChannel(versions, 'stable') ?? versions[versions.length - 1],
309
+ }
310
+ } catch (error) {
311
+ if (first === undefined) first = cleanMessage(error?.message ?? error)
312
+ }
313
+ }
314
+ return first
315
+ }
316
+
280
317
  persist() {
281
318
  try {
282
319
  mkdirSync(dirname(this.file), { recursive: true })
@@ -333,64 +370,12 @@ export class VersionStore {
333
370
  }
334
371
  }
335
372
 
336
- /** Single-quote a shell word so paths with spaces survive. */
337
- function shellQuote(value) {
338
- return `'${String(value).replaceAll("'", `'\\''`)}'`
339
- }
340
-
341
373
  /**
342
- * The detached restart script: wait for the response to land, stop the process
343
- * that serves this request, then start the very same command again from the
344
- * same directory. Relaunching by `argv` (not by a guessed CLI invocation)
345
- * keeps custom flags, ports and working directories intact.
346
- *
347
- * @returns the script text, or undefined when the running command is unusable.
374
+ * Keep a diagnostic to one printable short line. Error text can quote a
375
+ * response body, and a panel has no business rendering raw bytes.
348
376
  */
349
- export function restartScript({ pid, argv, cwd: workingDir, logFile }) {
350
- const entry = argv[1]
351
- const rest = argv.slice(2)
352
- if (typeof entry !== 'string' || entry.length === 0 || typeof pid !== 'number') return undefined
353
- const command = [process.execPath, entry, ...rest].map(shellQuote).join(' ')
354
- return [
355
- '#!/bin/sh',
356
- '# Written by dsh-token-use: restart the dsh web service on demand.',
357
- 'sleep 2',
358
- `kill -TERM ${pid} 2>/dev/null`,
359
- 'i=0',
360
- `while kill -0 ${pid} 2>/dev/null && [ "$i" -lt 30 ]; do sleep 1; i=$((i+1)); done`,
361
- `kill -9 ${pid} 2>/dev/null`,
362
- 'sleep 1',
363
- `cd ${shellQuote(workingDir)} || exit 1`,
364
- `nohup ${command} >>${shellQuote(logFile)} 2>&1 </dev/null &`,
365
- '',
366
- ].join('\n')
367
- }
368
-
369
- /**
370
- * Write and fire the restart script, fully detached so it outlives the server
371
- * it is about to stop.
372
- *
373
- * @returns `{ script, logFile }` for the response, or undefined when the
374
- * current command line cannot be relaunched safely.
375
- */
376
- export function scheduleRestart(dshHome) {
377
- const dir = join(dshHome, 'dsh-token-use')
378
- const logFile = join(dir, 'restart.log')
379
- const scriptFile = join(dir, 'restart.sh')
380
- const script = restartScript({
381
- pid: process.pid,
382
- argv: process.argv,
383
- cwd: process.cwd(),
384
- logFile,
385
- })
386
- if (script === undefined) return undefined
387
- try {
388
- mkdirSync(dir, { recursive: true })
389
- writeFileSync(scriptFile, script, { mode: 0o700 })
390
- } catch (error) {
391
- return { error: String(error?.message ?? error) }
392
- }
393
- const child = spawn('sh', [scriptFile], { detached: true, stdio: 'ignore' })
394
- child.unref()
395
- return { script: scriptFile, logFile }
377
+ function cleanMessage(message) {
378
+ const text = String(message).replace(/[^\t\n\r\x20-\x7e]/g, '').replace(/\s+/g, ' ').trim()
379
+ if (text.length === 0) return 'unknown error'
380
+ return text.length > 140 ? `${text.slice(0, 137)}...` : text
396
381
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-token-use",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "Real-time token usage and estimated spend for DeepSeek Harness, in a four-tab settings panel: an overview with cost/totals cards, a trend chart with a daily-average line and a 12-month usage heatmap; by-model and by-project tables with day/month/year/date-range queries; and versions, runtime facts, a harness self-check and a one-click restart. Prices come from DeepSeek's official page, refreshed daily, peak/off-peak aware. · 实时 Token 用量与估算金额面板,四个 tab:概览(金额/总量卡片、带日均线的趋势图、近 12 个月使用热力图)、模型统计与项目统计(按天/月/年/时间段/全部查询)、配置信息(版本对比、运行环境、Harness 自检、一键重启)。定价取 DeepSeek 官网、每日自动更新,区分高峰/空闲。",
5
5
  "keywords": [
6
6
  "dsh",