@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/lib/config.js CHANGED
@@ -29,10 +29,20 @@
29
29
  // 四个数据清单的 JSON 文件路径
30
30
  // DSH_ECOSYSTEM_PANEL_WEBSERVER_SERVICE 宿主 web server 服务名(默认 webServer,见 host-protocol.js)
31
31
  // DSH_ECOSYSTEM_PANEL_DISABLED_GROUPS 逗号分隔:把某几类体检整组标为"未启用"
32
+ //
33
+ // 首屏取数三件(SWR / 预热 / 超时预算)的旋钮(缺省值见 DEFAULTS,全部可被 cli > env > 配置文件覆盖):
34
+ // DSH_ECOSYSTEM_PANEL_TTL_MS "过期"阈值(缺省 300000 = 300 s;依据见文档仓 06 篇 §3.2)
35
+ // DSH_ECOSYSTEM_PANEL_SOURCE_TIMEOUT_MS 单源超时(缺省 2000 ms)
36
+ // DSH_ECOSYSTEM_PANEL_TOTAL_BUDGET_MS 整页总预算(缺省 6000 ms)
37
+ // DSH_ECOSYSTEM_PANEL_SOURCE_TIMEOUTS 逐源覆盖单源超时,形如 "boot=1500,skills=500"
38
+ // DSH_ECOSYSTEM_PANEL_WARMUP 周期预热开关(缺省 0 = 关;开 = 1/true/on)
39
+ // DSH_ECOSYSTEM_PANEL_WARMUP_MS 预热周期(缺省 300000 ms,下限 5000)
40
+ // DSH_ECOSYSTEM_PANEL_WARMUP_SOURCES 预热哪些源,逗号分隔;写 * = 全部
32
41
 
33
42
  import fs from 'node:fs';
34
43
  import os from 'node:os';
35
44
  import path from 'node:path';
45
+ import { WARMUP_DEFAULTS } from './warmup.js';
36
46
 
37
47
  export const PLUGIN_ID = 'dsh-ecosystem-panel';
38
48
  export const ENV_PREFIX = 'DSH_ECOSYSTEM_PANEL_';
@@ -58,7 +68,16 @@ export const DEFAULTS = {
58
68
  /** 界面文案覆盖:默认**空**(前端有内置中性文案,这里只做覆盖) */
59
69
  text: {},
60
70
  /** 整组停用的类目(例如不想要补丁体检就写 ["patches"]) */
61
- disabledGroups: []
71
+ disabledGroups: [],
72
+ /** 首屏取数:TTL("过期"判据)+ 单源超时 + 整页总预算 + 逐源超时覆盖 */
73
+ ttlMs: 300000,
74
+ sourceTimeoutMs: 2000,
75
+ // 总预算 = 整页的天花板:最慢的那格(升级体检出网查 npm)实测 1.5~3 s,留到 10 s 仍算"有上限",
76
+ // 但不会再让一个卡死的源把首屏无限期按住。要更紧就配 TOTAL_BUDGET_MS。
77
+ totalBudgetMs: 10000,
78
+ sourceTimeouts: {},
79
+ /** 周期预热:缺省**关闭**(后台常驻要花资源,不替使用者决定) */
80
+ warmup: { ...WARMUP_DEFAULTS }
62
81
  };
63
82
 
64
83
  const SCRIPT_KEYS = ['decoupling', 'triageCli', 'registryAudit', 'hostAdapterSync'];
@@ -106,6 +125,120 @@ function dirExists(p) {
106
125
  }
107
126
  }
108
127
 
128
+ /** 这些"窗口"类旋钮的硬上限:一天(写成算式而不是一串数字 —— 八位十进制字面量会被脱敏规则当成 id 形态) */
129
+ const MAX_WINDOW_MS = 24 * 3600 * 1000;
130
+
131
+ /** 值是否"真的给了"(0 / false 也算给了 —— 夹具与使用者会显式关掉某个旋钮) */
132
+ function present(v) {
133
+ if (v === undefined || v === null) return false;
134
+ if (typeof v === 'string') return v.trim() !== '';
135
+ return true;
136
+ }
137
+ function toNum(v, min, max, fallback) {
138
+ const n = Number(typeof v === 'string' ? v.trim() : v);
139
+ if (!Number.isFinite(n)) return fallback;
140
+ return Math.min(max, Math.max(min, n));
141
+ }
142
+ function toBool(v, fallback) {
143
+ if (typeof v === 'boolean') return v;
144
+ const s = String(v ?? '').trim().toLowerCase();
145
+ if (['1', 'true', 'on', 'yes', 'y', '开'].includes(s)) return true;
146
+ if (['0', 'false', 'off', 'no', 'n', '关'].includes(s)) return false;
147
+ return fallback;
148
+ }
149
+ /** "a=1,b=200" 或 {a:1,b:200} → { a:1, b:200 }(非数字 / 非正数一律丢弃) */
150
+ function toTimeoutMap(v) {
151
+ const out = {};
152
+ const put = (k, val) => {
153
+ const key = typeof k === 'string' ? k.trim() : '';
154
+ const n = Number(val);
155
+ if (key && Number.isFinite(n) && n > 0) out[key] = Math.min(600000, Math.round(n));
156
+ };
157
+ if (v && typeof v === 'object' && !Array.isArray(v)) {
158
+ for (const [k, val] of Object.entries(v)) put(k, val);
159
+ return out;
160
+ }
161
+ for (const part of String(v ?? '').split(/[,;]/)) {
162
+ const at = part.indexOf('=');
163
+ if (at > 0) put(part.slice(0, at), part.slice(at + 1));
164
+ }
165
+ return out;
166
+ }
167
+
168
+ /**
169
+ * 首屏取数策略(TTL / 超时预算 / 预热):**每个键独立**按 cli > env > 配置文件 > 缺省 解析,
170
+ * 并把"从哪读到的"一并带出(面板 meta 会原样展示 ⇒ 陌生环境排障不用猜)。
171
+ * 支持嵌套写法:clí/config 里的 `warmup: { enabled, intervalMs, sources }`。
172
+ */
173
+ export function resolveFetchPolicy({ cli = {}, env = process.env, file = {} } = {}) {
174
+ const envVal = (key) => {
175
+ const v = env?.[ENV_PREFIX + key];
176
+ return present(v) ? String(v).trim() : undefined;
177
+ };
178
+ const cliWarm = cli && typeof cli.warmup === 'object' && cli.warmup !== null ? cli.warmup : {};
179
+ const fileWarm = file && typeof file.warmup === 'object' && file.warmup !== null ? file.warmup : {};
180
+ const sources = {};
181
+
182
+ const pickRaw = (cliKey, envKey, fileKey, fallback, fromCli = cli[cliKey], fromFile = file[fileKey ?? cliKey]) => {
183
+ let r;
184
+ if (present(fromCli)) r = { value: fromCli, from: 'cli' };
185
+ else if (envKey && envVal(envKey) !== undefined) r = { value: envVal(envKey), from: `env:${ENV_PREFIX}${envKey}` };
186
+ else if (present(fromFile)) r = { value: fromFile, from: 'config-file' };
187
+ else r = { value: fallback, from: 'default' };
188
+ return r;
189
+ };
190
+
191
+ const ttl = pickRaw('ttlMs', 'TTL_MS', 'ttlMs', DEFAULTS.ttlMs);
192
+ sources.ttlMs = { value: toNum(ttl.value, 0, MAX_WINDOW_MS, DEFAULTS.ttlMs), from: ttl.from };
193
+ const sto = pickRaw('sourceTimeoutMs', 'SOURCE_TIMEOUT_MS', 'sourceTimeoutMs', DEFAULTS.sourceTimeoutMs);
194
+ sources.sourceTimeoutMs = { value: toNum(sto.value, 1, 600000, DEFAULTS.sourceTimeoutMs), from: sto.from };
195
+ const budget = pickRaw('totalBudgetMs', 'TOTAL_BUDGET_MS', 'totalBudgetMs', DEFAULTS.totalBudgetMs);
196
+ sources.totalBudgetMs = { value: toNum(budget.value, 1, 600000, DEFAULTS.totalBudgetMs), from: budget.from };
197
+ const perSource = pickRaw('sourceTimeouts', 'SOURCE_TIMEOUTS', 'sourceTimeouts', DEFAULTS.sourceTimeouts);
198
+ sources.sourceTimeouts = { value: toTimeoutMap(perSource.value), from: perSource.from };
199
+ sources.sourceTimeouts.value = Object.keys(sources.sourceTimeouts.value).length
200
+ ? `${Object.keys(sources.sourceTimeouts.value).length} 源`
201
+ : '(无覆盖)';
202
+
203
+ const enabled = pickRaw('warmupEnabled', 'WARMUP', 'warmupEnabled',
204
+ DEFAULTS.warmup.enabled,
205
+ present(cliWarm.enabled) ? cliWarm.enabled : undefined,
206
+ present(fileWarm.enabled) ? fileWarm.enabled : undefined);
207
+ const interval = pickRaw('warmupIntervalMs', 'WARMUP_MS', 'warmupIntervalMs',
208
+ DEFAULTS.warmup.intervalMs,
209
+ present(cliWarm.intervalMs) ? cliWarm.intervalMs : undefined,
210
+ present(fileWarm.intervalMs) ? fileWarm.intervalMs : undefined);
211
+ const warmSources = pickRaw('warmupSources', 'WARMUP_SOURCES', 'warmupSources',
212
+ DEFAULTS.warmup.sources,
213
+ present(cliWarm.sources) ? cliWarm.sources : undefined,
214
+ present(fileWarm.sources) ? fileWarm.sources : undefined);
215
+
216
+ const toList = (v) => (Array.isArray(v)
217
+ ? v.map((x) => String(x).trim()).filter(Boolean)
218
+ : String(v ?? '').split(/[,;\s]+/).map((x) => x.trim()).filter(Boolean));
219
+
220
+ const policy = {
221
+ ttlMs: sources.ttlMs.value,
222
+ sourceTimeoutMs: sources.sourceTimeoutMs.value,
223
+ totalBudgetMs: sources.totalBudgetMs.value,
224
+ sourceTimeouts: toTimeoutMap(perSource.value),
225
+ warmup: {
226
+ enabled: toBool(enabled.value, DEFAULTS.warmup.enabled),
227
+ // 0 / 负数 = 没给(回缺省 300 s);太密则压到 5 s 下限 —— 两种都不许变成空转
228
+ intervalMs: (() => {
229
+ const raw = Number(interval.value);
230
+ if (!Number.isFinite(raw) || raw <= 0) return DEFAULTS.warmup.intervalMs;
231
+ return Math.min(MAX_WINDOW_MS, Math.max(5000, Math.round(raw)));
232
+ })(),
233
+ sources: toList(warmSources.value)
234
+ }
235
+ };
236
+ sources.warmupEnabled = { value: policy.warmup.enabled ? 'on' : 'off', from: enabled.from };
237
+ sources.warmupIntervalMs = { value: policy.warmup.intervalMs, from: interval.from };
238
+ sources.warmupSources = { value: policy.warmup.sources.join(',') || '(缺省)', from: warmSources.from };
239
+ return { policy, sources };
240
+ }
241
+
109
242
  /** 把配置里的 ${HOME} / ${PROFILE_DIR} / ${PROFILE_NM} / ${SKILLS_DIR} / ${MAIN_NM} 占位符展开成实际路径 */
110
243
  export function expandVars(value, cfg) {
111
244
  if (typeof value !== 'string' || !value.includes('${')) return value;
@@ -240,6 +373,15 @@ export function resolveConfig(cli = {}, env = process.env) {
240
373
  sources
241
374
  };
242
375
 
376
+ // ---- 首屏取数策略(TTL / 单源超时 / 总预算 / 预热)------------------------
377
+ const fetchPolicy = resolveFetchPolicy({ cli, env, file: fileCfg });
378
+ cfg.ttlMs = fetchPolicy.policy.ttlMs;
379
+ cfg.sourceTimeoutMs = fetchPolicy.policy.sourceTimeoutMs;
380
+ cfg.totalBudgetMs = fetchPolicy.policy.totalBudgetMs;
381
+ cfg.sourceTimeouts = fetchPolicy.policy.sourceTimeouts;
382
+ cfg.warmup = fetchPolicy.policy.warmup;
383
+ Object.assign(cfg.sources, fetchPolicy.sources);
384
+
243
385
  // ---- 四个外部脚本(cli > env > 配置文件 > toolsDir 派生 > null)------------
244
386
  for (const key of SCRIPT_KEYS) {
245
387
  const envKey = { decoupling: 'DECOUPLING_SCRIPT', triageCli: 'TRIAGE_CLI', registryAudit: 'REGISTRY_AUDIT_LIB', hostAdapterSync: 'HOST_ADAPTER_SYNC' }[key];
@@ -0,0 +1,399 @@
1
+ // dsh-ecosystem-panel / lib/fetch-lane.js
2
+ //
3
+ // 取数车道:只读面板首屏的四件工程件,一处实现、按源复用。
4
+ //
5
+ // ① TTL 缓存 + SWR —— 数据块自带 fetchedAt;过期先给旧值(带年龄),后台换新。
6
+ // 冷启动(连旧值都没有)**不许返回空壳**:阻塞取真值,并把这一次记成 coldMiss。
7
+ // ② 单飞 —— 同一源同一时刻只有一次在途取数;并发请求共享那一次。
8
+ // ③ 超时预算 —— 每源一个超时,整页一个总预算;慢源不拖死整页。
9
+ // ④ 局部降级 —— 某源超时 / 报错 / 未配置 ⇒ 只有那一格标「不可探测 + 原因」,其余照出。
10
+ //
11
+ // 计量(SLI)也在这里数:命中 / 未命中 / 冷启动 / 取数耗时 / 降级次数。**每轮读数都是现算的**,
12
+ // 不落盘、不跨进程复用。
13
+ //
14
+ // 这一层只做"取数这件事",不掺任何业务判定 —— 源的清单与每个源怎么跑,由调用方给
15
+ // (本仓见 lib/state.js 的 SOURCES;下游也可以 registerSource() 追加自己的源)。
16
+
17
+ /** 缺省 TTL = 300 s(复用体系里既有的五分钟节奏;依据见文档仓《06-只读监控面板的设计纪律》§3.2) */
18
+ export const DEFAULT_TTL_MS = 300000;
19
+ /** 缺省单源超时 */
20
+ export const DEFAULT_SOURCE_TIMEOUT_MS = 2000;
21
+ /** 缺省整页总预算(单源超时之和会远大于它,这里防"十个慢源叠起来") */
22
+ export const DEFAULT_TOTAL_BUDGET_MS = 6000;
23
+ /** 失败后的冷却:冷却期内不再反复打同一个坏源(避免一个挂掉的源被每屏请求反复捅) */
24
+ export const DEFAULT_RETRY_DELAY_MS = 5000;
25
+
26
+ /** 降级原因枚举(页面上原样显示,所以只放三种 + 预算一种) */
27
+ export const REASON = Object.freeze({
28
+ TIMEOUT: '超时',
29
+ ERROR: '报错',
30
+ NOT_CONFIGURED: '未配置',
31
+ BUDGET: '总预算耗尽'
32
+ });
33
+
34
+ function num(v, fallback) {
35
+ const n = Number(v);
36
+ return Number.isFinite(n) ? n : fallback;
37
+ }
38
+
39
+ function timeoutError(ms) {
40
+ const e = new Error(`取数超时(>${ms} ms)`);
41
+ e.code = 'LANE_TIMEOUT';
42
+ return e;
43
+ }
44
+
45
+ /**
46
+ * 建一条车道。
47
+ * @param {object} options
48
+ * @param {Array<object>} options.sources 源定义:{ id, label, group, deps?, timeoutMs?, requires?(ctx), run(ctx) }
49
+ * @param {number} [options.ttlMs]
50
+ * @param {number} [options.defaultTimeoutMs] 每源超时缺省值(源定义里的 timeoutMs 优先)
51
+ * @param {number} [options.totalBudgetMs] 一次 readAll 的总预算
52
+ * @param {number} [options.retryDelayMs] 失败冷却窗口
53
+ * @param {() => number} [options.now] 时钟(夹具注入假时钟测 TTL 用)
54
+ * @param {() => number} [options.elapsedClock] 真实耗时用的钟(缺省 Date.now;两者故意分开)
55
+ * @param {object} [options.context] 车道级上下文(paths / cfg / opts):预热走 lane.prime() 时也要能取数
56
+ */
57
+ export function createLane(options = {}) {
58
+ const clock = typeof options.now === 'function' ? options.now : () => Date.now();
59
+ // 两个钟故意分开:clock 管"数据块什么时候取的"(夹具会注入假时钟测 TTL),
60
+ // real 管"这次取数花了多久"(假时钟不会随真实等待走动,混用会把耗时读成 0)。
61
+ const real = typeof options.elapsedClock === 'function' ? options.elapsedClock : () => Date.now();
62
+ const ttlMs = num(options.ttlMs, DEFAULT_TTL_MS);
63
+ const defaultTimeoutMs = num(options.defaultTimeoutMs, DEFAULT_SOURCE_TIMEOUT_MS);
64
+ const totalBudgetMs = num(options.totalBudgetMs, DEFAULT_TOTAL_BUDGET_MS);
65
+ const retryDelayMs = num(options.retryDelayMs, DEFAULT_RETRY_DELAY_MS);
66
+
67
+ /** id -> 源定义 */
68
+ const defs = new Map();
69
+ /** id -> { value, fetchedAt, elapsedMs }:已取回的数据块(自带 fetchedAt 就是 SWR 的全部状态) */
70
+ const cache = new Map();
71
+ /** id -> { promise, startedAt }:在途表(单飞的唯一凭据) */
72
+ const inflight = new Map();
73
+ /** id -> { at, reason, detail }:最近一次失败(冷却窗口用) */
74
+ const failure = new Map();
75
+ /** id -> 计数 */
76
+ const meters = new Map();
77
+ const totals = { requests: 0, requestMs: 0, lastRequestMs: 0 };
78
+
79
+ function addSource(def) {
80
+ if (!def || typeof def.id !== 'string' || typeof def.run !== 'function') {
81
+ throw new Error('fetch-lane: 源定义至少要 { id, run }');
82
+ }
83
+ defs.set(def.id, def);
84
+ return def;
85
+ }
86
+ for (const def of options.sources ?? []) addSource(def);
87
+
88
+ function meter(id) {
89
+ let m = meters.get(id);
90
+ if (!m) {
91
+ m = { hit: 0, miss: 0, coldMiss: 0, fetches: 0, failures: 0, degraded: 0, fetchMs: 0, lastMs: 0 };
92
+ meters.set(id, m);
93
+ }
94
+ return m;
95
+ }
96
+
97
+ const expired = (entry, t) => (t - entry.fetchedAt) > ttlMs;
98
+
99
+ /** 一次读取的结果形状(stale/fresh/cold/degraded 四态,页面只认前三个有值) */
100
+ function freshResult(id, entry, t, status) {
101
+ return {
102
+ id,
103
+ status,
104
+ value: entry.value,
105
+ fetchedAt: entry.fetchedAt,
106
+ ageMs: Math.max(0, t - entry.fetchedAt),
107
+ elapsedMs: 0,
108
+ reason: null,
109
+ detail: null
110
+ };
111
+ }
112
+
113
+ function degradedResult(id, reason, detail, elapsedMs, extra = {}) {
114
+ return {
115
+ id,
116
+ status: 'degraded',
117
+ value: undefined,
118
+ fetchedAt: null,
119
+ ageMs: null,
120
+ elapsedMs,
121
+ reason,
122
+ detail: detail ?? null,
123
+ ...extra
124
+ };
125
+ }
126
+
127
+ function classify(error) {
128
+ if (error?.code === 'LANE_TIMEOUT') return { reason: REASON.TIMEOUT, detail: String(error?.message ?? error) };
129
+ return { reason: REASON.ERROR, detail: String(error?.message ?? error) };
130
+ }
131
+
132
+ /** 迟到 / 及时的成功都缓存:成功后清掉失败标记 */
133
+ function store(id, value, startedAt) {
134
+ const at = clock();
135
+ const prev = cache.get(id);
136
+ if (!prev || prev.fetchedAt <= at) {
137
+ cache.set(id, { value, fetchedAt: at, elapsedMs: at - startedAt });
138
+ }
139
+ failure.delete(id);
140
+ return at;
141
+ }
142
+
143
+ function withTimeout(promise, ms) {
144
+ if (!(ms > 0) || !Number.isFinite(ms)) return promise;
145
+ return new Promise((resolve, reject) => {
146
+ const timer = setTimeout(() => reject(timeoutError(ms)), ms);
147
+ if (typeof timer.unref === 'function') timer.unref();
148
+ promise.then(
149
+ (v) => { clearTimeout(timer); resolve(v); },
150
+ (e) => { clearTimeout(timer); reject(e); }
151
+ );
152
+ });
153
+ }
154
+
155
+ /**
156
+ * 真正跑一次源(含单飞 / 超时 / 预算 / 降级记账)。
157
+ * @returns {Promise<{ok:boolean, value?:any, reason?:string, detail?:string, elapsedMs:number}>}
158
+ */
159
+ function refresh(def, ctx = {}) {
160
+ const running = inflight.get(def.id);
161
+ if (running) return running.promise; // ← 单飞:后来者共享在途那一次
162
+
163
+ const startedAt = clock();
164
+ const startedReal = real();
165
+ const budgetLeft = typeof ctx.deadline === 'number' ? ctx.deadline - clock() : Infinity;
166
+ const ownTimeout = num(ctx?.opts?.sourceTimeouts?.[def.id], num(def.timeoutMs, defaultTimeoutMs));
167
+ const limit = Math.min(ownTimeout, budgetLeft);
168
+ // 掐断是"单源超时"还是"整页预算用完了"?—— 页脚上这两个原因必须分得开:
169
+ // 前者说明这个源慢,后者说明整页的预算该调了(或者某个源在拖后腿)。
170
+ const budgetBound = budgetLeft < ownTimeout;
171
+
172
+ const m = meter(def.id);
173
+ const promise = (async () => {
174
+ if (!(limit > 0)) {
175
+ m.degraded += 1;
176
+ return { ok: false, reason: REASON.BUDGET, detail: `整页总预算 ${totalBudgetMs} ms 已用完`, elapsedMs: real() - startedReal, noFetch: true };
177
+ }
178
+ const gateReason = typeof def.requires === 'function' ? def.requires(ctx) : null;
179
+ if (gateReason) {
180
+ m.degraded += 1;
181
+ return { ok: false, reason: REASON.NOT_CONFIGURED, detail: String(gateReason), elapsedMs: real() - startedReal, noFetch: true };
182
+ }
183
+ m.fetches += 1;
184
+ const startedFetch = clock();
185
+ const sourcePromise = Promise.resolve().then(() => def.run(ctx));
186
+ // 迟到的成功也收下:超时只是"这一屏不再等",不是"这个源的数据作废"
187
+ sourcePromise.then(
188
+ (value) => { store(def.id, value, startedFetch); },
189
+ () => { /* 失败由下面的 catch 记账;这里只吞掉未处理的 rejection */ }
190
+ );
191
+ try {
192
+ const value = await withTimeout(sourcePromise, limit);
193
+ const elapsed = real() - startedReal;
194
+ store(def.id, value, startedFetch);
195
+ m.lastMs = elapsed;
196
+ m.fetchMs += elapsed;
197
+ return { ok: true, value, elapsedMs: elapsed };
198
+ } catch (error) {
199
+ const elapsed = real() - startedReal;
200
+ const classified = classify(error);
201
+ const reason = budgetBound && classified.reason === REASON.TIMEOUT ? REASON.BUDGET : classified.reason;
202
+ const detail = reason === REASON.BUDGET
203
+ ? `整页总预算 ${totalBudgetMs} ms 已用完(本源的超时上限是 ${ownTimeout} ms)`
204
+ : classified.detail;
205
+ m.failures += 1;
206
+ m.degraded += 1;
207
+ m.lastMs = elapsed;
208
+ failure.set(def.id, { at: clock(), reason, detail });
209
+ return { ok: false, reason, detail, elapsedMs: elapsed };
210
+ }
211
+ })();
212
+
213
+ inflight.set(def.id, { promise, startedAt });
214
+ promise.finally(() => {
215
+ const cur = inflight.get(def.id);
216
+ if (cur && cur.promise === promise) inflight.delete(def.id);
217
+ }).catch(() => {});
218
+ return promise;
219
+ }
220
+
221
+ /**
222
+ * 读一个源。
223
+ * 新鲜 → hit;过期 → 先给旧值(stale)+ 后台刷新(miss);没有旧值 → 阻塞取真值(coldMiss)。
224
+ */
225
+ async function read(target, ctx = {}) {
226
+ const def = typeof target === 'string' ? defs.get(target) : target;
227
+ if (!def) throw new Error(`fetch-lane: 未知数据源 ${String(target)}`);
228
+ const t = clock();
229
+ const entry = cache.get(def.id);
230
+ const m = meter(def.id);
231
+
232
+ if (entry !== undefined && !expired(entry, t)) {
233
+ m.hit += 1;
234
+ return freshResult(def.id, entry, t, 'fresh');
235
+ }
236
+ if (entry !== undefined) {
237
+ m.miss += 1;
238
+ refresh(def, ctx); // 不 await:这一屏用旧值先出
239
+ return freshResult(def.id, entry, t, 'stale');
240
+ }
241
+ const cool = failure.get(def.id);
242
+ if (cool && (t - cool.at) < retryDelayMs) {
243
+ // 刚失败过:冷却期内不再捅它,直接如实报「不可探测」+ 上次的原因
244
+ m.degraded += 1;
245
+ return degradedResult(def.id, cool.reason, cool.detail, 0, { coolingDown: true });
246
+ }
247
+ m.coldMiss += 1;
248
+ const started = real();
249
+ const r = await refresh(def, ctx);
250
+ if (r.ok) {
251
+ const at = clock();
252
+ const justStored = cache.get(def.id);
253
+ const entryNow = justStored ?? { value: r.value, fetchedAt: at };
254
+ if (!justStored) cache.set(def.id, { value: r.value, fetchedAt: at, elapsedMs: r.elapsedMs });
255
+ return { ...freshResult(def.id, entryNow, at, 'cold'), elapsedMs: real() - started };
256
+ }
257
+ return degradedResult(def.id, r.reason, r.detail, real() - started, { reasonCode: r.reason });
258
+ }
259
+
260
+ /**
261
+ * 一次取数 pass:按依赖顺序把给定源读出来(同一 pass 内同一源只读一次),
262
+ * 整页共享一个总预算 deadline。
263
+ * @param {string[]} ids
264
+ * @param {{ opts?: object, force?: string[]|boolean }} [ctx]
265
+ * @returns {Promise<{ results: Map<string, object>, byId: object, startedAt:number, elapsedMs:number, deadline:number }>}
266
+ */
267
+ async function readAll(ids, ctx = {}) {
268
+ const startedAt = clock();
269
+ const startedReal = real();
270
+ const deadline = startedAt + totalBudgetMs;
271
+ const base = options.context ?? {}; // 车道级上下文:路径/配置这类"每条车道都一样"的东西
272
+ const pass = {
273
+ opts: ctx.opts ?? base.opts ?? {},
274
+ deadline,
275
+ values: new Map(),
276
+ results: new Map(),
277
+ pending: new Map()
278
+ };
279
+ const force = ctx.force === true ? new Set(ids) : new Set(Array.isArray(ctx.force) ? ctx.force : []);
280
+
281
+ const visit = (id) => {
282
+ if (pass.results.has(id)) return Promise.resolve(pass.results.get(id));
283
+ if (pass.pending.has(id)) return pass.pending.get(id);
284
+ const def = defs.get(id);
285
+ if (!def) return Promise.reject(new Error(`fetch-lane: 未知数据源 ${id}`));
286
+ const p = (async () => {
287
+ for (const dep of def.deps ?? []) {
288
+ if (!defs.has(dep)) continue;
289
+ await visit(dep);
290
+ }
291
+ const one = {
292
+ ...base,
293
+ ...ctx,
294
+ deadline,
295
+ get: (depId) => pass.values.get(depId),
296
+ paths: ctx.paths ?? base.paths ?? null,
297
+ cfg: ctx.cfg ?? base.cfg ?? null
298
+ };
299
+ if (force.has(id)) cache.delete(id); // 显式刷新:丢掉旧值,按冷路径重取
300
+ const r = await read(def, one);
301
+ pass.results.set(id, r);
302
+ pass.values.set(id, r.value);
303
+ return r;
304
+ })();
305
+ pass.pending.set(id, p);
306
+ return p;
307
+ };
308
+
309
+ await Promise.all(ids.map(visit));
310
+ const elapsedMs = real() - startedReal;
311
+ totals.requests += 1;
312
+ totals.requestMs += elapsedMs;
313
+ totals.lastRequestMs = elapsedMs;
314
+ const byId = {};
315
+ for (const [id, r] of pass.results) byId[id] = r;
316
+ return { results: pass.results, byId, startedAt, elapsedMs, deadline };
317
+ }
318
+
319
+ /** 预热用:把给定源读一遍(新鲜的就只算命中,不重复取) */
320
+ async function prime(ids, ctx = {}) {
321
+ const out = await readAll(ids, ctx);
322
+ const summary = { at: new Date().toISOString(), elapsedMs: out.elapsedMs, sources: {} };
323
+ for (const [id, r] of out.results) {
324
+ summary.sources[id] = { status: r.status, elapsedMs: r.elapsedMs, reason: r.reason ?? null };
325
+ }
326
+ return summary;
327
+ }
328
+
329
+ /** 等在途取数全部落定(夹具与"后台刷新完再看一眼"用) */
330
+ async function settle() {
331
+ while (inflight.size > 0) {
332
+ const list = [...inflight.values()].map((v) => v.promise.catch(() => {}));
333
+ await Promise.all(list);
334
+ }
335
+ }
336
+
337
+ /** SLI 读数(页脚那三行数的源) */
338
+ function sli() {
339
+ const perSource = {};
340
+ const sums = { hit: 0, miss: 0, coldMiss: 0, fetches: 0, failures: 0, degraded: 0 };
341
+ for (const [id, m] of meters) {
342
+ const avgMs = m.fetches > 0 ? Math.round(m.fetchMs / m.fetches) : 0;
343
+ perSource[id] = {
344
+ hit: m.hit,
345
+ miss: m.miss,
346
+ coldMiss: m.coldMiss,
347
+ fetches: m.fetches,
348
+ failures: m.failures,
349
+ degraded: m.degraded,
350
+ lastMs: m.lastMs,
351
+ avgMs,
352
+ cached: cache.has(id),
353
+ ageMs: cache.has(id) ? Math.max(0, clock() - cache.get(id).fetchedAt) : null,
354
+ stale: cache.has(id) ? expired(cache.get(id), clock()) : null,
355
+ inflight: inflight.has(id)
356
+ };
357
+ for (const k of Object.keys(sums)) sums[k] += m[k];
358
+ }
359
+ return {
360
+ ttlMs,
361
+ defaultTimeoutMs,
362
+ totalBudgetMs,
363
+ retryDelayMs,
364
+ sources: perSource,
365
+ totals: {
366
+ ...sums,
367
+ requests: totals.requests,
368
+ lastRequestMs: totals.lastRequestMs,
369
+ avgRequestMs: totals.requests > 0 ? Math.round(totals.requestMs / totals.requests) : 0,
370
+ inflight: inflight.size
371
+ }
372
+ };
373
+ }
374
+
375
+ /** 夹具用:清空缓存/在途/计数(不删源定义) */
376
+ function reset() {
377
+ cache.clear();
378
+ inflight.clear();
379
+ failure.clear();
380
+ meters.clear();
381
+ totals.requests = 0;
382
+ totals.requestMs = 0;
383
+ totals.lastRequestMs = 0;
384
+ }
385
+
386
+ return {
387
+ ids: () => [...defs.keys()],
388
+ defs,
389
+ addSource,
390
+ read,
391
+ readAll,
392
+ prime,
393
+ settle,
394
+ sli,
395
+ reset,
396
+ ttlMs,
397
+ totalBudgetMs
398
+ };
399
+ }
package/lib/index.js CHANGED
@@ -1,10 +1,16 @@
1
1
  // dsh-ecosystem-panel / lib/index.js(服务端)
2
2
  // 注册 GET /api/dsh-ecosystem-panel/state —— 只读聚合,无副作用,不加请求栅栏。
3
3
  // 本 API 只暴露一个只读 GET(没有任何写操作入口),因此不需要控制面栅栏。
4
- import { API_PREFIX, buildState } from './state.js';
4
+ //
5
+ // 首屏三件(SWR / 周期预热 / 分片取数)的接线都在这里:
6
+ // · 车道(fetch-lane)按当前配置建一条,route 与预热器**共用同一条** ——
7
+ // 否则预热填的是另一份缓存,首屏照样吃冷启动("预热开着却没用"最常见的写法错误);
8
+ // · 预热器由配置层决定开不开(缺省关),生命周期跟着插件 effect 走,卸载时 timer 一并停掉。
9
+ import { API_PREFIX, buildState, getLane, setWarmupReporter } from './state.js';
10
+ import { startWarmup } from './warmup.js';
5
11
  // 宿主服务名常量(自带,不依赖任何外部适配层;服务名可经环境变量覆盖,见 host-protocol.js)
6
12
  import { SVC } from './host-protocol.js';
7
- import { setConfigOverride } from './config.js';
13
+ import { getConfig, setConfigOverride } from './config.js';
8
14
 
9
15
  export const inject = [SVC.webServer];
10
16
  export const name = 'dsh-ecosystem-panel';
@@ -28,6 +34,15 @@ function apply(ctx, pluginConfig) {
28
34
  if (pluginConfig && typeof pluginConfig === 'object') setConfigOverride(pluginConfig);
29
35
  ctx.effect(() => {
30
36
  const disposers = [];
37
+ const cfg = getConfig();
38
+ const lane = getLane(cfg);
39
+ const warm = startWarmup({
40
+ lane,
41
+ cfg,
42
+ log: (m) => console.log('[ecosystem-panel] ' + m)
43
+ });
44
+ // 预热状态进 SLI(页脚与 /state 都能看到"开没开、跑过几次、上次多久")
45
+ setWarmupReporter(() => warm.status());
31
46
  try {
32
47
  disposers.push(ctx.webServer.register({
33
48
  kind: 'exact',
@@ -37,9 +52,12 @@ function apply(ctx, pluginConfig) {
37
52
  return writeJson(res, 405, { ok: false, error: 'method-not-allowed' }, { 'cache-control': 'no-store' });
38
53
  }
39
54
  try {
40
- // ?refresh=1 强制重查 npm latest(默认走 24h 文件缓存,面板打开不卡)
41
- const refresh = /(?:\?|&)refresh=1(?:&|$)/.test(req.url ?? '');
42
- const state = await buildState({ refreshUpgrades: refresh });
55
+ // ?refresh=1 只强制重查升级体检(默认走 TTL 缓存,面板打开不卡);
56
+ // ?refresh=all 则整屏丢缓存重取(排障用,别日常按)
57
+ const url = req.url ?? '';
58
+ const refresh = /(?:\?|&)refresh=1(?:&|$)/.test(url);
59
+ const forceAll = /(?:\?|&)refresh=all(?:&|$)/.test(url);
60
+ const state = await buildState({ lane, refreshUpgrades: refresh, ...(forceAll ? { force: true } : {}) });
43
61
  writeJson(res, 200, state, { 'cache-control': 'no-store' });
44
62
  } catch (error) {
45
63
  writeJson(res, 500, { ok: false, error: String(error?.message ?? error) }, { 'cache-control': 'no-store' });
@@ -48,9 +66,13 @@ function apply(ctx, pluginConfig) {
48
66
  }));
49
67
  } catch (error) {
50
68
  for (const dispose of disposers) dispose();
69
+ warm.stop();
70
+ setWarmupReporter(null);
51
71
  throw error;
52
72
  }
53
73
  return () => {
74
+ warm.stop();
75
+ setWarmupReporter(null);
54
76
  for (const dispose of disposers) dispose();
55
77
  };
56
78
  }, 'ecosystem-panel: state route');