@kira905/dsh-ecosystem-panel 0.4.6 → 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 +10 -0
- package/README.en.md +74 -7
- package/README.md +79 -7
- package/lib/client.js +80 -9
- package/lib/config.js +143 -1
- package/lib/fetch-lane.js +399 -0
- package/lib/index.js +27 -5
- package/lib/state.js +1182 -775
- package/lib/warmup.js +131 -0
- package/package.json +7 -1
- package/scripts/sim-selfcheck.mjs +191 -0
- package/scripts/test-degrade.mjs +130 -0
- package/scripts/test-fixture.mjs +26 -1
- package/scripts/test-singleflight.mjs +88 -0
- package/scripts/test-swr.mjs +134 -0
- package/scripts/test-warmup-negctl.mjs +161 -0
|
@@ -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
|
-
|
|
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
|
|
41
|
-
|
|
42
|
-
const
|
|
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');
|