dsh-token-use 0.3.2 → 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);
@@ -166,6 +195,23 @@ window.__ModuleLoader__.load({ id: "dsh-token-use", factory: (require) => {
166
195
  versionRefreshing: "检查中…",
167
196
  versionFailed: "本次检查未成功:{names}(显示的是上次结果)",
168
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}",
169
215
  healthCheck: "健康检查",
170
216
  healthRunning: "检查中…",
171
217
  healthOk: "接口正常",
@@ -328,6 +374,23 @@ window.__ModuleLoader__.load({ id: "dsh-token-use", factory: (require) => {
328
374
  versionRefreshing: "Checking…",
329
375
  versionFailed: "This check failed for {names} — showing the last successful result",
330
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}",
331
394
  healthCheck: "Health check",
332
395
  healthRunning: "Checking…",
333
396
  healthOk: "Endpoint healthy",
@@ -405,7 +468,7 @@ window.__ModuleLoader__.load({ id: "dsh-token-use", factory: (require) => {
405
468
  hintServiceVersion: "a service bundle older or newer than the running CLI is a common cause of failed plugin startups"
406
469
  };
407
470
 
408
- 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%)}}";
409
472
 
410
473
  function trimNum(v, decimals) {
411
474
  var f = v.toFixed(decimals);
@@ -1206,6 +1269,60 @@ window.__ModuleLoader__.load({ id: "dsh-token-use", factory: (require) => {
1206
1269
  }).reduce(function (flat, pair) { return flat.concat(pair); }, []))));
1207
1270
  }
1208
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
+
1209
1326
  /** `已安装 / 最新 / 状态` for one tracked package. */
1210
1327
  function versionRow(t, entry) {
1211
1328
  var tone = entry.status === "current" ? "ok" : (entry.status === "outdated" ? "warn" : "idle");
@@ -1272,7 +1389,17 @@ window.__ModuleLoader__.load({ id: "dsh-token-use", factory: (require) => {
1272
1389
  var healthLabel = health === null ? null : health.state === "ok" ? t("healthOk") : health.state === "error" ? t("healthFail") + ":" + health.error : t("healthRunning");
1273
1390
  return h("div", { className: "dshtu_stack" },
1274
1391
  h("div", { className: "dshtu_card" },
1275
- 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"))),
1276
1403
  h("table", { className: "dshtu_table" },
1277
1404
  h("thead", null, h("tr", null,
1278
1405
  h("th", null, t("versionPackage")),
@@ -1280,16 +1407,8 @@ window.__ModuleLoader__.load({ id: "dsh-token-use", factory: (require) => {
1280
1407
  h("th", null, t("versionLatest")),
1281
1408
  h("th", null, t("versionState")))),
1282
1409
  h("tbody", null, (config.packages || []).map(function (entry) { return versionRow(t, entry); }))),
1283
- h("div", { className: "dshtu_bar dshtu_tblBar" },
1284
- h("span", { className: "dshtu_meta" }, config.checkedAt > 0 ? t("versionChecked").replace("{time}", fmtTime(config.checkedAt)) : ""),
1285
- h("span", { style: { flex: "1" } }),
1286
- h(button, {
1287
- variant: "outline",
1288
- size: "sm",
1289
- disabled: props.refreshing === true,
1290
- icon: iconRefresh === null ? null : h(iconRefresh, {}),
1291
- onClick: props.onRefresh
1292
- }, 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)) : ""),
1293
1412
  failed.length === 0 ? null : h("div", {
1294
1413
  className: "dshtu_state",
1295
1414
  "data-tone": "warn",
@@ -1323,19 +1442,30 @@ window.__ModuleLoader__.load({ id: "dsh-token-use", factory: (require) => {
1323
1442
  config.restartable === false
1324
1443
  ? h("span", { className: "dshtu_meta" }, t("restartBlocked"))
1325
1444
  : h(button, {
1326
- variant: "primary",
1445
+ variant: "outline",
1327
1446
  size: "sm",
1447
+ className: "dshtu_warnBtn",
1328
1448
  icon: iconRefresh === null ? null : h(iconRefresh, {}),
1329
- disabled: props.restart !== null && props.restart.state !== "error",
1449
+ disabled: props.busy === true,
1330
1450
  onClick: props.onRestart
1331
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")),
1332
1462
  healthLabel === null ? null : h("span", { className: "dshtu_state", "data-tone": tone },
1333
1463
  health.state === "running" && iconLoading !== null ? h(iconLoading, {}) : null,
1334
1464
  healthLabel)),
1335
- props.restart === null ? null : h("div", { className: "dshtu_meta", style: { marginTop: "10px" } },
1336
- props.restart.state === "pending" ? t("restartPending")
1337
- : props.restart.state === "done" ? t("restartDone")
1338
- : 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))),
1339
1469
  h(HealthPanel, {
1340
1470
  key: "health",
1341
1471
  t: t,
@@ -1381,8 +1511,11 @@ window.__ModuleLoader__.load({ id: "dsh-token-use", factory: (require) => {
1381
1511
  var _p = useState(null), configError = _p[0], setConfigError = _p[1];
1382
1512
  var _q = useState(0), configTick = _q[0], setConfigTick = _q[1];
1383
1513
  var _r = useState(null), health = _r[0], setHealth = _r[1];
1384
- var _s = useState(null), restart = _s[0], setRestart = _s[1];
1514
+ var _s = useState(null), powerError = _s[0], setPowerError = _s[1];
1385
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];
1386
1519
  var _u = useState(null), harness = _u[0], setHarness = _u[1];
1387
1520
  var _v = useState(null), bundles = _v[0], setBundles = _v[1];
1388
1521
  var _w = useState(false), refreshing = _w[0], setRefreshing = _w[1];
@@ -1561,9 +1694,17 @@ window.__ModuleLoader__.load({ id: "dsh-token-use", factory: (require) => {
1561
1694
  * had time to go down (the script waits two seconds), so the first success
1562
1695
  * cannot be the old process answering.
1563
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
+ */
1564
1703
  var doRestart = function () {
1565
1704
  setConfirming(false);
1566
- setRestart({ state: "pending" });
1705
+ setPowerError(null);
1706
+ var previousStartedAt = data === null || data === undefined ? null : data.startedAt;
1707
+ setPower({ mode: "restart", phase: "prepare", log: null });
1567
1708
  fetch("/dsh-token-use/restart", { method: "POST", headers: { accept: "application/json" } })
1568
1709
  .then(function (res) {
1569
1710
  return res.json().then(function (json) {
@@ -1571,25 +1712,80 @@ window.__ModuleLoader__.load({ id: "dsh-token-use", factory: (require) => {
1571
1712
  return json;
1572
1713
  });
1573
1714
  })
1574
- .then(function () {
1575
- var attempts = 0;
1715
+ .then(function (json) {
1716
+ setPower({ mode: "restart", phase: "stopping", log: json.logFile || null });
1576
1717
  setTimeout(function () {
1718
+ setPower({ mode: "restart", phase: "recovering", log: json.logFile || null });
1719
+ var deadline = Date.now() + 90000;
1577
1720
  var timer = setInterval(function () {
1578
- attempts++;
1721
+ if (Date.now() > deadline) {
1722
+ clearInterval(timer);
1723
+ setPower({ mode: "restart", phase: "timeout", log: json.logFile || null });
1724
+ return;
1725
+ }
1579
1726
  fetch("/dsh-token-use", { cache: "no-store" })
1580
- .then(function (res) {
1581
- if (!res.ok) return;
1582
- clearInterval(timer);
1583
- setRestart({ state: "done" });
1584
- 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
+ }
1585
1735
  })
1586
1736
  .catch(function () {});
1587
- if (attempts > 90) clearInterval(timer);
1588
- }, 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);
1589
1784
  }, 6000);
1590
1785
  })
1591
1786
  .catch(function (err) {
1592
- setRestart({ state: "error", error: String(err) });
1787
+ setPower(null);
1788
+ setPowerError({ kind: "shutdown", error: String(err) });
1593
1789
  });
1594
1790
  };
1595
1791
 
@@ -1640,6 +1836,11 @@ window.__ModuleLoader__.load({ id: "dsh-token-use", factory: (require) => {
1640
1836
  }
1641
1837
  metaParts.push(t("updated").replace("{time}", fmtTime(data.updatedAt)));
1642
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
+ };
1643
1844
  var rangeItems = [
1644
1845
  { id: "day", label: t("filterDay") },
1645
1846
  { id: "month", label: t("filterMonth") },
@@ -1739,11 +1940,13 @@ window.__ModuleLoader__.load({ id: "dsh-token-use", factory: (require) => {
1739
1940
  config: config,
1740
1941
  error: configError,
1741
1942
  health: health,
1742
- restart: restart,
1743
1943
  harness: harness,
1744
1944
  bundles: bundles,
1745
1945
  onHealth: runHealth,
1746
1946
  onRestart: function () { setConfirming(true); },
1947
+ onShutdown: function () { setConfirmingShutdown(true); },
1948
+ busy: power !== null,
1949
+ powerError: powerError,
1747
1950
  refreshing: refreshing,
1748
1951
  onRefresh: refreshConfig
1749
1952
  })];
@@ -1756,10 +1959,21 @@ window.__ModuleLoader__.load({ id: "dsh-token-use", factory: (require) => {
1756
1959
  title: t("restartTitle"),
1757
1960
  closeLabel: t("close"),
1758
1961
  footer: h("div", { className: "dshtu_modalFoot" },
1759
- 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")),
1760
1963
  h(button, { variant: "primary", size: "sm", onClick: doRestart }, t("restartConfirm")))
1761
1964
  }, h("div", { className: "dshtu_modalBody" },
1762
- 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 }),
1763
1977
  h("div", { className: "dshtu_foot" },
1764
1978
  stateDot === null ? null : h(stateDot, { state: scanState, size: 8 }),
1765
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'
@@ -379,65 +379,3 @@ function cleanMessage(message) {
379
379
  if (text.length === 0) return 'unknown error'
380
380
  return text.length > 140 ? `${text.slice(0, 137)}...` : text
381
381
  }
382
-
383
- /** Single-quote a shell word so paths with spaces survive. */
384
- function shellQuote(value) {
385
- return `'${String(value).replaceAll("'", `'\\''`)}'`
386
- }
387
-
388
- /**
389
- * The detached restart script: wait for the response to land, stop the process
390
- * that serves this request, then start the very same command again from the
391
- * same directory. Relaunching by `argv` (not by a guessed CLI invocation)
392
- * keeps custom flags, ports and working directories intact.
393
- *
394
- * @returns the script text, or undefined when the running command is unusable.
395
- */
396
- export function restartScript({ pid, argv, cwd: workingDir, logFile }) {
397
- const entry = argv[1]
398
- const rest = argv.slice(2)
399
- if (typeof entry !== 'string' || entry.length === 0 || typeof pid !== 'number') return undefined
400
- const command = [process.execPath, entry, ...rest].map(shellQuote).join(' ')
401
- return [
402
- '#!/bin/sh',
403
- '# Written by dsh-token-use: restart the dsh web service on demand.',
404
- 'sleep 2',
405
- `kill -TERM ${pid} 2>/dev/null`,
406
- 'i=0',
407
- `while kill -0 ${pid} 2>/dev/null && [ "$i" -lt 30 ]; do sleep 1; i=$((i+1)); done`,
408
- `kill -9 ${pid} 2>/dev/null`,
409
- 'sleep 1',
410
- `cd ${shellQuote(workingDir)} || exit 1`,
411
- `nohup ${command} >>${shellQuote(logFile)} 2>&1 </dev/null &`,
412
- '',
413
- ].join('\n')
414
- }
415
-
416
- /**
417
- * Write and fire the restart script, fully detached so it outlives the server
418
- * it is about to stop.
419
- *
420
- * @returns `{ script, logFile }` for the response, or undefined when the
421
- * current command line cannot be relaunched safely.
422
- */
423
- export function scheduleRestart(dshHome) {
424
- const dir = join(dshHome, 'dsh-token-use')
425
- const logFile = join(dir, 'restart.log')
426
- const scriptFile = join(dir, 'restart.sh')
427
- const script = restartScript({
428
- pid: process.pid,
429
- argv: process.argv,
430
- cwd: process.cwd(),
431
- logFile,
432
- })
433
- if (script === undefined) return undefined
434
- try {
435
- mkdirSync(dir, { recursive: true })
436
- writeFileSync(scriptFile, script, { mode: 0o700 })
437
- } catch (error) {
438
- return { error: String(error?.message ?? error) }
439
- }
440
- const child = spawn('sh', [scriptFile], { detached: true, stdio: 'ignore' })
441
- child.unref()
442
- return { script: scriptFile, logFile }
443
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-token-use",
3
- "version": "0.3.2",
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",