@kira905/dsh-ecosystem-panel 0.4.5 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  本仓遵循 [SemVer](https://semver.org/lang/zh-CN/):使用者可见的行为变化才升主版本/次版本,纯内部重构走修订号。
4
4
 
5
+ ## [0.5.0] - 2026-10-30
6
+
7
+ ### Added
8
+
9
+ - **首屏三件:SWR + 周期预热 + 分片取数(含单飞 / 超时预算 / 局部降级)+ 页脚 SLI** —— 数据块自带 `fetchedAt`,过期判据 = `now - fetchedAt > ttlMs`(缺省 **300 s**,出处 = 文档仓《只读监控面板的设计纪律》§3.2「复用既有的五分钟节奏,不新增定时作业」);过期先渲染旧值并标「旧了 N 秒」、后台刷新回来再替换,冷启动阻塞取真值并**单独记 `coldMiss`**(不许把冷启动算成命中);周期预热**缺省关闭**(配置层 + 三个环境变量口:`WARMUP` / `WARMUP_MS` / `WARMUP_SOURCES`,缺省只预热纯本地五源),周期与 TTL 同源;数据源清单参数化为 `lib/state.js` 的 `SOURCES`(加源不改装配主流程,运行时也可 `registerSource()`),同一源同一时刻只允许一次在途取数(并发请求共享它),每源单独超时 + 整页总预算,某源超时/报错/未配置 ⇒ **只有那一格**标「不可探测 + 原因」、其余照出;页脚一行给 SLI:取数耗时(每源 + 总)/ 命中率(命中 · 未命中 · 冷启动分开)/ 降级次数(每源)/ 预热状态。新增夹具四件(`test-swr` / `test-warmup-negctl` / `test-singleflight` / `test-degrade`,其中预热**阴性对照**关掉后必须看得见冷窗:冷启动 0 → 10、首屏 1 ms → 159 ms)+ 新文件行级同源度自查 `sim-selfcheck.mjs`(7 个新文件最高 22.4%,全部 < 50%)。`GET /state?refresh=all` 可整屏丢缓存重取。
10
+
11
+ ### Fixed
12
+
13
+ - **boot 清单把 HTTP 401 当成"空清单"**(隔离实例实测):需要 token 的实例(`/?token=…`)对服务端的无 cookie 抓取回 401,旧代码把 401 的正文当宿主 HTML、解析出 0 条 ⇒ 每一行插件都报「boot 无 client 条目(疑似未加载/崩溃)」。现在**非 2xx 一律按"抓不到"处理**(`meta.bootEntries = null` = 未知,`bootError` 写明状态码),插件行如实显示「加载状态未知」;夹具新增 401 断言。
14
+
5
15
  ## [0.4.5] - 2026-10-19
6
16
 
7
17
  ### Fixed
package/README.en.md CHANGED
@@ -26,6 +26,7 @@ This component is one implementation of the operational system described in
26
26
  (the fixed four questions that documentation repository defines):
27
27
 
28
28
  - **Why it exists and where its boundary is** → see *Design Discipline for a Read-Only Monitoring Panel* **§1 the questions the panel must answer**, **§2 display discipline (not probeable + reason)**; this component's six checks are one landing of that discipline
29
+ - **How the first screen fetches (SWR / periodic warmup / sharding + single-flight + timeout budget + partial degradation)** → see *Design Discipline for a Read-Only Monitoring Panel* **§3.2 "response: sharding + TTL + single-flight + timeout budget"** (**TTL = 300 s** comes from that section: "reuse the existing five-minute rhythm, add no scheduled job"); the same section's rule "caching goes through TTL + single-flight only, no HTTP cache negotiation" is also why this implementation skips ETag/304 (see §5.1 below)
29
30
  - **Its compatibility window with the host** → see *Multi-machine Handoff and Cloud Relay* **§4.5 "compatibility requirements"** (see also §6 "Compatibility" in this README)
30
31
  - **This component's own pitfalls and measurements** → the "Known limitations" section of this README; see also *Design Discipline for a Read-Only Monitoring Panel* **§7 summary of design lessons** (where the three cases of "silently not reporting" came from)
31
32
  - **Documentation repository** → Gitee (mirror) <https://gitee.com/kira905/ops-handoff-design>
@@ -53,6 +54,23 @@ This component is one implementation of the operational system described in
53
54
  **Design red line (throughout the repository)**: anything that cannot be read is shown as "not probeable + reason" — **never 0, never a pretend all-green**.
54
55
  The panel meta also carries a **self-attestation of configuration provenance** (which layer each configuration key was actually read from: CLI / environment variable / config file / auto-detection / built-in default). Look at it first when troubleshooting on a different machine.
55
56
 
57
+ ## Shared-source notice
58
+
59
+ Some files in this repository **share a source with an internal production implementation**
60
+ (the same engineering effort, evolved over time, with two landing places).
61
+
62
+ - **How much**: measured by the **line-level shared-source ratio** (among same-named comparable files, how
63
+ many non-empty lines of this repository's files are found in the reference files, multiset-deducted); this
64
+ repository's "≥50% line-share" figure is **100%**.
65
+ - **What that means**: this repository is a **distilled export** of that implementation — **neither a verbatim
66
+ copy nor a fully rewritten independent implementation**. Before reading any single file as an "independent
67
+ reference implementation", run the check below.
68
+ - **How to re-run it**: the shared-source scanner ships with `dsh-quality-toolkit` as `dsh-sim-scan.mjs`
69
+ (`--repo <this repo> --prod-root <reference root>`); the bucket thresholds and the measurement caliber live
70
+ in its `config/sim-scan.json`, not buried in code.
71
+ - **License**: see `LICENSE` at the repository root (when a subdirectory carries its own exception, that
72
+ subdirectory's `LICENSE` wins).
73
+
56
74
  ## 2. Prerequisites
57
75
 
58
76
  - A DSH instance (web profile); for the host package line see the compatibility table in §6
@@ -69,7 +87,7 @@ npm install @kira905/dsh-ecosystem-panel --prefix "<data root>/profiles/web"
69
87
  # "<data root>/profiles/web/node_modules/@kira905/dsh-ecosystem-panel"
70
88
 
71
89
  # 2. Register it in the profile's package.json:
72
- # "dependencies": add "@kira905/dsh-ecosystem-panel": "0.4.4"
90
+ # "dependencies": add "@kira905/dsh-ecosystem-panel": "0.5.0"
73
91
  # "dsh.profile.bundles": add "@kira905/dsh-ecosystem-panel" to the array
74
92
 
75
93
  # 3. Restart DSH (this plugin has no hot reload), then open the GUI: the panel button should appear
@@ -112,6 +130,13 @@ Precedence (**per key, independently**): `explicit input (plugin config / call a
112
130
  | `DSH_ECOSYSTEM_PANEL_LABELS_FILE` | package name → one-line description, JSON | empty (an unregistered package simply has no description) |
113
131
  | `DSH_ECOSYSTEM_PANEL_DISABLED_GROUPS` | classes disabled as a whole (comma-separated) | all enabled |
114
132
  | `DSH_ECOSYSTEM_PANEL_WEBSERVER_SERVICE` | host web server service name | `webServer` |
133
+ | `DSH_ECOSYSTEM_PANEL_TTL_MS` | the "expired" threshold (`fetchedAt` versus now) | `300000` (300 s, see §5.1) |
134
+ | `DSH_ECOSYSTEM_PANEL_SOURCE_TIMEOUT_MS` | default per-source timeout | `2000` ms |
135
+ | `DSH_ECOSYSTEM_PANEL_TOTAL_BUDGET_MS` | total budget for one first screen | `10000` ms |
136
+ | `DSH_ECOSYSTEM_PANEL_SOURCE_TIMEOUTS` | per-source timeout overrides, e.g. `boot=1500,skills=500` | empty (per-source default) |
137
+ | `DSH_ECOSYSTEM_PANEL_WARMUP` | periodic warmup switch (`1/true/on` to enable) | `0` (**off**: a resident background job costs resources, so it is not decided for you) |
138
+ | `DSH_ECOSYSTEM_PANEL_WARMUP_MS` | warmup period (below 5000 is clamped up; 0/negative falls back to the default) | `300000` (300 s, same origin as the TTL) |
139
+ | `DSH_ECOSYSTEM_PANEL_WARMUP_SOURCES` | which sources to warm (comma-separated; `*` = all) | `boot,bundles,inserts,patches,skills` (the five purely local ones) |
115
140
 
116
141
  ### 4.2 Configuration file and examples
117
142
 
@@ -141,10 +166,38 @@ DSH_ECOSYSTEM_PANEL_CONFIG=/path/to/my.config.json # points at the copy you ed
141
166
  ## 5. Interface
142
167
 
143
168
  - Each of the six classes has its own tab; the small dot on a tab = the worst state of that class (red > yellow > green)
144
- - Clicking "refresh" fetches the data again; `GET /state?refresh=1` forces a fresh lookup of the npm latest version (otherwise the 24 h cache is used)
169
+ - Clicking "refresh" fetches the data again; `GET /state?refresh=1` forces a fresh lookup of the npm latest version (otherwise the 24 h cache is used); `GET /state?refresh=all` drops the whole cache and refetches (for troubleshooting, not for everyday clicks)
145
170
  - Colours: green = healthy / yellow = at risk or not judgeable / red = abnormal (a missing patch insert entity, for instance)
146
171
  - The panel's title and footer note can be overridden by configuration (`text.title` / `text.note` / `text.tabs`, delivered by the server through `meta.ui`)
147
172
 
173
+ ### 5.1 Why the first screen does not spin (SWR / periodic warmup / sharded fetch)
174
+
175
+ A read-only panel's first screen has three classic failure modes. This version pairs each with a remedy, and all the readings land on the single footer line:
176
+
177
+ | Failure mode | Remedy | What you see |
178
+ |---|---|---|
179
+ | ① Data goes stale and you have to sit through a spinner | **SWR**: every data block carries `fetchedAt`; "expired" means `now - fetchedAt > threshold` (default **300 s**). When expired, the **old value is rendered first** while a refresh runs in the background | A banner at the top: "🕗 This is a stale value: skills is 320 s old (refreshing in the background, it will replace itself)". No spinner |
180
+ | ② Every cold start computes from scratch (slow) | **Periodic warmup**: configurable period + three environment-variable ports; **off by default**; once on it primes immediately and then keeps the rhythm | Footer: "warmup on · 300 s · ran N times"; when off it says "warmup off" |
181
+ | ③ One slow or broken source wastes the whole page | **Sharding + single-flight + timeout budget + partial degradation**: the source list is parameterised, one source has at most one in-flight fetch at a time, each source has its own timeout plus a page-wide budget, and a bad source **only breaks its own cell** | That cell becomes "not probeable: timeout / error / not configured / total budget exhausted" while everything else renders and the page still returns 200 |
182
+
183
+ **Why 300 s**: it reuses the existing five-minute rhythm and **adds no scheduled job** — the source is §3.2 of *Design Discipline for a Read-Only Monitoring Panel* (see "Design basis"). Change `ttlMs` if you must, but do not write a second number somewhere in the code.
184
+
185
+ **Why warmup is off by default**: warmup is a resident background activity that spends resources on your behalf, so it is not a decision to make for you. When enabled it primes only the **five purely local sources** (`boot / bundles / inserts / patches / skills`); `decoupling / upgrades / registry` run external scripts or query npm and are left out unless you name them (or pass `*` to warm everything).
186
+
187
+ **A cold start must never return an empty shell**: with no old value at all, that request **blocks for the real value** and is recorded separately as `coldMiss` in the SLI (otherwise the hit rate would count cold starts as hits and the reading would be worthless). The footer always keeps the three buckets apart: `hit 12 (70.6%) · miss 3 · cold 2`.
188
+
189
+ **How "warmup works" is proven**: not by watching it run, but by the fact that **turning it off must reveal a cold window**. The fixture `scripts/test-warmup-negctl.mjs` is exactly that negative control:
190
+
191
+ ```
192
+ scenario first-screen cold starts sources
193
+ warmup on 1 ms 0 10
194
+ warmup off 159 ms 10 10
195
+ ```
196
+
197
+ **Sharding is parameterised**: adding a data source means adding one entry to the source list in `lib/state.js` (`{ id, label, group, deps, timeoutMs, run, describe }`) — **not a single line of the assembly flow changes**. A downstream plugin can also call `registerSource()` at runtime to hang its own health shard into this panel (the fixtures verify exactly that).
198
+
199
+ **Footer SLI line** (`state.sli`): fetch time (per source + total) · hit rate (hit / miss / cold start kept apart) · degradation count (per source) · warmup state. There is no second dashboard.
200
+
148
201
  ## 6. Compatibility
149
202
 
150
203
  | Host package line | Status | Basis |
@@ -171,15 +224,22 @@ DSH_ECOSYSTEM_PANEL_CONFIG=/path/to/my.config.json # points at the copy you ed
171
224
  ## 8. Repository layout
172
225
 
173
226
  ```
174
- lib/index.js server side: registers the read-only route
175
- lib/state.js the six-class aggregation logic (every data source goes through the config layer and degrades explicitly)
227
+ lib/index.js server side: registers the read-only route and starts the warmer (route and warmer share one fetch lane)
228
+ lib/state.js the six-class aggregation logic + **the (parameterised) source list** + first-screen assembly (every data source goes through the config layer and degrades explicitly)
229
+ lib/fetch-lane.js the fetch lane: TTL cache + SWR / single-flight / timeout budget / partial degradation / SLI counters
230
+ lib/warmup.js periodic warmup (off by default; three environment-variable ports; only the five purely local sources by default)
176
231
  lib/upgrade.js ⑤ the upgrade check (local facts + npm cache → external read-only CLI → rendering)
177
- lib/config.js the config layer (precedence resolution + placeholder expansion + provenance self-attestation)
232
+ lib/config.js the config layer (precedence resolution + placeholder expansion + provenance self-attestation + fetch policy)
178
233
  lib/host-protocol.js host protocol constants (this plugin uses only the single service name webServer)
179
- lib/client.js browser side: sidebar button + panel (zero imports)
234
+ lib/client.js browser side: sidebar button + panel + stale-value banner + footer SLI (zero imports)
180
235
  cordis.patch.yml bundle patch (mounts the plugin into the host)
181
236
  scripts/verify-source.mjs pre-release static verification (syntax + config-layer behaviour + sanitization scan)
182
237
  scripts/test-fixture.mjs end-to-end fixture on a clean temporary path (with assertions for degradation on missing data)
238
+ scripts/test-swr.mjs fixture ①: stale-first value / cold start blocks for the real value and records coldMiss
239
+ scripts/test-warmup-negctl.mjs fixture ②: warmup negative control (turning it off must reveal the cold window)
240
+ scripts/test-singleflight.mjs fixture ③: concurrent requests to one source ⇒ the source is called once
241
+ scripts/test-degrade.mjs fixture ④: a bad source only breaks its own cell; the rest renders and the total is not dragged
242
+ scripts/sim-selfcheck.mjs line-level same-source check for new files (< 50% to pass)
183
243
  scripts/verify-state.mjs read-only state verifier (no GUI needed)
184
244
  examples/ five configuration examples
185
245
  docs/RELEASING.md release conventions and checklist
@@ -190,7 +250,14 @@ docs/RELEASING.md release conventions and checklist
190
250
  ```bash
191
251
  node scripts/verify-source.mjs . # syntax + config-layer behaviour + sanitization scan (must be run before a release)
192
252
  node scripts/test-fixture.mjs # end-to-end fixture: needs no real installation, everything is built in a temporary directory
193
- node scripts/verify-state.mjs # read-only health check of the current instance (prints the six classes + configuration provenance)
253
+ node scripts/test-swr.mjs # first-screen item ①: SWR
254
+ node scripts/test-warmup-negctl.mjs # first-screen item ②: warmup negative control (off must reveal the cold window)
255
+ node scripts/test-singleflight.mjs # first-screen item ③: single-flight
256
+ node scripts/test-degrade.mjs # first-screen item ③: sharding + timeout budget + partial degradation
257
+ node scripts/sim-selfcheck.mjs --since <commit> # line-level same-source ratio of new files (< 50% to pass)
258
+ node scripts/verify-state.mjs # read-only health check of the current instance (six classes + provenance + three-bucket hit rate)
259
+ # or all at once:
260
+ npm run test:all
194
261
  ```
195
262
 
196
263
  `test-fixture.mjs` builds a minimal data root on the spot (a fake profile / fake plugin entities / fake skills / four external tool stubs), asserts that all six classes produce a result, and specifically verifies that "missing data always degrades explicitly".
package/README.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # dsh-ecosystem-panel
2
2
 
3
+ > **本仓装完即用**:`dsh plugin --profile <profile> add @kira905/dsh-ecosystem-panel` 一条命令,`dsh.bundle` 会让它自动挂进 profile 的组合列表,**不需要再手工写 `cordis.patch.yml`**。
4
+
5
+ ## 装完自检(三条只读验法)
6
+
7
+ `<profile>` 换成你实际用的 profile(默认 `web`),`<数据根>` 换成你的 DSH 数据根(默认 `~/.dsh`)。
8
+
9
+ ```powershell
10
+ # ① 组合树里有没有它(只读、约 0.6 秒、不启服务)
11
+ dsh --profile <profile> --dump-config | Select-String "dsh-ecosystem-panel"
12
+ # 期望:bundle 层出现一行 "# == <登记名>"、insert 层出现 "- id: <entry>"(问的都是「它挂上没」);
13
+ # 本仓包名 @kira905/dsh-ecosystem-panel —— 带 scope 装的写带 scope 名、老工装 / 手写的可能是裸名,两种都搜一遍
14
+ # ⚠️ 若该包会 insert 一行,还要数一遍"同一个 id 出现了几次",必须 == 1
15
+
16
+ # ② 组合列表里有没有它(只读)
17
+ Get-Content <数据根>/profiles/<profile>/package.json | Select-String "@kira905/dsh-ecosystem-panel"
18
+ # 期望:dependencies 与 dsh.profile.bundles 两处都有(CLI 类只需要 dependencies;bundles 里也可能写裸名 dsh-ecosystem-panel)
19
+
20
+ # ③ 本仓是插件、没有命令入口 —— 跳过这一步(判据看 ① 与 ②)
21
+ ```
22
+
23
+ ---
24
+
3
25
  > **English**: [README.en.md](./README.en.md)
4
26
 
5
27
  > 给 DSH 实例装一块**只读**的生态总览面板:插件有没有加载、补丁还在不在、技能 frontmatter 有没有写坏、
@@ -19,6 +41,7 @@ GET /api/dsh-ecosystem-panel/state
19
41
  设计依据(对应文档仓约定的固定四问):
20
42
 
21
43
  - **它为什么存在、边界在哪** → 见《只读监控面板的设计纪律》**§1 面板要回答的问题**、**§2 显示纪律(不可探测 + 原因)**;本组件的六类体检正是那份纪律的一次落地
44
+ - **首屏怎么取数(SWR / 周期预热 / 分片 + 单飞 + 超时预算 + 局部降级)** → 见《只读监控面板的设计纪律》**§3.2「处置:分片 + TTL + 单飞 + 超时预算」**(**TTL = 300 s** 的出处就在这一节:「复用既有五分钟节奏,不新增定时作业」);同篇 §3.2 末尾「缓存只走 TTL + 单飞、不加 HTTP 缓存协商」也是本实现的取舍依据(见本 README §5.1)
22
45
  - **它与宿主版本的兼容区间** → 见《多机交接与云中继》**§4.5「兼容性要求」**(另见本 README 第 6 节「兼容性」)
23
46
  - **本组件特有的坑与实测** → 见本 README「已知限制」一节;另见《只读监控面板的设计纪律》**§7 设计教训汇总**(三次"静默不报"的来历)
24
47
  - **文档仓地址** → Gitee(镜像)<https://gitee.com/kira905/ops-handoff-design>
@@ -45,6 +68,15 @@ GET /api/dsh-ecosystem-panel/state
45
68
  面板 meta 里还带一份**配置来源自证**(每个配置键到底是从 CLI / 环境变量 / 配置文件 / 自动探测 / 默认值
46
69
  哪一层读到的),换机器排障时先看它。
47
70
 
71
+ ## 同源说明
72
+
73
+ 本仓**部分文件与一套内部生产实现同源**(同一份工程长期演进下来的两处落点)。
74
+
75
+ - **同源程度**:按**行级同源度**口径统计(同名可比件里,仓内文件的非空行有多少能在参照件中找到,多重集扣减),本仓「≥50% 行同占比」= **100%**。
76
+ - **这意味着**:本仓是那套实现的**提炼导出** —— **既不是逐字副本,也不是完全重写的独立实现**。拿单个文件当"独立参考实现"读之前,请先跑下面的判据。
77
+ - **怎么复跑**:同源度扫描器见 `dsh-quality-toolkit` 的 `dsh-sim-scan.mjs`(`--repo <本仓> --prod-root <参照根>`);分档阈值与口径写在它的 `config/sim-scan.json` 里,不藏在代码里。
78
+ - **许可**:本仓许可见仓根 `LICENSE`(有子树例外时以子树 `LICENSE` 为准)。
79
+
48
80
  ## 2. 前置条件
49
81
 
50
82
  - 一个 DSH 实例(web profile),主包线见 §6 兼容性表
@@ -54,6 +86,10 @@ GET /api/dsh-ecosystem-panel/state
54
86
  - 可选的三个/四个外部脚本(解耦检查、档位 CLI、跨表校验器、适配层一致性校验器)——**没有也能跑**,
55
87
  对应类目会降级;本仓不附带这些脚本(它们属于你自己的体检体系)
56
88
 
89
+ > **手工装(旧版 / 离线用)** —— 下面三步是 0.4.5 之前的装法(`npm install --prefix` + 手写 profile `package.json` 两处);
90
+ > 现在有 `dsh.bundle`,一条 `dsh plugin --profile <profile> add @kira905/dsh-ecosystem-panel` 就够,见文首那段。
91
+ > **两条路别混用**:混用会在组合里留下**同一件的两份登记**(`dsh.profile.bundles` 里同一包名两行,或 `cordis.patch.yml` 里同一个 id 两行;后者**不一定会报错**,光看日志发现不了)⇒ 判据 = 两处各数一遍,每一处必须 == 1。
92
+
57
93
  ## 3. 安装三步
58
94
 
59
95
  ```bash
@@ -63,7 +99,7 @@ npm install @kira905/dsh-ecosystem-panel --prefix "<数据根>/profiles/web"
63
99
  # "<数据根>/profiles/web/node_modules/@kira905/dsh-ecosystem-panel"
64
100
 
65
101
  # ② 注册:profile 的 package.json 里
66
- # "dependencies": 加一条 "@kira905/dsh-ecosystem-panel": "0.4.4"
102
+ # "dependencies": 加一条 "@kira905/dsh-ecosystem-panel": "0.5.0"
67
103
  # "dsh.profile.bundles": 数组里加 "@kira905/dsh-ecosystem-panel"
68
104
 
69
105
  # ③ 重启 DSH(本插件没有热加载),然后打开 GUI:侧边栏齿轮上方应出现面板按钮
@@ -104,6 +140,13 @@ node "<数据根>/profiles/web/node_modules/@kira905/dsh-ecosystem-panel/scripts
104
140
  | `DSH_ECOSYSTEM_PANEL_LABELS_FILE` | 包名 → 一句话简介 JSON | 空(没登记的包只是没有简介) |
105
141
  | `DSH_ECOSYSTEM_PANEL_DISABLED_GROUPS` | 整组停用的类目(逗号分隔) | 全开 |
106
142
  | `DSH_ECOSYSTEM_PANEL_WEBSERVER_SERVICE` | 宿主 web server 服务名 | `webServer` |
143
+ | `DSH_ECOSYSTEM_PANEL_TTL_MS` | 「过期」阈值(数据块 `fetchedAt` 与它的差) | `300000`(300 s,见 §5.1) |
144
+ | `DSH_ECOSYSTEM_PANEL_SOURCE_TIMEOUT_MS` | 单源缺省超时 | `2000` ms |
145
+ | `DSH_ECOSYSTEM_PANEL_TOTAL_BUDGET_MS` | 一次首屏的总预算 | `10000` ms |
146
+ | `DSH_ECOSYSTEM_PANEL_SOURCE_TIMEOUTS` | 逐源覆盖单源超时,形如 `boot=1500,skills=500` | 空(用单源缺省) |
147
+ | `DSH_ECOSYSTEM_PANEL_WARMUP` | 周期预热开关(`1/true/on` 开) | `0`(**关**:后台常驻要花资源,不替使用者决定) |
148
+ | `DSH_ECOSYSTEM_PANEL_WARMUP_MS` | 预热周期(< 5000 会被压到 5000;0/负数回缺省) | `300000`(300 s,与 TTL 同源) |
149
+ | `DSH_ECOSYSTEM_PANEL_WARMUP_SOURCES` | 预热哪些源(逗号分隔;`*` = 全部) | `boot,bundles,inserts,patches,skills`(纯本地五源) |
107
150
 
108
151
  ### 4.2 配置文件与示例
109
152
 
@@ -135,10 +178,51 @@ DSH_ECOSYSTEM_PANEL_CONFIG=/path/to/my.config.json # 指向你抄过去改的
135
178
  ## 5. 界面
136
179
 
137
180
  - 六类各占一张标签卡(Tab),Tab 上的小圆点 = 该类最差状态(红 > 黄 > 绿)
138
- - 点「刷新」会重新取数;`GET /state?refresh=1` 强制重查 npm 最新版(否则 24h 缓存)
181
+ - 点「刷新」会重新取数;`GET /state?refresh=1` 强制重查 npm 最新版(否则 24h 缓存);
182
+ `GET /state?refresh=all` 整屏丢缓存重取(排障用,别日常按)
139
183
  - 颜色:🟢 正常 / 🟡 风险或不可判定 / 🔴 异常(比如 patch insert 的实体缺失)
140
184
  - 面板底部说明与标题可由配置覆盖(`text.title` / `text.note` / `text.tabs`,服务端经 `meta.ui` 下发)
141
185
 
186
+ ### 5.1 首屏为什么不转圈(SWR / 周期预热 / 分片取数)
187
+
188
+ 只读面板的首屏有三个常见坏毛病,本版各配一个处方,读数全部落在页脚那一行:
189
+
190
+ | 坏毛病 | 处方 | 你能看到什么 |
191
+ |---|---|---|
192
+ | ① 数据过期了就得干等刷新(转圈) | **SWR**:数据块自带 `fetchedAt`,过期判据 = `now - fetchedAt > 阈值`(缺省 **300 s**);过期时**先把旧值渲染出去**,同时后台刷新,刷回来再替换 | 面板顶部一条「🕗 这是旧值:skills 旧了 320 s(后台正在换新,刷回来自动替换)」;页面不转圈 |
193
+ | ② 每次冷启动都从零算(慢) | **周期预热**:可配周期 + 三个环境变量口;**缺省关闭**,开了就先填一遍再按周期续 | 页脚「预热 开 · 300 s · 已跑 N 次」;关掉时页脚写「预热 关」 |
194
+ | ③ 某一个源挂了/超时,整页跟着废 | **分片 + 单飞 + 超时预算 + 局部降级**:源清单是参数化的、同一源同一时刻只有一次在途取数、每源一个超时 + 整页一个总预算;坏源**只坏那一格** | 那一格变成「不可探测:超时 / 报错 / 未配置 / 总预算耗尽」,其余照出,整页仍然 200 |
195
+
196
+ **为什么阈值是 300 s**:复用体系里既有的五分钟节奏,**不新增定时作业** —— 出处是文档仓
197
+ 《只读监控面板的设计纪律》**§3.2**(见「设计依据」节)。想改就改 `ttlMs`,但别在代码里另写一个数。
198
+
199
+ **为什么预热缺省关**:预热是"后台常驻"的动作,会替你花资源;默认替你决定不合适。开了以后
200
+ 缺省只预热**纯本地的五源**(`boot / bundles / inserts / patches / skills`)——
201
+ `decoupling / upgrades / registry` 要跑外部脚本或查 npm,不进缺省预热(要就自己在
202
+ `DSH_ECOSYSTEM_PANEL_WARMUP_SOURCES` 里点名,或写 `*` 全预热)。
203
+
204
+ **冷启动不许返回空壳**:没有任何旧值时,这一次**阻塞取真值**,并单独记成 SLI 的 `coldMiss`
205
+ (不记的话"命中率"会把冷启动算成命中,读数就废了)。页脚的三桶永远是分开的:
206
+ `命中 12(70.6%)· 未命中 3 · 冷启 2`。
207
+
208
+ **"预热生效"怎么证明**:不是看它跑没跑,而是**关掉它必须看得见冷窗**。夹具
209
+ `scripts/test-warmup-negctl.mjs` 就是这条阴性对照,读数形如:
210
+
211
+ ```
212
+ 场景 首屏耗时 首屏冷启动 源数
213
+ 预热开 1 ms 0 10
214
+ 预热关 159 ms 10 10
215
+ ```
216
+
217
+ (开 = 全部命中缓存;关 = 首屏自己冷取 10 个源,其中那个 150 ms 的慢源躲不掉。)
218
+
219
+ **分片是参数化的**:新增数据源 = 在 `lib/state.js` 的源清单里加一条
220
+ (`{ id, label, group, deps, timeoutMs, run, describe }`),**装配主流程一行都不用改**;
221
+ 下游插件也可以在运行时 `registerSource()` 把自己的健康片挂进这块面板(夹具里就是这么验的)。
222
+
223
+ **页脚 SLI 一行**(`state.sli`):取数耗时(每源 + 总)· 命中率(命中 / 未命中 / 冷启动分开)·
224
+ 降级次数(每源)· 预热状态。不做第二套仪表盘。
225
+
142
226
  ## 6. 兼容性
143
227
 
144
228
  | 宿主主包线 | 状态 | 依据 |
@@ -165,15 +249,22 @@ DSH_ECOSYSTEM_PANEL_CONFIG=/path/to/my.config.json # 指向你抄过去改的
165
249
  ## 8. 仓结构
166
250
 
167
251
  ```
168
- lib/index.js 服务端:注册只读 route
169
- lib/state.js 六类聚合逻辑(每个数据源都走配置层 + 显式降级)
252
+ lib/index.js 服务端:注册只读 route + 起预热器(route 与预热共用同一条取数车道)
253
+ lib/state.js 六类聚合逻辑 + **数据源清单(参数化)** + 首屏装配(每个数据源都走配置层 + 显式降级)
254
+ lib/fetch-lane.js 取数车道:TTL 缓存 + SWR / 单飞 / 超时预算 / 局部降级 / SLI 计数
255
+ lib/warmup.js 周期预热(缺省关;三个环境变量口;缺省只预热纯本地五源)
170
256
  lib/upgrade.js ⑤ 升级体检(本地事实 + npm 缓存 → 外部只读 CLI → 渲染)
171
- lib/config.js 配置层(优先级解析 + 占位符展开 + 来源自证)
257
+ lib/config.js 配置层(优先级解析 + 占位符展开 + 来源自证 + 取数策略)
172
258
  lib/host-protocol.js 宿主协议常量(本插件只用 webServer 一个服务名)
173
- lib/client.js 浏览器端:侧边栏按钮 + 面板(零 import)
259
+ lib/client.js 浏览器端:侧边栏按钮 + 面板 + 旧值提示 + 页脚 SLI(零 import)
174
260
  cordis.patch.yml bundle patch(把插件挂到宿主)
175
261
  scripts/verify-source.mjs 发布前静态验证(语法 + 配置层行为 + 脱敏扫描)
176
262
  scripts/test-fixture.mjs 干净临时路径上的端到端夹具(含缺数据降级断言)
263
+ scripts/test-swr.mjs 夹具①:过期先给旧值 / 冷启动阻塞取真值并记 coldMiss
264
+ scripts/test-warmup-negctl.mjs 夹具②:预热阴性对照(关掉预热必须看得见冷窗)
265
+ scripts/test-singleflight.mjs 夹具③:并发打同一源 ⇒ 源侧只被调用一次
266
+ scripts/test-degrade.mjs 夹具④:坏源只坏那一格、其余照出、总耗时不被拖爆
267
+ scripts/sim-selfcheck.mjs 新文件行级同源度自查(< 50% 通过)
177
268
  scripts/verify-state.mjs 只读状态验证器(不依赖 GUI)
178
269
  examples/ 五份配置示例
179
270
  docs/RELEASING.md 发版规范与检查单
@@ -184,7 +275,14 @@ docs/RELEASING.md 发版规范与检查单
184
275
  ```bash
185
276
  node scripts/verify-source.mjs . # 语法 + 配置层行为 + 脱敏扫描(发布前必跑)
186
277
  node scripts/test-fixture.mjs # 端到端夹具:不需要任何真实安装,全在临时目录里造
187
- node scripts/verify-state.mjs # 对当前实例只读体检(打印六类 + 配置来源)
278
+ node scripts/test-swr.mjs # 首屏三件之①:SWR
279
+ node scripts/test-warmup-negctl.mjs # 首屏三件之②:预热阴性对照(关掉必须看得见冷窗)
280
+ node scripts/test-singleflight.mjs # 首屏三件之③:单飞
281
+ node scripts/test-degrade.mjs # 首屏三件之③:分片 + 超时预算 + 局部降级
282
+ node scripts/sim-selfcheck.mjs --since <commit> # 新文件行级同源度(< 50% 通过)
283
+ node scripts/verify-state.mjs # 对当前实例只读体检(打印六类 + 配置来源 + 三桶命中率)
284
+ # 或者一把梭:
285
+ npm run test:all
188
286
  ```
189
287
 
190
288
  `test-fixture.mjs` 会现场造一个最小数据根(假 profile / 假插件实体 / 假技能 / 四个外部工具 stub),
package/lib/client.js CHANGED
@@ -60,6 +60,10 @@ window.__ModuleLoader__.load({
60
60
  [${PANEL_ATTR}] .ep-badge{font-size:10.5px;border-radius:5px;padding:0 6px;margin-left:6px;vertical-align:1px;background:var(--dsw-alias-bg-module-platform,rgba(128,128,128,.18))}
61
61
  [${PANEL_ATTR}] .ep-prob{font-size:12px;color:var(--dsw-alias-state-warn-primary,#e5b35c);margin-top:2px;word-break:break-all}
62
62
  [${PANEL_ATTR}] .ep-note{font-size:12px;color:var(--dsw-alias-label-tertiary,#9aa0a8);margin-top:8px}
63
+ /* 首屏「旧值」提示(SWR):过期先给旧值时,这里明写"旧了多久、后台正在换新" */
64
+ [${PANEL_ATTR}] .ep-stale{font-size:12px;margin:0 0 8px;padding:5px 9px;border-radius:8px;background:rgba(229,179,92,.12);color:var(--dsw-alias-state-warn-primary,#e5b35c)}
65
+ /* 页脚 SLI 一行:取数耗时 / 命中率 / 降级次数 / 预热状态(只做一行,不另起仪表盘) */
66
+ [${PANEL_ATTR}] .ep-foot{font-size:11.5px;color:var(--dsw-alias-label-tertiary,#9aa0a8);border-top:1px solid var(--dsw-alias-border-l1,rgba(255,255,255,.07));margin-top:10px;padding-top:8px;word-break:break-all}
63
67
  [${PANEL_ATTR}] .ep-err{color:var(--dsw-alias-label-error,#e06c6c);font-size:13px;padding:10px 0}
64
68
  .dot-green{background:#3ecf6e}.dot-yellow{background:#e5b35c}.dot-red{background:#e5484d}.dot-unknown{background:#8a8a8a}`;
65
69
  function injectCss() {
@@ -169,7 +173,7 @@ window.__ModuleLoader__.load({
169
173
  //#region 面板
170
174
  const MARK = { green: "🟢", yellow: "🟡", red: "🔴" };
171
175
  /** 内置中性说明文案(服务端 meta.ui.note 可覆盖;见 lib/config.js 的 text 配置) */
172
- const DEFAULT_NOTE = "只读总览(静态判定):bundle 加载看 boot client 条目;服务端 patch insert 插件实体在即绿。补丁健康 = 特征串扫描(清单由配置提供)。升级体检 = 本地实体版本 vs npm latest(24h 缓存)+ 规则表文案;本面板**只诊断不升级**,升级永远由人决定。解耦健康 = 本地包 R1(禁运行时静态 import 宿主主包符号)/ R2(dependencies · client.inject 归零)扫描(脚本路径由配置提供)。🔴 = 静态 import,宿主主包删符号时会启动崩。";
176
+ const DEFAULT_NOTE = "只读总览(静态判定):bundle 加载看 boot client 条目;服务端 patch insert 插件实体在即绿。补丁健康 = 特征串扫描(清单由配置提供)。升级体检 = 本地实体版本 vs npm latest(24h 缓存)+ 规则表文案;本面板**只诊断不升级**,升级永远由人决定。解耦健康 = 本地包 R1(禁运行时静态 import 宿主主包符号)/ R2(dependencies · client.inject 归零)扫描(脚本路径由配置提供)。🔴 = 静态 import,宿主主包删符号时会启动崩。首屏纪律:过期先给旧值再后台换新(旧了多久见上方提示)、某源超时只坏那一格、页脚那行是本屏的取数账(耗时 / 命中 / 降级 / 预热)。";
173
177
  const KIND_LABEL = {
174
178
  bundle: "npm/GitHub 插件", bundles: "npm/GitHub 插件",
175
179
  insert: "patch insert", inserts: "patch insert",
@@ -241,6 +245,39 @@ window.__ModuleLoader__.load({
241
245
  container.appendChild(groupTitle(title));
242
246
  for (const it of items) container.appendChild(renderRow(it));
243
247
  }
248
+ /** 首屏「旧值」一行:哪些源是旧的说清楚,别让人以为这是刚取的(SWR 的唯一可见承诺) */
249
+ function staleText(st) {
250
+ const fr = st && st.freshness;
251
+ if (!fr || !fr.stale) return "";
252
+ const parts = Object.entries(fr.sources || {})
253
+ .filter((p) => p[1] && p[1].stale)
254
+ .map((p) => p[0] + " 旧了 " + Math.max(1, Math.round((p[1].ageMs || 0) / 1000)) + " s");
255
+ return "🕗 这是旧值:" + parts.join("、") + "(后台正在换新,刷回来自动替换)";
256
+ }
257
+ /** 页脚 SLI 一行:取数耗时(每源 + 总)· 命中 / 未命中 / 冷启动 · 降级次数 · 预热状态 */
258
+ function fmtSli(st) {
259
+ const sli = st && st.sli;
260
+ if (!sli) return "";
261
+ const req = sli.request || {};
262
+ const per = (req.sources || [])
263
+ .filter((s) => typeof s.elapsedMs === "number" && s.elapsedMs > 0)
264
+ .slice(0, 5)
265
+ .map((s) => s.id + " " + s.elapsedMs);
266
+ const costs = "取数 " + (req.totalMs ?? 0) + " ms" + (per.length ? "(" + per.join(" · ") + ")" : "");
267
+ const t = sli.totals || {};
268
+ const hit = t.hit ?? 0;
269
+ const miss = t.miss ?? 0;
270
+ const cold = t.coldMiss ?? 0;
271
+ const denom = hit + miss + cold;
272
+ const rate = denom > 0 ? Math.round((hit * 1000) / denom) / 10 : null;
273
+ const cache = "命中 " + hit + (rate === null ? "" : "(" + rate + "%)") + " · 未命中 " + miss + " · 冷启 " + cold;
274
+ const deg = "降级 " + (t.degraded ?? 0);
275
+ const w = sli.warmup || {};
276
+ const warm = w.enabled
277
+ ? "预热 开 · " + Math.round((w.intervalMs ?? 0) / 1000) + " s · 已跑 " + (w.runs ?? 0) + " 次"
278
+ : "预热 关";
279
+ return costs + " · " + cache + " · " + deg + " · " + warm;
280
+ }
244
281
  /** 面板主体。返回 { root, refresh, close } */
245
282
  function mountPanel(onDismiss) {
246
283
  const overlay = el("div");
@@ -278,9 +315,12 @@ window.__ModuleLoader__.load({
278
315
  }
279
316
  overlay.appendChild(card);
280
317
  // 状态子标题(异步更新)
318
+ const staleRow = el("div", "ep-stale");
319
+ staleRow.style.display = "none";
281
320
  const summaryRow = el("div", "ep-summary");
282
321
  const errRow = el("div", "ep-err");
283
322
  errRow.style.display = "none";
323
+ body.appendChild(staleRow);
284
324
  body.appendChild(summaryRow);
285
325
  body.appendChild(errRow);
286
326
  // 分组容器:每类一个面板页,由 switchTab 决定显示哪个
@@ -293,6 +333,10 @@ window.__ModuleLoader__.load({
293
333
  }
294
334
  const note = el("div", "ep-note", DEFAULT_NOTE);
295
335
  body.appendChild(note);
336
+ // 页脚 SLI:一行说完"这屏数据怎么来的、花了多久、命中多少"(不另做仪表盘)
337
+ const foot = el("div", "ep-foot");
338
+ foot.style.display = "none";
339
+ body.appendChild(foot);
296
340
  let lastState = null;
297
341
  const render = (st) => {
298
342
  const s = st?.summary;
@@ -328,10 +372,24 @@ window.__ModuleLoader__.load({
328
372
  if (n > 0) appendGroup(g, TAB_LABELS[key] + "(" + n + ")", list);
329
373
  }
330
374
  switchTab(currentTab);
375
+ // 旧值提示 + 页脚 SLI:数据新鲜度与取数成本都摊在一个面板里,不另开窗口
376
+ const stale = staleText(st);
377
+ if (stale) {
378
+ staleRow.textContent = stale;
379
+ staleRow.style.display = "";
380
+ } else {
381
+ staleRow.style.display = "none";
382
+ }
383
+ foot.textContent = fmtSli(st);
384
+ foot.style.display = foot.textContent ? "" : "none";
331
385
  };
332
- const refresh = async () => {
333
- refreshBtn.disabled = true;
334
- refreshBtn.textContent = "刷新中…";
386
+ let staleTimer = null;
387
+ // silent=true 是"后台刷回来的那次":不动按钮、不闪"刷新中…"(旧值已经在屏幕上)
388
+ const refresh = async (silent) => {
389
+ if (silent !== true) {
390
+ refreshBtn.disabled = true;
391
+ refreshBtn.textContent = "刷新中…";
392
+ }
335
393
  errRow.style.display = "none";
336
394
  try {
337
395
  const res = await fetch(API, { cache: "no-store" });
@@ -339,12 +397,23 @@ window.__ModuleLoader__.load({
339
397
  if (!res.ok) throw new Error(data?.error ?? ("HTTP " + res.status));
340
398
  lastState = data;
341
399
  applyUiText(data, { title: titleEl, note }); render(data);
400
+ // 这一屏是旧值 ⇒ 服务端已经在后台换新了,稍后静默再取一次把新值换上来
401
+ if (data?.freshness?.stale && staleTimer === null) {
402
+ staleTimer = setTimeout(() => {
403
+ staleTimer = null;
404
+ refresh(true);
405
+ }, 1200);
406
+ }
342
407
  } catch (e) {
343
- errRow.textContent = "加载失败:" + (e?.message ?? e);
344
- errRow.style.display = "";
408
+ if (silent !== true) {
409
+ errRow.textContent = "加载失败:" + (e?.message ?? e);
410
+ errRow.style.display = "";
411
+ }
345
412
  } finally {
346
- refreshBtn.disabled = false;
347
- refreshBtn.textContent = "刷新";
413
+ if (silent !== true) {
414
+ refreshBtn.disabled = false;
415
+ refreshBtn.textContent = "刷新";
416
+ }
348
417
  }
349
418
  };
350
419
  /** 统一关闭:移除 overlay + 解绑键盘 + 通知 apply 清面板状态(data-active)。
@@ -353,16 +422,18 @@ window.__ModuleLoader__.load({
353
422
  const onKey = (e) => { if (e.key === "Escape") fireClose(); };
354
423
  const fireClose = () => {
355
424
  document.removeEventListener("keydown", onKey);
425
+ if (staleTimer !== null) { clearTimeout(staleTimer); staleTimer = null; }
356
426
  overlay.remove();
357
427
  onDismiss?.();
358
428
  };
359
- refreshBtn.addEventListener("click", refresh);
429
+ refreshBtn.addEventListener("click", () => { refresh(); });
360
430
  closeBtn.addEventListener("click", fireClose);
361
431
  overlay.addEventListener("click", (e) => { if (e.target === overlay) fireClose(); });
362
432
  document.addEventListener("keydown", onKey);
363
433
  refresh();
364
434
  const cleanup = () => {
365
435
  document.removeEventListener("keydown", onKey);
436
+ if (staleTimer !== null) { clearTimeout(staleTimer); staleTimer = null; }
366
437
  overlay.remove();
367
438
  };
368
439
  return { root: overlay, refresh, close: fireClose, cleanup };