@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 +10 -0
- package/README.en.md +74 -7
- package/README.md +105 -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
package/lib/state.js
CHANGED
|
@@ -1,775 +1,1182 @@
|
|
|
1
|
-
// dsh-ecosystem-panel / lib/state.js
|
|
2
|
-
// 只读生态聚合逻辑(六类:bundles / patch inserts / 补丁健康 / 技能 / 升级体检 / 解耦健康)
|
|
3
|
-
// 供 lib/index.js(服务端 route)与 scripts/verify-state.mjs(standalone 验证)复用。
|
|
4
|
-
//
|
|
5
|
-
// ★ 通用化:路径与清单全部来自 lib/config.js(配置层)。本文件内**不写死任何绝对路径、
|
|
6
|
-
// 不内嵌任何个人环境的清单**。每个可选数据源缺失时走**显式降级**(status='yellow'/'unknown'
|
|
7
|
-
// + 原因),绝不显示 0、也不假装全绿。
|
|
8
|
-
import { createRequire } from 'node:module';
|
|
9
|
-
import fs from 'node:fs';
|
|
10
|
-
import path from 'node:path';
|
|
11
|
-
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
12
|
-
import { buildUpgradeState, getBreakingSymbols } from './upgrade.js';
|
|
13
|
-
import { getConfig, describeResolution } from './config.js';
|
|
14
|
-
import {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
export const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
return
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
*
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
if (
|
|
214
|
-
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
return
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
return
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
special
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
status
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
if (
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
1
|
+
// dsh-ecosystem-panel / lib/state.js
|
|
2
|
+
// 只读生态聚合逻辑(六类:bundles / patch inserts / 补丁健康 / 技能 / 升级体检 / 解耦健康)
|
|
3
|
+
// 供 lib/index.js(服务端 route)与 scripts/verify-state.mjs(standalone 验证)复用。
|
|
4
|
+
//
|
|
5
|
+
// ★ 通用化:路径与清单全部来自 lib/config.js(配置层)。本文件内**不写死任何绝对路径、
|
|
6
|
+
// 不内嵌任何个人环境的清单**。每个可选数据源缺失时走**显式降级**(status='yellow'/'unknown'
|
|
7
|
+
// + 原因),绝不显示 0、也不假装全绿。
|
|
8
|
+
import { createRequire } from 'node:module';
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
12
|
+
import { buildUpgradeState, getBreakingSymbols } from './upgrade.js';
|
|
13
|
+
import { getConfig, describeResolution } from './config.js';
|
|
14
|
+
import { createLane } from './fetch-lane.js';
|
|
15
|
+
import { API_PREFIX as PROTOCOL_API_PREFIX } from './host-protocol.js';
|
|
16
|
+
|
|
17
|
+
export const API_PREFIX = PROTOCOL_API_PREFIX;
|
|
18
|
+
export const PANEL_ID = 'dsh-ecosystem-panel';
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// 路径推导(全部来自配置层;口径见 lib/config.js 头部)
|
|
22
|
+
//
|
|
23
|
+
// 历史坑(保留说明,避免回退):DSH 数据根必须在**调用时**解析,不能在模块加载期固化成
|
|
24
|
+
// `<用户目录>/.dsh` —— 否则隔离实例(数据根指向别处)加载本插件时,读的仍是默认 home 的
|
|
25
|
+
// profiles / skills,缓存还写到默认 home。因此下面的 paths() 每次现读配置。
|
|
26
|
+
//
|
|
27
|
+
// 本文件另在加载期导出一份 HOME_DIR / PROFILE_DIR / … 常量(默认配置下的值),
|
|
28
|
+
// 仅供"打印现状"与旧调用点使用;**所有取数逻辑一律走 paths()**。
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
export function paths(cli) {
|
|
31
|
+
return pathsFromConfig(getConfig(cli));
|
|
32
|
+
}
|
|
33
|
+
/** 由已解析的配置拼出路径表(车道要按同一份配置取数,所以这里单独暴露一半) */
|
|
34
|
+
export function pathsFromConfig(cfg) {
|
|
35
|
+
return {
|
|
36
|
+
cfg,
|
|
37
|
+
HOME_DIR: cfg.home,
|
|
38
|
+
PROFILE_DIR: cfg.profileDir,
|
|
39
|
+
PROFILE_NM: cfg.profileNm,
|
|
40
|
+
SHARED_NM: cfg.sharedNm,
|
|
41
|
+
SKILLS_DIR: cfg.skillsDir,
|
|
42
|
+
MAIN_NM: cfg.mainNm,
|
|
43
|
+
MAIN_PKG_DIR: cfg.mainPkgDir,
|
|
44
|
+
TOOLS_DIR: cfg.toolsDir,
|
|
45
|
+
DSH_BOOT_URL: cfg.webUrl,
|
|
46
|
+
CACHE_FILE: cfg.cacheFile
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
const _bootPaths = paths();
|
|
50
|
+
export const HOME_DIR = _bootPaths.HOME_DIR;
|
|
51
|
+
export const PROFILE_DIR = _bootPaths.PROFILE_DIR;
|
|
52
|
+
export const PROFILE_NM = _bootPaths.PROFILE_NM;
|
|
53
|
+
// 共享模块 fallback 层(<HOME>/profiles/node_modules):宿主在 profile 之外还有一层 fallback,
|
|
54
|
+
// 官方子包(及主包内嵌依赖)常由这一层提供。Node 的解析链是从 profile 目录逐级向上找
|
|
55
|
+
// node_modules,因此 insert 声明的 @deepseek-ai/* 官方子包命中的可能正是这一层 ——
|
|
56
|
+
// 只查 PROFILE_NM 会把生效中的 insert 误报成「实体缺失」。
|
|
57
|
+
export const SHARED_NM = _bootPaths.SHARED_NM;
|
|
58
|
+
export const SKILLS_DIR = _bootPaths.SKILLS_DIR;
|
|
59
|
+
|
|
60
|
+
// YAML 解析器(宿主 profile 里通常随主包自带 js-yaml)。
|
|
61
|
+
// ⚠️ 解析器不可用时**显式降级**:cordis.patch.yml 与技能 frontmatter 判为「不可解析」,
|
|
62
|
+
// 而不是静默当成空清单(那会把"读不到"伪装成"全绿")。
|
|
63
|
+
let yaml = null;
|
|
64
|
+
let yamlLoadError = null;
|
|
65
|
+
try {
|
|
66
|
+
const require = createRequire(path.join(_bootPaths.PROFILE_DIR, 'package.json'));
|
|
67
|
+
yaml = require('js-yaml');
|
|
68
|
+
} catch (e) {
|
|
69
|
+
yamlLoadError = String(e?.message ?? e);
|
|
70
|
+
}
|
|
71
|
+
export const YAML_AVAILABLE = yaml !== null;
|
|
72
|
+
export const YAML_ERROR = yamlLoadError;
|
|
73
|
+
/** 统一的"解析器不可用"文案 */
|
|
74
|
+
const YAML_HINT = 'YAML 解析器(js-yaml)不可用 ⇒ 该判据不可得出'
|
|
75
|
+
+ (yamlLoadError ? `:${yamlLoadError}` : '')
|
|
76
|
+
+ '。修法:确认数据根下 profiles/<profile> 的依赖链里能解析到 js-yaml(通常随宿主主包提供),'
|
|
77
|
+
+ '或用 DSH_ECOSYSTEM_PANEL_HOME 指向正确的数据根。';
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// 小工具
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
function readJson(p) {
|
|
83
|
+
try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; }
|
|
84
|
+
}
|
|
85
|
+
function readText(p) {
|
|
86
|
+
try { return fs.readFileSync(p, 'utf8'); } catch { return null; }
|
|
87
|
+
}
|
|
88
|
+
function exists(p) { try { return fs.statSync(p).isFile() || fs.statSync(p).isDirectory(); } catch { return false; } }
|
|
89
|
+
/** scoped 包实体路径:node_modules/<name> 或 node_modules/@scope/<name> */
|
|
90
|
+
function entityDir(name, P) {
|
|
91
|
+
const seg = name.startsWith('@') ? name.split('/') : [name];
|
|
92
|
+
return path.join(P.PROFILE_NM, ...seg);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* insert 实体的解析结果(Node 解析链口径):profile 自己的 node_modules 优先,
|
|
96
|
+
* 其次共享 fallback 层;两层都读不到才算缺失。
|
|
97
|
+
* @param {string} name 包名(含 scope 形式)
|
|
98
|
+
* @param {object} P paths()
|
|
99
|
+
* @returns {{ dir: string, source: 'profile'|'profiles-shared' } | null}
|
|
100
|
+
*/
|
|
101
|
+
function resolveEntity(name, P) {
|
|
102
|
+
const seg = name.startsWith('@') ? name.split('/') : [name];
|
|
103
|
+
for (const [nm, source] of [[P.PROFILE_NM, 'profile'], [P.SHARED_NM, 'profiles-shared']]) {
|
|
104
|
+
const dir = path.join(nm, ...seg);
|
|
105
|
+
if (readJson(path.join(dir, 'package.json'))) return { dir, source };
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* 特征扫描目标文件解析:
|
|
111
|
+
* target='global' → 主包 node_modules/<pkg>/<rest>(主包布局未探测到 ⇒ 返回 null,由调用方降级)
|
|
112
|
+
* 否则 → profile node_modules/<pkg>/<rest>
|
|
113
|
+
*/
|
|
114
|
+
function resolveCheckFile(fileSpec, P) {
|
|
115
|
+
if (fileSpec.target === 'global') {
|
|
116
|
+
if (!P.MAIN_NM) return null;
|
|
117
|
+
return path.join(P.MAIN_NM, fileSpec.pkg, fileSpec.rel ?? '');
|
|
118
|
+
}
|
|
119
|
+
return path.join(P.PROFILE_NM, fileSpec.pkg, fileSpec.rel ?? '');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// 补丁特征表(**外置**:见 examples/patch-manifest.example.json)
|
|
124
|
+
// 条目形状(与旧版一致):
|
|
125
|
+
// { id, title, script, retired?, checks: [{ pkg, rel, target?: 'profile'|'global', contains?[], notContains?[] }],
|
|
126
|
+
// special?: { kind: 'dir-has-file', pkg, dir, suffix, label } | { kind: 'no-sidecar-files', dir, suffix: ['.db-wal'], label } }
|
|
127
|
+
// checks:每个 check 全部满足才算健康(contains 缺一 = 特征丢失;notContains 命中 = 残留应无特征)。
|
|
128
|
+
// special:整体性判定(如"某目录下必须有某后缀文件" / "某目录不许有 -wal 残留")。
|
|
129
|
+
// 字符串里可写 ${HOME} / ${PROFILE_NM} / ${MAIN_NM} / ${MAIN_PKG_DIR} 占位符(配置层展开成实际路径)。
|
|
130
|
+
// 默认**空清单**:本文件不内嵌任何环境的补丁表;未配置时该类显式降级为"不可判定",不显示"0 个补丁"。
|
|
131
|
+
/** 取当前配置里的补丁清单 */
|
|
132
|
+
export function getPatchManifest(cfg = getConfig()) {
|
|
133
|
+
return Array.isArray(cfg.patchManifest) ? cfg.patchManifest : [];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
// boot entries(抓宿主 HTML 里的 __DSH_BOOT__ 清单,15s 缓存;URL 来自配置层)
|
|
138
|
+
// 抓不到不影响其它类目:bootLoaded 一律为 null(= 未知),绝不把"未知"渲染成"未加载"。
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
let bootCache = { at: 0, ids: null, error: null, url: null };
|
|
141
|
+
async function fetchBootEntries(P, force = false) {
|
|
142
|
+
const now = Date.now();
|
|
143
|
+
if (!force && bootCache.ids !== null && bootCache.url === P.DSH_BOOT_URL && now - bootCache.at < 15000) return bootCache;
|
|
144
|
+
try {
|
|
145
|
+
const res = await fetch(P.DSH_BOOT_URL + '/', { cache: 'no-store' });
|
|
146
|
+
// 非 2xx **必须按"抓不到"处理**(ids=null = 未知),不能当成"清单里 0 条":
|
|
147
|
+
// 需要 token 的实例(`/?token=…`)对未认领的请求回 401,正文是"authentication required"。
|
|
148
|
+
// 早先把它当空清单 ⇒ 每一行都报「boot 无 client 条目(疑似未加载/崩溃)」,纯属误报。
|
|
149
|
+
if (!res.ok) {
|
|
150
|
+
bootCache = {
|
|
151
|
+
at: now,
|
|
152
|
+
ids: null,
|
|
153
|
+
error: `宿主首页返回 HTTP ${res.status}(需要 token?本面板服务端不带 cookie 抓取;该实例上 boot 清单不可读)`,
|
|
154
|
+
url: P.DSH_BOOT_URL
|
|
155
|
+
};
|
|
156
|
+
return bootCache;
|
|
157
|
+
}
|
|
158
|
+
const html = await res.text();
|
|
159
|
+
const m = /__DSH_BOOT__"\s*\]\s*=\s*(\{[\s\S]*?\})\s*;?\s*<\/script>/.exec(html);
|
|
160
|
+
const ids = [];
|
|
161
|
+
if (m) {
|
|
162
|
+
for (const im of m[1].matchAll(/"id":"([^"]+)"/g)) ids.push(im[1]);
|
|
163
|
+
}
|
|
164
|
+
bootCache = { at: now, ids, error: ids.length === 0 ? 'HTML 里未解析到 __DSH_BOOT__ entries' : null, url: P.DSH_BOOT_URL };
|
|
165
|
+
} catch (e) {
|
|
166
|
+
bootCache = { at: now, ids: null, error: String(e?.message ?? e), url: P.DSH_BOOT_URL };
|
|
167
|
+
}
|
|
168
|
+
return bootCache;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
// ① bundles(profile package.json dsh.profile.bundles)
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
/** core 包白名单(默认 = 随宿主主包发行的内置包;可经配置追加) */
|
|
175
|
+
function coreBundlesOf(cfg) {
|
|
176
|
+
return new Set(Array.isArray(cfg?.coreBundles) ? cfg.coreBundles : []);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** 包名 → 一句话简介(面板可读性)。默认**空**:由使用者用 LABELS 配置提供(见 examples/)。
|
|
180
|
+
* 没登记的包照常参与判定,只是没有简介 —— 简介缺失不影响任何状态色。 */
|
|
181
|
+
function labelsOf(cfg) {
|
|
182
|
+
const l = cfg?.labels;
|
|
183
|
+
return l && typeof l === 'object' && !Array.isArray(l) ? l : {};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function collectBundles(profilePkg, bootIds, P) {
|
|
187
|
+
const deps = profilePkg?.dependencies ?? {};
|
|
188
|
+
const bundles = profilePkg?.dsh?.profile?.bundles ?? [];
|
|
189
|
+
const coreBundles = coreBundlesOf(P.cfg);
|
|
190
|
+
const labels = labelsOf(P.cfg);
|
|
191
|
+
return bundles.map((name) => {
|
|
192
|
+
const isCore = coreBundles.has(name);
|
|
193
|
+
const declared = isCore ? null : (deps[name] ?? null);
|
|
194
|
+
const ent = entityDir(name, P);
|
|
195
|
+
const entPkg = readJson(path.join(ent, 'package.json'));
|
|
196
|
+
const hasClientCapability = !!(entPkg?.dsh?.client) || !!(entPkg?.exports?.['./client']);
|
|
197
|
+
const bootLoaded = bootIds === null ? null : bootIds.includes(name);
|
|
198
|
+
const problems = [];
|
|
199
|
+
let status = 'green';
|
|
200
|
+
|
|
201
|
+
if (!isCore && !entPkg) {
|
|
202
|
+
// 实体目录缺失(可能走 link 或主包路径 —— 有 boot 条目即实际加载,仅提示)
|
|
203
|
+
if (bootLoaded === false) { status = 'red'; problems.push('声明在 bundles 且无实体目录、boot 无 client 条目'); }
|
|
204
|
+
else if (bootLoaded === true) { status = 'yellow'; problems.push('无 profile 实体目录(link/主包提供),但 boot 已加载'); }
|
|
205
|
+
else { status = 'yellow'; problems.push('无 profile 实体目录,加载状态未知'); }
|
|
206
|
+
} else if (isCore) {
|
|
207
|
+
status = 'green'; // core 内置:client 由子包提供,boot 有 50+ 官方条目即证在跑
|
|
208
|
+
} else {
|
|
209
|
+
// 实体在:查 client 声明 vs boot 加载
|
|
210
|
+
if (hasClientCapability && bootLoaded === false) {
|
|
211
|
+
status = 'red';
|
|
212
|
+
problems.push('实体声明 client 能力但 boot 无加载条目(疑似未加载/崩溃)');
|
|
213
|
+
} else if (bootLoaded === true) {
|
|
214
|
+
status = 'green';
|
|
215
|
+
} else if (hasClientCapability && bootLoaded === null) {
|
|
216
|
+
// boot 清单拿不到(宿主未响应 / 基址配错)⇒ 客户端加载状态**未知**——
|
|
217
|
+
// 这时绝不能渲染成绿("读不到"与"已加载"是两回事)。
|
|
218
|
+
status = 'yellow';
|
|
219
|
+
problems.push('boot 清单不可用 ⇒ 客户端加载状态未知(这不是"已加载")');
|
|
220
|
+
}
|
|
221
|
+
// 版本漂移:deps 声明(去 link: 前缀 / ^ 范围)≠ 实体 version
|
|
222
|
+
if (declared !== null && entPkg?.version) {
|
|
223
|
+
const cleanDeclared = declared.replace(/^link:/, '');
|
|
224
|
+
if (/^\d/.test(cleanDeclared) && cleanDeclared !== entPkg.version) {
|
|
225
|
+
if (status === 'green') status = 'yellow';
|
|
226
|
+
problems.push(`声明 ${cleanDeclared} ≠ 实体 ${entPkg.version}(版本漂移)`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (status === 'green' && problems.length === 0 && !hasClientCapability && bootLoaded !== false) {
|
|
230
|
+
// 服务端插件(无 client):实体在即绿
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
kind: 'bundle', name, source: 'npm/github', declared, isCore,
|
|
235
|
+
description: labels[name] ?? null,
|
|
236
|
+
entityVersion: entPkg?.version ?? null,
|
|
237
|
+
hasClientCapability, bootLoaded,
|
|
238
|
+
status, problems
|
|
239
|
+
};
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
// ② cordis.patch.yml inserts(insert 声明的插件:实体是否存在)
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
function collectInserts(P) {
|
|
247
|
+
const patchPath = path.join(P.PROFILE_DIR, 'cordis.patch.yml');
|
|
248
|
+
const raw = readText(patchPath);
|
|
249
|
+
if (raw === null) return { inserts: [], parseError: `cordis.patch.yml 读取失败(路径:${patchPath})` };
|
|
250
|
+
if (!yaml) return { inserts: [], parseError: YAML_HINT };
|
|
251
|
+
let docs;
|
|
252
|
+
try { docs = yaml.load(raw); } catch (e) { return { inserts: [], parseError: `cordis.patch.yml YAML 解析失败: ${e.message}` }; }
|
|
253
|
+
const list = Array.isArray(docs) ? docs : [];
|
|
254
|
+
const labels = labelsOf(P.cfg);
|
|
255
|
+
const inserts = [];
|
|
256
|
+
for (const doc of list) {
|
|
257
|
+
if (!doc || typeof doc !== 'object') continue;
|
|
258
|
+
for (const row of Array.isArray(doc.insert) ? doc.insert : []) {
|
|
259
|
+
if (!row || typeof row !== 'object') continue;
|
|
260
|
+
const id = String(row.id ?? '');
|
|
261
|
+
const name = String(row.name ?? id);
|
|
262
|
+
const ent = resolveEntity(name, P);
|
|
263
|
+
const entPkg = ent ? readJson(path.join(ent.dir, 'package.json')) : null;
|
|
264
|
+
const problems = [];
|
|
265
|
+
let status = 'green';
|
|
266
|
+
if (!entPkg) { status = 'red'; problems.push('insert 声明但实体目录缺失'); }
|
|
267
|
+
if (doc.disabled === true) { status = 'yellow'; problems.push('被 disabled'); }
|
|
268
|
+
inserts.push({
|
|
269
|
+
kind: 'insert', id, name, source: 'patch insert',
|
|
270
|
+
description: labels[name] ?? null,
|
|
271
|
+
declaredVersion: entPkg?.version ?? null,
|
|
272
|
+
entityExists: !!entPkg,
|
|
273
|
+
entitySource: ent?.source ?? null,
|
|
274
|
+
disabled: doc.disabled === true,
|
|
275
|
+
status, problems
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
// 也收集 disabled 的 id patch(如官方 first-prompt provider 被禁)
|
|
280
|
+
const disabledIds = list.filter((d) => d && typeof d === 'object' && d.disabled === true && d.id).map((d) => String(d.id));
|
|
281
|
+
return { inserts, disabledIds, parseError: null };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
// ③ 补丁健康(特征串扫描)
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
function checkSpecial(special, P) {
|
|
288
|
+
if (special.kind === 'dir-has-file') {
|
|
289
|
+
const dir = path.join(entityDir(special.pkg, P), special.dir);
|
|
290
|
+
try {
|
|
291
|
+
const files = fs.readdirSync(dir);
|
|
292
|
+
const hit = files.find((f) => f.toLowerCase().endsWith(special.suffix));
|
|
293
|
+
return { ok: !!hit, detail: hit ? `找到 ${hit}` : `${special.label} 缺失` };
|
|
294
|
+
} catch { return { ok: false, detail: `目录不存在: ${dir}` }; }
|
|
295
|
+
}
|
|
296
|
+
if (special.kind === 'no-sidecar-files') {
|
|
297
|
+
try {
|
|
298
|
+
const files = fs.readdirSync(special.dir);
|
|
299
|
+
const bad = files.filter((f) => special.suffix.some((s) => f.endsWith(s)));
|
|
300
|
+
return { ok: bad.length === 0, detail: bad.length === 0 ? '无 -wal/-shm 残留' : `发现: ${bad.join(', ')}` };
|
|
301
|
+
} catch { return { ok: false, detail: `目录不可读: ${special.dir}` }; }
|
|
302
|
+
}
|
|
303
|
+
return { ok: false, detail: 'unknown special' };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function collectPatches(P) {
|
|
307
|
+
const manifest = getPatchManifest(P.cfg);
|
|
308
|
+
if (manifest.length === 0) {
|
|
309
|
+
// **显式降级**:清单为空 ≠ 没有补丁 ⇒ 该组不可判定,绝不显示"0 个补丁全绿"。
|
|
310
|
+
return [{
|
|
311
|
+
kind: 'patch', id: null, title: '补丁清单未配置', script: null, healthy: null, status: 'yellow',
|
|
312
|
+
problems: ['未配置补丁清单 ⇒ 本类不可判定(这不代表"没有补丁")。配置法:设 DSH_ECOSYSTEM_PANEL_PATCH_MANIFEST_FILE'
|
|
313
|
+
+ '=<JSON 路径>(条目形状见 examples/patch-manifest.example.json);若本环境确实不做补丁体检,'
|
|
314
|
+
+ '用 disabledGroups=["patches"] 显式停用本类'],
|
|
315
|
+
checks: [], special: null
|
|
316
|
+
}];
|
|
317
|
+
}
|
|
318
|
+
// 带 retired 的条目(目标包已退役/能力已移植)不参与体检,避免长期假红淹没真信号。
|
|
319
|
+
return manifest.filter((e) => !e.retired).map((entry) => {
|
|
320
|
+
const checks = [];
|
|
321
|
+
let fileMissing = false;
|
|
322
|
+
for (const c of entry.checks ?? []) {
|
|
323
|
+
const p = resolveCheckFile(c, P);
|
|
324
|
+
if (p === null) {
|
|
325
|
+
// 主包布局未探测到(global 类目标)⇒ 显式缺失,不伪造为"特征丢失"
|
|
326
|
+
checks.push({
|
|
327
|
+
file: `(主包 node_modules 未探测到:${c.pkg})`, fileExists: false,
|
|
328
|
+
contains: c.contains ?? [], missing: c.contains ?? [], notContains: c.notContains ?? [], forbiddenHit: []
|
|
329
|
+
});
|
|
330
|
+
fileMissing = true;
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
const text = readText(p);
|
|
334
|
+
const fileExists = text !== null;
|
|
335
|
+
if (!fileExists) fileMissing = true;
|
|
336
|
+
const missing = (c.contains ?? []).filter((s) => text === null || !text.includes(s));
|
|
337
|
+
const forbiddenHit = (c.notContains ?? []).filter((s) => text !== null && text.includes(s));
|
|
338
|
+
checks.push({
|
|
339
|
+
file: p,
|
|
340
|
+
fileExists,
|
|
341
|
+
contains: c.contains ?? [], missing,
|
|
342
|
+
notContains: c.notContains ?? [], forbiddenHit
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
let special = null;
|
|
346
|
+
if (entry.special) {
|
|
347
|
+
const r = checkSpecial(entry.special, P);
|
|
348
|
+
special = { ...r, label: entry.special.label };
|
|
349
|
+
if (!r.ok && !fileMissing) fileMissing = false;
|
|
350
|
+
}
|
|
351
|
+
const ok = checks.every((c) => c.fileExists && c.missing.length === 0 && c.forbiddenHit.length === 0)
|
|
352
|
+
&& (entry.special ? (special?.ok ?? false) : true);
|
|
353
|
+
let status = 'green';
|
|
354
|
+
const problems = [];
|
|
355
|
+
if (!ok) {
|
|
356
|
+
if (fileMissing) { status = 'red'; problems.push('补丁目标文件缺失(插件整体可能已卸载)'); }
|
|
357
|
+
else { status = 'yellow'; problems.push('补丁特征丢失(插件更新/重装冲掉了定制,需重打补丁)'); }
|
|
358
|
+
for (const c of checks) {
|
|
359
|
+
for (const s of c.missing) problems.push(`缺特征: ${c.file.split(path.sep).slice(-2).join('/')} 无 "${s}"`);
|
|
360
|
+
for (const s of c.forbiddenHit) problems.push(`残留应无特征: "${s}"`);
|
|
361
|
+
}
|
|
362
|
+
if (special && !special.ok) problems.push(special.detail);
|
|
363
|
+
}
|
|
364
|
+
return {
|
|
365
|
+
kind: 'patch', id: entry.id, title: entry.title, script: entry.script,
|
|
366
|
+
healthy: ok, status, problems,
|
|
367
|
+
checks: checks.map((c) => ({ file: c.file, fileExists: c.fileExists, missing: c.missing, forbiddenHit: c.forbiddenHit })),
|
|
368
|
+
special
|
|
369
|
+
};
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ---------------------------------------------------------------------------
|
|
374
|
+
// ④ 技能(<数据根>/skills 下各技能目录的 SKILL.md frontmatter)
|
|
375
|
+
// ---------------------------------------------------------------------------
|
|
376
|
+
function parseFrontmatter(text) {
|
|
377
|
+
// 首个 --- 行到第二个 --- 行之间的内容
|
|
378
|
+
const lines = text.split(/\r?\n/);
|
|
379
|
+
let start = -1, end = -1;
|
|
380
|
+
for (let i = 0; i < lines.length; i++) {
|
|
381
|
+
if (lines[i].trim() === '---') {
|
|
382
|
+
if (start === -1) start = i;
|
|
383
|
+
else { end = i; break; }
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
if (start === -1 || end === -1) return { fm: null, bodyStart: 0, err: '没有 frontmatter(缺少 --- 围栏)' };
|
|
387
|
+
const fmText = lines.slice(start + 1, end).join('\n');
|
|
388
|
+
if (!yaml) return { fm: null, bodyStart: end + 1, err: YAML_HINT };
|
|
389
|
+
try {
|
|
390
|
+
const obj = yaml.load(fmText);
|
|
391
|
+
return { fm: (obj && typeof obj === 'object') ? obj : null, bodyStart: end + 1, err: null };
|
|
392
|
+
} catch (e) {
|
|
393
|
+
return { fm: null, bodyStart: end + 1, err: `frontmatter YAML 解析失败: ${e.message}` };
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function collectSkills(P) {
|
|
398
|
+
const out = [];
|
|
399
|
+
let userDirOk = exists(P.SKILLS_DIR);
|
|
400
|
+
if (userDirOk) {
|
|
401
|
+
for (const d of fs.readdirSync(P.SKILLS_DIR, { withFileTypes: true })) {
|
|
402
|
+
if (!d.isDirectory()) continue;
|
|
403
|
+
const name = d.name;
|
|
404
|
+
const skillFile = path.join(P.SKILLS_DIR, name, 'SKILL.md');
|
|
405
|
+
const text = readText(skillFile);
|
|
406
|
+
const problems = [];
|
|
407
|
+
let status = 'green';
|
|
408
|
+
let fmName = null, fmDescription = null;
|
|
409
|
+
if (text === null) {
|
|
410
|
+
status = 'red';
|
|
411
|
+
problems.push('目录无 SKILL.md');
|
|
412
|
+
} else {
|
|
413
|
+
const { fm, err } = parseFrontmatter(text);
|
|
414
|
+
if (err) { status = 'yellow'; problems.push(err); }
|
|
415
|
+
else {
|
|
416
|
+
fmName = typeof fm?.name === 'string' ? fm.name : null;
|
|
417
|
+
fmDescription = typeof fm?.description === 'string' ? fm.description : null;
|
|
418
|
+
if (fmName === null || fmName === '') { if (status === 'green') status = 'yellow'; problems.push('frontmatter 缺 name'); }
|
|
419
|
+
if (fmDescription === null || fmDescription === '') { if (status === 'green') status = 'yellow'; problems.push('frontmatter 缺 description'); }
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
out.push({
|
|
423
|
+
kind: 'skill', name, origin: 'user', dir: path.join(P.SKILLS_DIR, name),
|
|
424
|
+
hasSkillMd: text !== null, fmName, fmDescription,
|
|
425
|
+
description: fmDescription ?? null,
|
|
426
|
+
status, problems
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
return { skills: out, userDirOk };
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// ---------------------------------------------------------------------------
|
|
434
|
+
// ⑤ 升级体检(版本 + 兼容风险 + 补丁关联;只读,唯一写=自己的缓存文件)
|
|
435
|
+
// ---------------------------------------------------------------------------
|
|
436
|
+
async function collectUpgrades(profilePkg, force, decouplingInputs = null, P = paths()) {
|
|
437
|
+
const labels = labelsOf(P.cfg);
|
|
438
|
+
try {
|
|
439
|
+
const r = await buildUpgradeState({
|
|
440
|
+
profilePkg,
|
|
441
|
+
profileNm: P.PROFILE_NM,
|
|
442
|
+
mainPkgDir: P.MAIN_PKG_DIR,
|
|
443
|
+
patchManifest: getPatchManifest(P.cfg),
|
|
444
|
+
force,
|
|
445
|
+
decouplingInputs,
|
|
446
|
+
config: P.cfg
|
|
447
|
+
});
|
|
448
|
+
for (const it of r.items) {
|
|
449
|
+
if (!it.description) it.description = labels[it.name] ?? null;
|
|
450
|
+
}
|
|
451
|
+
return r;
|
|
452
|
+
} catch (e) {
|
|
453
|
+
return {
|
|
454
|
+
items: [],
|
|
455
|
+
summary: { total: 0, green: 0, yellow: 0, red: 0, unknown: 0 },
|
|
456
|
+
cacheAt: null,
|
|
457
|
+
cacheSource: 'error',
|
|
458
|
+
errors: { _: String(e?.message ?? e) },
|
|
459
|
+
breaking: getBreakingSymbols(P.cfg),
|
|
460
|
+
fetchedAt: new Date().toISOString(),
|
|
461
|
+
tier: { ok: false, tierSource: 'unavailable', reason: `升级体检整体失败:${String(e?.message ?? e)}`, inputGaps: [], elapsedMs: null }
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// ---------------------------------------------------------------------------
|
|
467
|
+
// ⑥ 解耦健康(R1 运行时 import / R2 依赖面)
|
|
468
|
+
// 复用外部解耦检查脚本的导出函数(动态 import),**不复制判定逻辑**——两处真相会漂移;
|
|
469
|
+
// 脚本未配置或不存在时只降级成一条 🟡(附原因),绝不让整份体检失败、也绝不假装通过。
|
|
470
|
+
// ---------------------------------------------------------------------------
|
|
471
|
+
async function collectDecoupling(P = paths()) {
|
|
472
|
+
const scriptPath = P.cfg.scripts?.decoupling?.path ?? null;
|
|
473
|
+
const meta = {
|
|
474
|
+
script: scriptPath, scriptVersion: null, scope: null, imported: false,
|
|
475
|
+
scriptFrom: P.cfg.scripts?.decoupling?.from ?? null
|
|
476
|
+
};
|
|
477
|
+
const fallback = (detail) => ({
|
|
478
|
+
items: [{
|
|
479
|
+
kind: 'decoupling', name: 'check-decoupling.mjs', scope: null,
|
|
480
|
+
status: 'yellow', problems: [detail],
|
|
481
|
+
description: '解耦检查未执行(脚本不可用)'
|
|
482
|
+
}],
|
|
483
|
+
meta
|
|
484
|
+
});
|
|
485
|
+
if (scriptPath === null) {
|
|
486
|
+
return fallback('未配置解耦检查脚本(工具目录未指定)⇒ 本类不可判定。配置法:DSH_ECOSYSTEM_PANEL_TOOLS_DIR=<工具目录>'
|
|
487
|
+
+ ' 或 DSH_ECOSYSTEM_PANEL_DECOUPLING_SCRIPT=<脚本路径>;若本环境不做解耦体检,用 disabledGroups=["decoupling"] 停用本类');
|
|
488
|
+
}
|
|
489
|
+
if (!exists(scriptPath)) return fallback(`脚本不存在:${scriptPath}`);
|
|
490
|
+
let mod;
|
|
491
|
+
try {
|
|
492
|
+
mod = await import(pathToFileURL(scriptPath).href);
|
|
493
|
+
} catch (e) {
|
|
494
|
+
return fallback(`脚本导入失败:${String(e?.message ?? e)}`);
|
|
495
|
+
}
|
|
496
|
+
if (typeof mod.scanRoot !== 'function') return fallback('脚本未导出 scanRoot(版本不匹配?)');
|
|
497
|
+
meta.scriptVersion = typeof mod.VERSION === 'string' ? mod.VERSION : null;
|
|
498
|
+
meta.imported = true;
|
|
499
|
+
|
|
500
|
+
if (P.TOOLS_DIR === null) return fallback('已配置解耦脚本但未指定工具目录(DSH_ECOSYSTEM_PANEL_TOOLS_DIR)⇒ 无法确定扫描范围');
|
|
501
|
+
const tools = mod.scanRoot(P.TOOLS_DIR);
|
|
502
|
+
if (tools.error) return fallback(tools.error);
|
|
503
|
+
const scanned = [...tools.results];
|
|
504
|
+
meta.scope = `tools ${tools.results.length} 包`;
|
|
505
|
+
if (typeof mod.scanProfilePackages === 'function') {
|
|
506
|
+
try {
|
|
507
|
+
const pr = mod.scanProfilePackages();
|
|
508
|
+
scanned.push(...pr.results);
|
|
509
|
+
meta.scope += ` + profile ${pr.results.length} 包`;
|
|
510
|
+
} catch { /* profile 侧不可用不算失败 */ }
|
|
511
|
+
}
|
|
512
|
+
meta.packages = scanned.length;
|
|
513
|
+
|
|
514
|
+
const items = scanned.map((r) => {
|
|
515
|
+
const problems = [];
|
|
516
|
+
for (const v of r.hardViolations) {
|
|
517
|
+
problems.push(`R1 静态 import \`${v.module}\` → ${v.symbols.join(', ') || '(裸导入)'}(${v.file}:${v.line})`);
|
|
518
|
+
}
|
|
519
|
+
for (const v of r.softViolations) {
|
|
520
|
+
problems.push(`R1 动态导入 \`${v.module}\`(${v.file}:${v.line})→ 需确认调用点有 try/catch 兜底`);
|
|
521
|
+
}
|
|
522
|
+
if (r.dependencies.length) problems.push(`R2 dependencies 未归零:${r.dependencies.join(', ')}`);
|
|
523
|
+
if (r.clientInject.length) problems.push(`R2 client.inject 未归零:${r.clientInject.join(', ')}`);
|
|
524
|
+
const status = r.hardViolations.length ? 'red' : (problems.length ? 'yellow' : 'green');
|
|
525
|
+
return {
|
|
526
|
+
kind: 'decoupling', name: r.name, scope: r.scope, version: r.version,
|
|
527
|
+
status, problems,
|
|
528
|
+
r1Hard: r.hardViolations.length, r1Soft: r.softViolations.length,
|
|
529
|
+
depCount: r.dependencies.length, injectCount: r.clientInject.length,
|
|
530
|
+
description: r.scope === 'profile'
|
|
531
|
+
? '本地包(源码只在 profile 实体,无独立工具目录)'
|
|
532
|
+
: '本地包(工具目录源码)'
|
|
533
|
+
};
|
|
534
|
+
});
|
|
535
|
+
// 把解耦结论**复用**给档位 CLI(`meta.triageInputs`),CLI 侧不再重复扫一遍全部包 ⇒ 首屏更快。
|
|
536
|
+
// 口径来源(**不复制判定**):
|
|
537
|
+
// · R1 计数 = 上面 `scanRoot`/`scanProfilePackages` **明细的聚合**(判定仍是外部脚本做的);
|
|
538
|
+
// · R5 = 适配层一致性校验器(配置项,可缺)的 `verifySummary()`(只比 sha,几毫秒);
|
|
539
|
+
// 未配置 ⇒ 该项判"不可用"并在档位输入里如实标注(**不假绿**)。
|
|
540
|
+
let triageInputs = { ok: false, reason: '解耦结论未产出' };
|
|
541
|
+
try {
|
|
542
|
+
const r5Script = P.cfg.scripts?.hostAdapterSync?.path ?? null;
|
|
543
|
+
if (r5Script === null) throw new Error('未配置适配层一致性校验器(可选项)');
|
|
544
|
+
const syncMod = await import(pathToFileURL(r5Script).href);
|
|
545
|
+
if (typeof syncMod.verifySummary !== 'function') throw new Error('适配层一致性校验器未导出 verifySummary');
|
|
546
|
+
const r5 = syncMod.verifySummary();
|
|
547
|
+
triageInputs = {
|
|
548
|
+
ok: true,
|
|
549
|
+
r1HardCount: items.reduce((s, it) => s + (it.r1Hard ?? 0), 0),
|
|
550
|
+
r1SoftCount: items.reduce((s, it) => s + (it.r1Soft ?? 0), 0),
|
|
551
|
+
r1HardPackages: items.filter((it) => (it.r1Hard ?? 0) > 0).map((it) => it.name),
|
|
552
|
+
r5Ok: r5?.level === 'ok',
|
|
553
|
+
r5Note: r5?.note ?? null,
|
|
554
|
+
source: 'panel:decoupling-section + host-adapter-sync.verifySummary'
|
|
555
|
+
};
|
|
556
|
+
} catch (e) {
|
|
557
|
+
triageInputs = { ok: false, reason: `适配层一致性(R5)判定不可用:${String(e?.message ?? e)}` };
|
|
558
|
+
}
|
|
559
|
+
return { items, meta: { ...meta, triageInputs } };
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// ---------------------------------------------------------------------------
|
|
563
|
+
// ⑦ 跨表准入一致性(可选类目)
|
|
564
|
+
// 判定唯一源 = 外部校验器(动态 import,**不复制判定**,与 collectDecoupling 同形)——
|
|
565
|
+
// 校验器路径由配置层给出(DSH_ECOSYSTEM_PANEL_REGISTRY_AUDIT_LIB),**未配置即整组 unavailable**。
|
|
566
|
+
// ⚠️ **显式状态枚举 `ok / fail / unavailable`** ——**不许照抄 collectUpgrades 的 catch 形态**:
|
|
567
|
+
// 那个 catch 返 `summary 全 0 + ok:true` = **静默变绿**(同类错误不许再犯);本函数把三态写在
|
|
568
|
+
// `meta.status` 上,且 **`asOf` 缺失即判 unavailable**(显示"不可用",**不是 0/0/0 绿**)。
|
|
569
|
+
// 只渲染不自算(判定实现只许有一份,就在外部校验器里 —— 这里只消费它的输出);
|
|
570
|
+
// 纯只读:不写任何文件、不改开关、不发通知、不消费去重配额。
|
|
571
|
+
// ---------------------------------------------------------------------------
|
|
572
|
+
/** 跨表准入校验器路径(可选类目):来自配置层;未配置即整组 unavailable(**不是绿**) */
|
|
573
|
+
function registryAuditLibPath(cfg) {
|
|
574
|
+
return cfg?.scripts?.registryAudit?.path ?? null;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/** @param {{registryLibPath?:string, home?:string, root?:string, now?:number, startedAt?:number}} [opts]
|
|
578
|
+
* `registryLibPath` **仅供夹具**(把库指向沙箱/改名后的副本 ⇒ 实测 unavailable 反例);
|
|
579
|
+
* `startedAt` 缺省 = **本进程启动时刻**(`Date.now() - process.uptime()*1000`)——
|
|
580
|
+
* 面板跑的是**实体副本**、且它在进程启动时加载了当时的源仓 lib ⇒ "磁盘绿 ≠ 运行态绿"的判据就是它。 */
|
|
581
|
+
export async function collectRegistry(opts = {}) {
|
|
582
|
+
const cfg = opts.config ?? getConfig();
|
|
583
|
+
const libPath = opts.registryLibPath ?? registryAuditLibPath(cfg);
|
|
584
|
+
const home = opts.home ?? cfg.home;
|
|
585
|
+
const root = opts.root ?? (cfg.toolsDir ? path.dirname(cfg.toolsDir) : cfg.home);
|
|
586
|
+
const nowMs = opts.now ?? Date.now();
|
|
587
|
+
// 判定源新鲜度:校验库 mtime 晚于本进程启动时刻 ⇒ 该进程仍在跑旧代码(重启才生效)
|
|
588
|
+
const startedAtMs = Number.isFinite(opts.startedAt)
|
|
589
|
+
? opts.startedAt
|
|
590
|
+
: nowMs - Math.round(process.uptime() * 1000);
|
|
591
|
+
const base = {
|
|
592
|
+
lib: libPath, home, root, asOf: null, source: null, counts: null,
|
|
593
|
+
checkFailed: null, problemCount: null, unavailableCount: null,
|
|
594
|
+
problems: [], problemTotal: null, unavailable: [], unknown: [],
|
|
595
|
+
};
|
|
596
|
+
const unavailable = (reason, extra = {}) => ({ status: 'unavailable', ...base, reason, ...extra });
|
|
597
|
+
let mod;
|
|
598
|
+
try {
|
|
599
|
+
if (libPath === null) {
|
|
600
|
+
return unavailable('未配置跨表准入校验器 ⇒ 本组不可用(不是绿)。配置法:'
|
|
601
|
+
+ 'DSH_ECOSYSTEM_PANEL_REGISTRY_AUDIT_LIB=<校验器路径>;若本环境没有这类校验器,用 disabledGroups=["registry"] 停用本组');
|
|
602
|
+
}
|
|
603
|
+
if (!exists(libPath)) return unavailable(`校验器不存在:${libPath}`);
|
|
604
|
+
mod = await import(pathToFileURL(libPath).href);
|
|
605
|
+
} catch (e) {
|
|
606
|
+
return unavailable(`校验器导入失败:${String(e?.message ?? e)}`);
|
|
607
|
+
}
|
|
608
|
+
if (typeof mod.auditRegistries !== 'function') return unavailable('校验器未导出 auditRegistries(版本不匹配?)');
|
|
609
|
+
let r;
|
|
610
|
+
try {
|
|
611
|
+
r = mod.auditRegistries({ home, root, now: nowMs, startedAt: startedAtMs });
|
|
612
|
+
} catch (e) {
|
|
613
|
+
return unavailable(`校验器执行抛错:${String(e?.message ?? e)}`, { libVersion: mod.REGISTRY_AUDIT_VERSION ?? null });
|
|
614
|
+
}
|
|
615
|
+
// asOf 缺失 / 结构不对 ⇒ **unavailable**(判据:不许当 0/0/0 绿)
|
|
616
|
+
if (!r || typeof r.asOf !== 'string' || !r.asOf) {
|
|
617
|
+
return unavailable('校验器返回值缺 asOf ⇒ 判 unavailable(不许当 0/0/0 绿)', { libVersion: mod.REGISTRY_AUDIT_VERSION ?? null });
|
|
618
|
+
}
|
|
619
|
+
const problems = Array.isArray(r.problems) ? r.problems : [];
|
|
620
|
+
const un = Array.isArray(r.unavailable) ? r.unavailable : [];
|
|
621
|
+
const src = r.source ?? {};
|
|
622
|
+
return {
|
|
623
|
+
status: r.ok === true ? 'ok' : 'fail',
|
|
624
|
+
...base,
|
|
625
|
+
libVersion: src.libVersion ?? mod.REGISTRY_AUDIT_VERSION ?? null,
|
|
626
|
+
asOf: r.asOf,
|
|
627
|
+
source: {
|
|
628
|
+
lib: src.lib ?? (libPath ? path.basename(libPath) : null),
|
|
629
|
+
libVersion: src.libVersion ?? mod.REGISTRY_AUDIT_VERSION ?? null,
|
|
630
|
+
libMtime: src.libMtime ?? null,
|
|
631
|
+
startedAt: src.startedAt ?? null,
|
|
632
|
+
pendingRestart: src.pendingRestart ?? null,
|
|
633
|
+
},
|
|
634
|
+
counts: r.counts ?? null,
|
|
635
|
+
checkFailed: Object.values(r.byDomain ?? {}).reduce((s, v) => s + Number(v?.failed ?? 0), 0),
|
|
636
|
+
problemCount: problems.length,
|
|
637
|
+
problemTotal: problems.length,
|
|
638
|
+
unavailableCount: un.length,
|
|
639
|
+
problems: problems.slice(0, 5).map((p) => `[${p.domain}] ${p.id} · ${p.check}:${p.detail}`),
|
|
640
|
+
unavailable: un.slice(0, 5).map((u) => `[${u.domain}] ${u.check}:${u.detail}`),
|
|
641
|
+
reason: r.ok === true
|
|
642
|
+
? null
|
|
643
|
+
: (problems.length ? `差异 ${problems.length} 项(判定唯一源:registry-audit.mjs)` : `判定不可用 ${un.length} 项(**不是绿**)`),
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// ---------------------------------------------------------------------------
|
|
648
|
+
// 本插件自己的版本 + 停用提示(装配层小件)
|
|
649
|
+
// ---------------------------------------------------------------------------
|
|
650
|
+
/** 本插件自己的版本(从自身 package.json 读;**不写死**,避免发版时漏改一处) */
|
|
651
|
+
const SELF_VERSION = (() => {
|
|
652
|
+
try {
|
|
653
|
+
const pkgPath = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json');
|
|
654
|
+
return readJson(pkgPath)?.version ?? 'unknown';
|
|
655
|
+
} catch {
|
|
656
|
+
return 'unknown';
|
|
657
|
+
}
|
|
658
|
+
})();
|
|
659
|
+
|
|
660
|
+
/** 类目停用提示项(**显式呈现**为一条提示,不伪装成"0 项"、也不假装全绿) */
|
|
661
|
+
function disabledNotice(key) {
|
|
662
|
+
return [{
|
|
663
|
+
kind: key.replace(/s$/, ''),
|
|
664
|
+
name: `${key}(已按配置停用)`,
|
|
665
|
+
status: 'yellow',
|
|
666
|
+
problems: [`该类体检被配置停用(disabledGroups 含 "${key}")⇒ 不计入 summary,也不代表"全绿"`],
|
|
667
|
+
description: '停用项:配置层 disabledGroups'
|
|
668
|
+
}];
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// ---------------------------------------------------------------------------
|
|
672
|
+
// 数据源清单(**参数化**)
|
|
673
|
+
//
|
|
674
|
+
// 首屏的每个格子 = 一个源。新增一个源 = 往 BUILTIN_SOURCES 里加一条(或在运行时
|
|
675
|
+
// registerSource()),**装配主流程 buildState 不用改一行** —— 这是本卡拿来证明
|
|
676
|
+
// "分片是参数化的"的判据(夹具里现场注册一个源跑通)。
|
|
677
|
+
//
|
|
678
|
+
// 每条源:
|
|
679
|
+
// id 源名(也是 SLI / 预热清单里的键名)
|
|
680
|
+
// label 页面上的中文名(降级时显示成「<label> 不可探测」)
|
|
681
|
+
// group 落到面板哪个分组;不给 = 只贡献 meta、不出行
|
|
682
|
+
// deps 依赖哪些源(跑之前先把它们读完;用 ctx.get(id) 取值,拿不到就是 undefined)
|
|
683
|
+
// timeoutMs 单源缺省超时(配置层 sourceTimeouts / 环境变量可按 id 覆盖)
|
|
684
|
+
// run(ctx) 只负责取数;**不许自己画降级**(降级是车道与装配层的事)
|
|
685
|
+
// describe(v) 把取数结果翻成 { rows, meta, state }:
|
|
686
|
+
// rows → 面板行;meta → 并入 state.meta;state → 并入顶层字段 / 分组附加键
|
|
687
|
+
// ---------------------------------------------------------------------------
|
|
688
|
+
const BUILTIN_SOURCES = [
|
|
689
|
+
{
|
|
690
|
+
id: 'boot',
|
|
691
|
+
label: 'boot 清单',
|
|
692
|
+
timeoutMs: 1500,
|
|
693
|
+
async run(ctx) {
|
|
694
|
+
return fetchBootEntries(ctx.paths);
|
|
695
|
+
},
|
|
696
|
+
describe(v) {
|
|
697
|
+
const ids = v?.ids ?? null;
|
|
698
|
+
return {
|
|
699
|
+
rows: [],
|
|
700
|
+
meta: { bootEntries: ids === null ? null : ids.length, bootError: v?.error ?? null }
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
},
|
|
704
|
+
{
|
|
705
|
+
id: 'profile',
|
|
706
|
+
label: 'profile 清单',
|
|
707
|
+
timeoutMs: 800,
|
|
708
|
+
async run(ctx) {
|
|
709
|
+
return readJson(path.join(ctx.paths.PROFILE_DIR, 'package.json'));
|
|
710
|
+
},
|
|
711
|
+
describe() {
|
|
712
|
+
return { rows: [], meta: {} };
|
|
713
|
+
}
|
|
714
|
+
},
|
|
715
|
+
{
|
|
716
|
+
id: 'bundles',
|
|
717
|
+
label: '插件包(bundle)',
|
|
718
|
+
group: 'bundles',
|
|
719
|
+
deps: ['profile', 'boot'],
|
|
720
|
+
timeoutMs: 1500,
|
|
721
|
+
async run(ctx) {
|
|
722
|
+
const profilePkg = ctx.get('profile') ?? null;
|
|
723
|
+
const bootIds = ctx.get('boot')?.ids ?? null;
|
|
724
|
+
return collectBundles(profilePkg, bootIds, ctx.paths);
|
|
725
|
+
},
|
|
726
|
+
describe(v) {
|
|
727
|
+
return { rows: Array.isArray(v) ? v : [] };
|
|
728
|
+
}
|
|
729
|
+
},
|
|
730
|
+
{
|
|
731
|
+
id: 'inserts',
|
|
732
|
+
label: 'patch insert',
|
|
733
|
+
group: 'inserts',
|
|
734
|
+
timeoutMs: 1200,
|
|
735
|
+
async run(ctx) {
|
|
736
|
+
return collectInserts(ctx.paths);
|
|
737
|
+
},
|
|
738
|
+
describe(v) {
|
|
739
|
+
return {
|
|
740
|
+
rows: Array.isArray(v?.inserts) ? v.inserts : [],
|
|
741
|
+
meta: { patchYamlError: v?.parseError ?? null },
|
|
742
|
+
state: { groups: { disabledPatchIds: Array.isArray(v?.disabledIds) ? v.disabledIds : [] } }
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
},
|
|
746
|
+
{
|
|
747
|
+
id: 'patches',
|
|
748
|
+
label: '补丁健康',
|
|
749
|
+
group: 'patches',
|
|
750
|
+
timeoutMs: 1200,
|
|
751
|
+
async run(ctx) {
|
|
752
|
+
return collectPatches(ctx.paths);
|
|
753
|
+
},
|
|
754
|
+
describe(v) {
|
|
755
|
+
return { rows: Array.isArray(v) ? v : [] };
|
|
756
|
+
}
|
|
757
|
+
},
|
|
758
|
+
{
|
|
759
|
+
id: 'skills',
|
|
760
|
+
label: '技能',
|
|
761
|
+
group: 'skills',
|
|
762
|
+
timeoutMs: 1500,
|
|
763
|
+
async run(ctx) {
|
|
764
|
+
return collectSkills(ctx.paths);
|
|
765
|
+
},
|
|
766
|
+
describe(v) {
|
|
767
|
+
return {
|
|
768
|
+
rows: Array.isArray(v?.skills) ? v.skills : [],
|
|
769
|
+
meta: { skillsDirOk: v?.userDirOk ?? null }
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
},
|
|
773
|
+
{
|
|
774
|
+
id: 'decoupling',
|
|
775
|
+
label: '解耦健康',
|
|
776
|
+
group: 'decoupling',
|
|
777
|
+
timeoutMs: 3000,
|
|
778
|
+
async run(ctx) {
|
|
779
|
+
return collectDecoupling(ctx.paths);
|
|
780
|
+
},
|
|
781
|
+
describe(v) {
|
|
782
|
+
const items = Array.isArray(v?.items) ? v.items : [];
|
|
783
|
+
const meta = v?.meta ?? {};
|
|
784
|
+
const s = { green: 0, yellow: 0, red: 0, total: items.length };
|
|
785
|
+
for (const it of items) s[it.status] = (s[it.status] ?? 0) + 1;
|
|
786
|
+
return {
|
|
787
|
+
rows: items,
|
|
788
|
+
meta: {
|
|
789
|
+
decouplingScript: meta.script ?? null,
|
|
790
|
+
decouplingScriptVersion: meta.scriptVersion ?? null,
|
|
791
|
+
decouplingScope: meta.scope ?? null
|
|
792
|
+
},
|
|
793
|
+
state: {
|
|
794
|
+
decouplingMeta: {
|
|
795
|
+
script: meta.script ?? null,
|
|
796
|
+
scriptVersion: meta.scriptVersion ?? null,
|
|
797
|
+
scope: meta.scope ?? null,
|
|
798
|
+
packages: meta.packages ?? 0,
|
|
799
|
+
summary: s
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
},
|
|
805
|
+
{
|
|
806
|
+
id: 'upgrades',
|
|
807
|
+
label: '升级体检',
|
|
808
|
+
group: 'upgrades',
|
|
809
|
+
deps: ['profile', 'decoupling'],
|
|
810
|
+
// 唯一会出网的一格(查 npm 最新版):慢一点可以等,但**必须**有上限 —— 上限到了就降级那一格,
|
|
811
|
+
// 不能让它把整页按在转圈里。实测冷缓存一次 1.5~3 s,留足余量。
|
|
812
|
+
timeoutMs: 12000,
|
|
813
|
+
async run(ctx) {
|
|
814
|
+
const profilePkg = ctx.get('profile') ?? null;
|
|
815
|
+
const triageInputs = ctx.get('decoupling')?.meta?.triageInputs ?? null;
|
|
816
|
+
return collectUpgrades(profilePkg, ctx.opts?.refreshUpgrades === true, triageInputs, ctx.paths);
|
|
817
|
+
},
|
|
818
|
+
describe(v) {
|
|
819
|
+
return {
|
|
820
|
+
rows: Array.isArray(v?.items) ? v.items : [],
|
|
821
|
+
meta: { upgradeCacheAt: v?.cacheAt ?? null, upgradeCacheSource: v?.cacheSource ?? null },
|
|
822
|
+
state: {
|
|
823
|
+
upgradesMeta: {
|
|
824
|
+
summary: v?.summary ?? { total: 0, green: 0, yellow: 0, red: 0, unknown: 0 },
|
|
825
|
+
cacheAt: v?.cacheAt ?? null,
|
|
826
|
+
cacheSource: v?.cacheSource ?? null,
|
|
827
|
+
errors: v?.errors ?? {},
|
|
828
|
+
breaking: v?.breaking ?? null,
|
|
829
|
+
fetchedAt: v?.fetchedAt ?? null,
|
|
830
|
+
tier: v?.tier ?? null
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
},
|
|
836
|
+
{
|
|
837
|
+
id: 'registry',
|
|
838
|
+
label: '跨表准入校验器',
|
|
839
|
+
group: 'registry',
|
|
840
|
+
timeoutMs: 3000,
|
|
841
|
+
async run(ctx) {
|
|
842
|
+
const o = ctx.opts ?? {};
|
|
843
|
+
return collectRegistry({
|
|
844
|
+
config: ctx.cfg,
|
|
845
|
+
...(typeof o.registryLibPath === 'string' ? { registryLibPath: o.registryLibPath } : {}),
|
|
846
|
+
...(typeof o.registryHome === 'string' ? { home: o.registryHome } : {}),
|
|
847
|
+
...(typeof o.registryRoot === 'string' ? { root: o.registryRoot } : {}),
|
|
848
|
+
...(Number.isFinite(o.registryNow) ? { now: o.registryNow } : {})
|
|
849
|
+
});
|
|
850
|
+
},
|
|
851
|
+
describe(v) {
|
|
852
|
+
const status = v?.status ?? 'unavailable';
|
|
853
|
+
const row = {
|
|
854
|
+
kind: 'registry',
|
|
855
|
+
name: '跨表准入校验器',
|
|
856
|
+
// 面板色域只有 green/yellow/red ⇒ unavailable 用 yellow;**权威枚举看 registryMeta.status**
|
|
857
|
+
status: status === 'ok' ? 'green' : (status === 'fail' ? 'red' : 'yellow'),
|
|
858
|
+
problems: status === 'unavailable' ? [`判定不可用:${v?.reason}`] : (v?.problems ?? []),
|
|
859
|
+
description: status === 'ok'
|
|
860
|
+
? '跨表准入零差集(判定唯一源 = 外部校验器,本面板只渲染不自算)'
|
|
861
|
+
: v?.reason
|
|
862
|
+
};
|
|
863
|
+
return {
|
|
864
|
+
rows: [row],
|
|
865
|
+
state: {
|
|
866
|
+
registryMeta: {
|
|
867
|
+
status,
|
|
868
|
+
asOf: v?.asOf ?? null,
|
|
869
|
+
reason: v?.reason ?? null,
|
|
870
|
+
lib: v?.lib ?? null,
|
|
871
|
+
libVersion: v?.libVersion ?? null,
|
|
872
|
+
source: v?.source ?? null,
|
|
873
|
+
home: v?.home ?? null,
|
|
874
|
+
root: v?.root ?? null,
|
|
875
|
+
counts: v?.counts ?? null,
|
|
876
|
+
checkFailed: v?.checkFailed ?? null,
|
|
877
|
+
problemCount: v?.problemCount ?? null,
|
|
878
|
+
problemTotal: v?.problemTotal ?? null,
|
|
879
|
+
unavailableCount: v?.unavailableCount ?? null,
|
|
880
|
+
problems: v?.problems ?? [],
|
|
881
|
+
unavailable: v?.unavailable ?? []
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
];
|
|
888
|
+
|
|
889
|
+
let SOURCES = BUILTIN_SOURCES.slice();
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* 运行时追加 / 覆盖一个数据源(下游插件可以把自己的健康片挂进这块面板;夹具也用它
|
|
893
|
+
* 证明"源清单是参数化的、加源不用改主流程")。
|
|
894
|
+
* @param {{id:string,label?:string,group?:string,deps?:string[],timeoutMs?:number,
|
|
895
|
+
* requires?:(ctx:object)=>(string|null),run:(ctx:object)=>any,describe?:(v:any)=>object}} def
|
|
896
|
+
*/
|
|
897
|
+
export function registerSource(def) {
|
|
898
|
+
if (!def || typeof def.id !== 'string' || typeof def.run !== 'function') {
|
|
899
|
+
throw new Error('registerSource: 至少需要 { id, run }');
|
|
900
|
+
}
|
|
901
|
+
const at = SOURCES.findIndex((s) => s.id === def.id);
|
|
902
|
+
if (at >= 0) SOURCES[at] = def;
|
|
903
|
+
else SOURCES.push(def);
|
|
904
|
+
return def;
|
|
905
|
+
}
|
|
906
|
+
/** 当前生效的源清单(顺序 = 装配顺序) */
|
|
907
|
+
export function listSources() {
|
|
908
|
+
return SOURCES.slice();
|
|
909
|
+
}
|
|
910
|
+
/** 回到内置清单(夹具收尾用) */
|
|
911
|
+
export function resetSources() {
|
|
912
|
+
SOURCES = BUILTIN_SOURCES.slice();
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// ---------------------------------------------------------------------------
|
|
916
|
+
// 取数车道(每个配置指纹一条):
|
|
917
|
+
// TTL 缓存 + SWR / 单飞 / 超时预算 / 局部降级 全在 fetch-lane 里,
|
|
918
|
+
// 这里只负责"按当前配置建一条、并保证源清单变化时同步进车道"。
|
|
919
|
+
// ---------------------------------------------------------------------------
|
|
920
|
+
const lanes = new Map();
|
|
921
|
+
const LANE_LIMIT = 4;
|
|
922
|
+
|
|
923
|
+
function laneFingerprint(cfg, opts = {}) {
|
|
924
|
+
return JSON.stringify({
|
|
925
|
+
home: cfg.home,
|
|
926
|
+
profile: cfg.profile,
|
|
927
|
+
webUrl: cfg.webUrl,
|
|
928
|
+
toolsDir: cfg.toolsDir,
|
|
929
|
+
mainNm: cfg.mainNm,
|
|
930
|
+
cacheFile: cfg.cacheFile,
|
|
931
|
+
scripts: cfg.scripts,
|
|
932
|
+
patchManifest: cfg.patchManifest,
|
|
933
|
+
upgradeRules: cfg.upgradeRules,
|
|
934
|
+
breakingSymbols: cfg.breakingSymbols,
|
|
935
|
+
labels: cfg.labels,
|
|
936
|
+
text: cfg.text,
|
|
937
|
+
disabledGroups: cfg.disabledGroups,
|
|
938
|
+
coreBundles: cfg.coreBundles,
|
|
939
|
+
ttlMs: cfg.ttlMs,
|
|
940
|
+
sourceTimeoutMs: cfg.sourceTimeoutMs,
|
|
941
|
+
totalBudgetMs: cfg.totalBudgetMs,
|
|
942
|
+
sourceTimeouts: cfg.sourceTimeouts,
|
|
943
|
+
registryLibPath: opts.registryLibPath ?? null,
|
|
944
|
+
registryHome: opts.registryHome ?? null,
|
|
945
|
+
registryRoot: opts.registryRoot ?? null,
|
|
946
|
+
registryNow: opts.registryNow ?? null
|
|
947
|
+
});
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/** 取(或建)当前配置对应的车道;源清单新增项会自动补进去 */
|
|
951
|
+
export function getLane(cfg = getConfig(), opts = {}) {
|
|
952
|
+
const key = laneFingerprint(cfg, opts);
|
|
953
|
+
let lane = lanes.get(key);
|
|
954
|
+
if (!lane) {
|
|
955
|
+
lane = createLane({
|
|
956
|
+
sources: SOURCES,
|
|
957
|
+
ttlMs: cfg.ttlMs,
|
|
958
|
+
defaultTimeoutMs: cfg.sourceTimeoutMs,
|
|
959
|
+
totalBudgetMs: cfg.totalBudgetMs,
|
|
960
|
+
// 预热器调用 lane.prime() 时只给源名(它不该知道路径怎么拼),所以路径放进车道级上下文
|
|
961
|
+
context: { cfg, paths: pathsFromConfig(cfg) },
|
|
962
|
+
...(typeof opts.now === 'function' ? { now: opts.now } : {})
|
|
963
|
+
});
|
|
964
|
+
lanes.set(key, lane);
|
|
965
|
+
while (lanes.size > LANE_LIMIT) lanes.delete(lanes.keys().next().value);
|
|
966
|
+
}
|
|
967
|
+
for (const def of SOURCES) if (!lane.defs.has(def.id)) lane.addSource(def);
|
|
968
|
+
return lane;
|
|
969
|
+
}
|
|
970
|
+
/** 丢开所有车道(夹具 / 重启后重来) */
|
|
971
|
+
export function resetLanes() {
|
|
972
|
+
lanes.clear();
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
// ---------------------------------------------------------------------------
|
|
976
|
+
// 预热状态上报(由 lib/index.js 注册;buildState 只读不建 —— 别在纯取数路径里起定时器)
|
|
977
|
+
// ---------------------------------------------------------------------------
|
|
978
|
+
let warmupReporter = null;
|
|
979
|
+
/** @param {null | (() => object)} fn */
|
|
980
|
+
export function setWarmupReporter(fn) {
|
|
981
|
+
warmupReporter = typeof fn === 'function' ? fn : null;
|
|
982
|
+
}
|
|
983
|
+
function warmupStatus() {
|
|
984
|
+
if (warmupReporter === null) return { enabled: false, reason: '未挂预热器(面板未起在宿主里时属正常)' };
|
|
985
|
+
try {
|
|
986
|
+
return warmupReporter();
|
|
987
|
+
} catch (error) {
|
|
988
|
+
return { enabled: false, reason: `预热状态读取失败:${String(error?.message ?? error)}` };
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// ---------------------------------------------------------------------------
|
|
993
|
+
// 降级行的统一画法
|
|
994
|
+
// ---------------------------------------------------------------------------
|
|
995
|
+
function degradeRow(source, res) {
|
|
996
|
+
const why = `${res.reason ?? '不可探测'}${res.detail ? `(${res.detail})` : ''}`;
|
|
997
|
+
return {
|
|
998
|
+
kind: source.rowKind ?? source.id,
|
|
999
|
+
name: source.label ?? source.id,
|
|
1000
|
+
status: 'yellow',
|
|
1001
|
+
problems: [`不可探测:${why}${res.coolingDown ? ' —— 失败冷却中,稍后自动重试' : ''}`],
|
|
1002
|
+
description: `${source.label ?? source.id} 本次没取到数;**只有这一格受影响**,其余类目照常出`
|
|
1003
|
+
};
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// ---------------------------------------------------------------------------
|
|
1007
|
+
// 汇总(装配)
|
|
1008
|
+
// ---------------------------------------------------------------------------
|
|
1009
|
+
const GROUP_KEYS = ['bundles', 'inserts', 'patches', 'skills', 'decoupling', 'upgrades', 'registry'];
|
|
1010
|
+
const COUNTED_GROUPS = ['bundles', 'inserts', 'patches', 'skills', 'decoupling'];
|
|
1011
|
+
|
|
1012
|
+
/**
|
|
1013
|
+
* 跑一遍首屏取数并装配状态。
|
|
1014
|
+
* @param {object} [options]
|
|
1015
|
+
* @param {object} [options.config] 配置层显式覆盖(夹具 / verify-state 用)
|
|
1016
|
+
* @param {boolean} [options.refreshUpgrades] 强制重查升级体检(等价于只刷新 upgrades 这一源)
|
|
1017
|
+
* @param {boolean|string[]} [options.force] 强制刷新(true = 全部源;数组 = 指定源)
|
|
1018
|
+
* @param {object} [options.lane] 直接注入车道(夹具用;缺省按配置指纹取)
|
|
1019
|
+
* @param {Function} [options.now] 时钟(夹具注入假时钟用)
|
|
1020
|
+
*/
|
|
1021
|
+
export async function buildState(options = {}) {
|
|
1022
|
+
const P = paths(options.config ?? null);
|
|
1023
|
+
const cfg = P.cfg;
|
|
1024
|
+
const lane = options.lane ?? getLane(cfg, options);
|
|
1025
|
+
const sources = listSources();
|
|
1026
|
+
const ids = sources.map((s) => s.id);
|
|
1027
|
+
|
|
1028
|
+
const force = options.force === true
|
|
1029
|
+
? true
|
|
1030
|
+
: [
|
|
1031
|
+
...(Array.isArray(options.force) ? options.force : []),
|
|
1032
|
+
...(options.refreshUpgrades === true ? ['upgrades'] : [])
|
|
1033
|
+
];
|
|
1034
|
+
|
|
1035
|
+
const read = await lane.readAll(ids, {
|
|
1036
|
+
force,
|
|
1037
|
+
cfg,
|
|
1038
|
+
paths: P,
|
|
1039
|
+
opts: {
|
|
1040
|
+
refreshUpgrades: options.refreshUpgrades === true,
|
|
1041
|
+
sourceTimeouts: cfg.sourceTimeouts,
|
|
1042
|
+
registryLibPath: options.registryLibPath,
|
|
1043
|
+
registryHome: options.registryHome,
|
|
1044
|
+
registryRoot: options.registryRoot,
|
|
1045
|
+
registryNow: options.registryNow
|
|
1046
|
+
}
|
|
1047
|
+
});
|
|
1048
|
+
|
|
1049
|
+
const groups = {};
|
|
1050
|
+
const extraGroups = {};
|
|
1051
|
+
const top = {};
|
|
1052
|
+
const freshness = { ttlMs: lane.ttlMs, stale: false, oldestAgeMs: null, sources: {} };
|
|
1053
|
+
const degradeEvents = [];
|
|
1054
|
+
const requestSources = [];
|
|
1055
|
+
const meta = {
|
|
1056
|
+
profileDir: P.PROFILE_DIR,
|
|
1057
|
+
skillsDir: P.SKILLS_DIR,
|
|
1058
|
+
// 下面这些是"源没取到"时的**中性缺省**:显式 null/空,不编造、也不假绿
|
|
1059
|
+
bootEntries: null,
|
|
1060
|
+
bootError: null,
|
|
1061
|
+
patchYamlError: null,
|
|
1062
|
+
skillsDirOk: null,
|
|
1063
|
+
panelVersion: SELF_VERSION,
|
|
1064
|
+
upgradeCacheAt: null,
|
|
1065
|
+
upgradeCacheSource: null,
|
|
1066
|
+
decouplingScript: null,
|
|
1067
|
+
decouplingScriptVersion: null,
|
|
1068
|
+
decouplingScope: null,
|
|
1069
|
+
toolsDir: P.TOOLS_DIR,
|
|
1070
|
+
disabledGroups: cfg.disabledGroups,
|
|
1071
|
+
yamlAvailable: YAML_AVAILABLE,
|
|
1072
|
+
config: describeResolution(cfg),
|
|
1073
|
+
configErrors: cfg.errors,
|
|
1074
|
+
// 界面文案覆盖:有值则前端用它替代内置中性文案(见 lib/client.js)
|
|
1075
|
+
ui: Object.keys(cfg.text).length ? cfg.text : null
|
|
1076
|
+
};
|
|
1077
|
+
const registryMetaDefault = {
|
|
1078
|
+
status: 'unavailable', asOf: null, reason: '本次未取到跨表准入结论(该源降级)',
|
|
1079
|
+
lib: null, libVersion: null, source: null, home: null, root: null, counts: null,
|
|
1080
|
+
checkFailed: null, problemCount: null, problemTotal: null, unavailableCount: null,
|
|
1081
|
+
problems: [], unavailable: []
|
|
1082
|
+
};
|
|
1083
|
+
const upgradesMetaDefault = {
|
|
1084
|
+
summary: { total: 0, green: 0, yellow: 0, red: 0, unknown: 0 },
|
|
1085
|
+
cacheAt: null, cacheSource: null, errors: {}, breaking: getBreakingSymbols(cfg),
|
|
1086
|
+
fetchedAt: null, tier: null
|
|
1087
|
+
};
|
|
1088
|
+
const decouplingMetaDefault = { script: null, scriptVersion: null, scope: null, packages: 0, summary: { green: 0, yellow: 0, red: 0, total: 0 } };
|
|
1089
|
+
|
|
1090
|
+
for (const source of sources) {
|
|
1091
|
+
const res = read.byId[source.id];
|
|
1092
|
+
const ok = res !== undefined && res.status !== 'degraded';
|
|
1093
|
+
requestSources.push({
|
|
1094
|
+
id: source.id,
|
|
1095
|
+
label: source.label ?? source.id,
|
|
1096
|
+
status: res?.status ?? 'missing',
|
|
1097
|
+
elapsedMs: res?.elapsedMs ?? null,
|
|
1098
|
+
ageMs: res?.ageMs ?? null,
|
|
1099
|
+
reason: res?.reason ?? null
|
|
1100
|
+
});
|
|
1101
|
+
|
|
1102
|
+
if (ok) {
|
|
1103
|
+
const block = source.describe ? (source.describe(res.value) ?? {}) : {};
|
|
1104
|
+
const rows = Array.isArray(block.rows) ? block.rows : [];
|
|
1105
|
+
if (source.group) groups[source.group] = (groups[source.group] ?? []).concat(rows);
|
|
1106
|
+
Object.assign(meta, block.meta ?? {});
|
|
1107
|
+
Object.assign(top, block.state ?? {});
|
|
1108
|
+
if (block.state?.groups) Object.assign(extraGroups, block.state.groups);
|
|
1109
|
+
freshness.sources[source.id] = {
|
|
1110
|
+
status: res.status,
|
|
1111
|
+
stale: res.status === 'stale',
|
|
1112
|
+
fetchedAt: new Date(res.fetchedAt).toISOString(),
|
|
1113
|
+
ageMs: res.ageMs
|
|
1114
|
+
};
|
|
1115
|
+
if (res.status === 'stale') {
|
|
1116
|
+
freshness.stale = true;
|
|
1117
|
+
freshness.oldestAgeMs = Math.max(freshness.oldestAgeMs ?? 0, res.ageMs ?? 0);
|
|
1118
|
+
}
|
|
1119
|
+
} else {
|
|
1120
|
+
// 局部降级:只有这一格标「不可探测 + 原因」,其余源照常出
|
|
1121
|
+
const degraded = res ?? { reason: '未取到', detail: null };
|
|
1122
|
+
if (source.group) groups[source.group] = (groups[source.group] ?? []).concat([degradeRow(source, degraded)]);
|
|
1123
|
+
degradeEvents.push({
|
|
1124
|
+
id: source.id,
|
|
1125
|
+
label: source.label ?? source.id,
|
|
1126
|
+
reason: degraded.reason ?? '未知',
|
|
1127
|
+
detail: degraded.detail ?? null,
|
|
1128
|
+
coolingDown: degraded.coolingDown === true
|
|
1129
|
+
});
|
|
1130
|
+
freshness.sources[source.id] = {
|
|
1131
|
+
status: 'degraded',
|
|
1132
|
+
stale: false,
|
|
1133
|
+
fetchedAt: null,
|
|
1134
|
+
ageMs: null,
|
|
1135
|
+
reason: degraded.reason ?? null
|
|
1136
|
+
};
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
// 分组附加键(例如 disabledPatchIds)落在分组表里;没有就显式给空数组(与旧输出一致)
|
|
1141
|
+
groups.disabledPatchIds = extraGroups.disabledPatchIds ?? [];
|
|
1142
|
+
|
|
1143
|
+
// 配置层停用类目:显式呈现为一条"已停用"项(**不伪装成 0 项**),且不计入 summary
|
|
1144
|
+
for (const key of GROUP_KEYS) {
|
|
1145
|
+
if (cfg.isGroupDisabled(key)) groups[key] = disabledNotice(key);
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
// summary 只统计"真正参与判定的前五类"(与改造前一致:upgrades / registry 各有独立 meta)
|
|
1149
|
+
const countedGroups = COUNTED_GROUPS.filter((k) => !cfg.isGroupDisabled(k));
|
|
1150
|
+
const all = countedGroups.flatMap((k) => groups[k] ?? []);
|
|
1151
|
+
const summary = { total: all.length, green: 0, yellow: 0, red: 0 };
|
|
1152
|
+
for (const item of all) summary[item.status] = (summary[item.status] ?? 0) + 1;
|
|
1153
|
+
|
|
1154
|
+
const laneSli = lane.sli();
|
|
1155
|
+
return {
|
|
1156
|
+
ok: true,
|
|
1157
|
+
generatedAt: new Date().toISOString(),
|
|
1158
|
+
meta,
|
|
1159
|
+
groups,
|
|
1160
|
+
upgradesMeta: top.upgradesMeta ?? upgradesMetaDefault,
|
|
1161
|
+
registryMeta: top.registryMeta ?? registryMetaDefault,
|
|
1162
|
+
decouplingMeta: top.decouplingMeta ?? decouplingMetaDefault,
|
|
1163
|
+
summary,
|
|
1164
|
+
/**
|
|
1165
|
+
* 这一屏的数据新鲜度:stale=true 表示"有源先给了旧值、后台正在换新",
|
|
1166
|
+
* 页面上据此标「旧值 · 旧了 N 秒」,而不是干等转圈。
|
|
1167
|
+
*/
|
|
1168
|
+
freshness,
|
|
1169
|
+
/** SLI:取数耗时(每源 + 总)/ 命中率(命中·未命中·冷启动分开)/ 降级次数(每源)+ 预热状态 */
|
|
1170
|
+
sli: {
|
|
1171
|
+
...laneSli,
|
|
1172
|
+
request: {
|
|
1173
|
+
startedAt: new Date(read.startedAt).toISOString(),
|
|
1174
|
+
totalMs: read.elapsedMs,
|
|
1175
|
+
budgetMs: lane.totalBudgetMs,
|
|
1176
|
+
sources: requestSources
|
|
1177
|
+
},
|
|
1178
|
+
degradeEvents,
|
|
1179
|
+
warmup: warmupStatus()
|
|
1180
|
+
}
|
|
1181
|
+
};
|
|
1182
|
+
}
|