-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathindex.html
More file actions
1193 lines (1147 loc) · 60.6 KB
/
Copy pathindex.html
File metadata and controls
1193 lines (1147 loc) · 60.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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
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
295
296
297
298
299
300
301
302
303
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
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
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
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
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>nodedata — 节点性能监控</title>
<style>
:root{
--bg:#f4f5f7; --card:#fff; --line:#e3e6ea; --ink:#1b1f24; --dim:#697280;
--ok:#2e9e5b; --warn:#d98016; --bad:#d63f3f; --cool:#2f6fbf; --accent:#1b4b8f;
color-scheme:light;
}
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"PingFang SC","Microsoft YaHei",sans-serif;
background:var(--bg);color:var(--ink);font-size:14px;line-height:1.5;padding:18px}
.wrap{max-width:1500px;margin:0 auto}
header{display:flex;flex-wrap:wrap;gap:12px;align-items:baseline;justify-content:space-between;margin-bottom:16px}
header h1{font-size:20px;font-weight:650;letter-spacing:-.2px}
header h1 small{font-weight:400;color:var(--dim);font-size:13px;margin-left:8px}
.hdr-right{display:flex;gap:10px;align-items:center;font-size:12px;color:var(--dim)}
.badge{display:inline-block;padding:2px 9px;border-radius:11px;font-size:12px;font-weight:600;color:#fff}
.badge.ok{background:var(--ok)} .badge.warn{background:var(--warn)} .badge.bad{background:var(--bad)}
.badge.idle{background:#98a1ad}
button{font:inherit;padding:6px 13px;border:1px solid var(--line);background:#fff;color:var(--ink);
border-radius:6px;cursor:pointer}
button:hover{border-color:#bfc6cf;background:#fafbfc}
button.on{background:var(--accent);border-color:var(--accent);color:#fff}
section{background:var(--card);border:1px solid var(--line);border-radius:9px;padding:16px 18px;margin-bottom:16px}
section>h2{font-size:15px;font-weight:650;margin-bottom:3px}
section>h2 .layer{color:var(--dim);font-weight:500;font-size:12px;margin-left:7px}
.sub{color:var(--dim);font-size:12px;margin-bottom:13px}
.err{border-left:3px solid var(--bad);background:#fdf0f0;color:#8f2a2a;padding:10px 12px;
border-radius:5px;font-size:13px;margin:8px 0;font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
.empty{color:var(--dim);font-size:13px;padding:14px 0}
/* ---- L0 ---- */
.only{float:right;font-size:12px;color:var(--dim);cursor:pointer;user-select:none}
.only input{vertical-align:-1px;margin-right:3px}
.baseline{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
.baseline button{font-size:11.5px;padding:2px 8px}
.cats{display:grid;grid-template-columns:repeat(auto-fill,minmax(232px,1fr));gap:12px}
.cat{border:1px solid var(--line);border-radius:7px;padding:11px 12px;background:#fcfcfd}
.cat.hasfail{border-color:#f0c2c2;background:#fffafa}
.cat.haswarn{border-color:#f2ddb8;background:#fffdf7}
.cat-h{display:flex;justify-content:space-between;align-items:center;gap:6px}
.cat-h b{font-size:13px;font-weight:620}
.cat-n{font-size:12px;color:var(--dim);font-variant-numeric:tabular-nums}
.bar{height:5px;background:#e7eaee;border-radius:3px;margin:8px 0 9px;overflow:hidden}
.bar>i{display:block;height:100%;background:var(--ok);border-radius:3px}
.bar>i.warn{background:var(--warn)} .bar>i.bad{background:var(--bad)}
.checks{font-size:12px}
.chk{display:flex;gap:7px;align-items:flex-start;padding:3px 0;border-top:1px dotted #e7eaee}
.chk:first-child{border-top:0}
.chk .m{flex:1;min-width:0}
.chk .m span{display:block;color:var(--dim);font-size:11px;word-break:break-word}
.chk i{font-style:normal;flex:none;width:13px;text-align:center;font-weight:700}
.chk i.ok{color:var(--ok)} .chk i.warn{color:var(--warn)} .chk i.bad{color:var(--bad)}
/* ---- L3 ---- */
.toolbar{display:flex;flex-wrap:wrap;gap:14px;align-items:center;margin-bottom:12px}
.grp{display:flex;gap:5px}
.grp button{padding:5px 11px;font-size:13px}
.tabhint{color:var(--dim);font-size:12px}
.tbl-wrap{overflow-x:auto}
table{border-collapse:collapse;width:100%;font-size:12.5px}
th,td{padding:5px 8px;text-align:left;border-bottom:1px solid #eef0f3;white-space:nowrap}
th{font-weight:600;color:var(--dim);font-size:11.5px;text-transform:uppercase;letter-spacing:.3px;
position:sticky;top:0;background:#fff}
td.num,th.num{text-align:right;font-variant-numeric:tabular-nums}
td.mid,th.mid{text-align:center}
tr:hover td{background:#fafbfc}
.mid-id{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px}
th[data-k]{cursor:pointer;user-select:none;white-space:nowrap}
th[data-k]:hover{color:var(--fg,#1f2937)}
th[data-k] .ar{display:inline-block;width:10px;margin-left:2px;opacity:.35}
th.sorted .ar{opacity:1}
.pids{margin-left:6px;font-size:11px;color:var(--dim)}
.pids b{font-weight:600;color:#2a5c9e}
.self-tag{margin-left:6px;font-size:11px;padding:0 5px;border-radius:3px;background:#fdf1dd;color:#96591a}
.cmd{display:inline-block;margin:1px 4px 1px 0;padding:1px 6px;border-radius:4px;background:#f0f2f5;
font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11.5px;cursor:copy;border:0}
.cmd:hover{background:#e3e7ee}
h3.procs-h{margin:18px 0 6px;font-size:13px;font-weight:600}
h3.cmp-h{margin:14px 0 6px;font-size:14px;font-weight:600}
h2 .fold{appearance:none;border:1px solid var(--line);background:#fff;border-radius:4px;cursor:pointer;
font:inherit;font-size:11.5px;color:var(--dim);padding:1px 8px;margin-left:10px;font-weight:400}
h2 .fold:hover{color:#1d4ed8;border-color:#1d4ed8}
table[data-sid] thead th{cursor:pointer;user-select:none;white-space:nowrap}
table[data-sid] thead th:hover{color:var(--fg,#1f2937)}
table[data-sid] thead th .ar{display:inline-block;width:10px;margin-left:2px;opacity:.35;font-weight:400}
table[data-sid] thead th.sorted .ar{opacity:1}
.chartgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(330px,1fr));gap:14px}
.chartbox{border:1px solid var(--line);border-radius:6px;padding:8px 10px 4px;background:#fff}
.chartbox h4{margin:0 0 2px;font-size:12.5px;font-weight:600;display:flex;justify-content:space-between;align-items:baseline}
.chartbox h4 b{font-size:13px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
.chartbox svg{display:block;width:100%;height:auto}
.chartbox .ax{font-size:9.5px;fill:var(--dim)}
.chartbox .gl{stroke:var(--line);stroke-width:1}
.chartbox .ln{fill:none;stroke:#1d4ed8;stroke-width:1.5;stroke-linejoin:round}
.chartbox .ar{fill:#1d4ed8;opacity:.07}
.chartbox .hv{stroke:#94a3b8;stroke-width:1;stroke-dasharray:2 2}
.chartbox .flat{font-size:10px;fill:var(--dim)}
.chartcap{font-size:10.5px;color:var(--dim);margin-top:2px;min-height:14px}
.chartbox{cursor:zoom-in}
/* ---- chart modal ---- */
#cmask{position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:900;display:flex;align-items:center;justify-content:center}#cmask[hidden]{display:none!important}
#cmodal{background:#fff;border-radius:10px;padding:20px 22px 16px;width:min(860px,96vw);max-height:90vh;overflow:auto;position:relative;box-shadow:0 8px 32px rgba(0,0,0,.28)}
#cmodal h3{font-size:16px;font-weight:650;margin:0 0 4px;display:flex;justify-content:space-between;align-items:center}
#cmodal h3 b{font-size:22px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-weight:700;color:var(--ink)}
/* 不要 preserveAspectRatio="none":viewBox 被拉成别的比例时,坐标轴数字会跟着纵向拉伸变形 */
#cmodal svg{display:block;width:100%;height:auto;margin:8px 0 4px}
#cmodal #cmwins{margin:2px 0 0}
#cmstats{display:flex;flex-wrap:wrap;gap:0 22px;margin:8px 0 2px;padding:8px 10px;
border:1px solid var(--line);border-radius:6px;background:#fafbfc}
#cmstats div{font-size:12px;color:var(--dim);white-space:nowrap}
#cmstats div b{display:block;font-size:14px;color:var(--ink);font-weight:650;
font-family:ui-monospace,SFMono-Regular,Menlo,monospace;margin-top:1px}
.cmtools{display:flex;align-items:center;gap:8px;margin-top:10px;flex-wrap:wrap}
.cmbtn{appearance:none;border:1px solid var(--line);background:#fff;border-radius:4px;cursor:pointer;
font:inherit;font-size:12px;padding:3px 10px;color:var(--ink)}
.cmbtn:hover{border-color:#1d4ed8;color:#1d4ed8}
.cmbtn.on{background:#1d4ed8;border-color:#1d4ed8;color:#fff}
#cmnote{font-size:11.5px;color:var(--dim)}
#cmtable{max-height:240px;overflow:auto;margin-top:10px;border:1px solid var(--line);border-radius:6px}
#cmtable table{width:100%;border-collapse:collapse;font-size:12px}
#cmtable th{position:sticky;top:0;background:#f7f8fa;text-align:left;padding:5px 10px;
border-bottom:1px solid var(--line);font-weight:600}
#cmtable td{padding:4px 10px;border-bottom:1px solid #f0f2f5;
font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
#cmtable td.t{font-family:inherit;color:var(--dim)}
#cmtable tr.gap td{color:var(--dim);font-style:italic}
#cmsvg .cross{stroke:#94a3b8;stroke-width:1;stroke-dasharray:3 3}
#cmsvg .dot{fill:#1d4ed8}
#cmodal .chartcap{font-size:12px;min-height:18px}
#cmodal .mclose{position:absolute;top:12px;right:14px;font-size:20px;color:var(--dim);background:none;border:none;cursor:pointer;line-height:1;padding:2px 6px}
#cmodal .mclose:hover{color:var(--ink)}
.panetabs{display:flex;gap:2px;margin:18px 0 4px;border-bottom:1px solid var(--line)}
.panetabs button{appearance:none;border:0;background:none;cursor:pointer;font:inherit;font-size:13.5px;
padding:8px 14px;color:var(--dim);border-bottom:2px solid transparent;margin-bottom:-1px}
.panetabs button:hover{color:var(--fg,#1f2937)}
.panetabs button.on{color:#1d4ed8;border-bottom-color:#1d4ed8;font-weight:600}
table.cmp td:first-child{white-space:nowrap}
.cmp-now{font-weight:600}
.chg{font-size:11px;margin-left:4px;color:var(--dim);font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
.chg.bad{color:#c0392b}.chg.good{color:#1e8449}
.na-dash{color:var(--dim)}
.culp-d{color:var(--dim);font-size:12.5px;margin-left:6px}
tr.gone td{color:var(--dim)}
tr.gone td:first-child{text-decoration:line-through}
.st-yes{color:#1e8449;font-weight:600}
.st-no{color:#c0392b;font-weight:600}
.st-unk{color:var(--dim)}
.st-re{color:#b9770e;font-weight:600;font-size:11.5px}
.svc-port{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11.5px;color:var(--dim)}
.svc-kind{font-size:11px;color:var(--dim);margin-left:5px}
h3.procs-h span{font-weight:400;color:var(--dim);margin-left:8px;font-size:12px}
.z{display:inline-block;min-width:38px;padding:1px 5px;border-radius:4px;font-variant-numeric:tabular-nums;
font-size:11.5px;font-weight:600}
.z.q0{background:#f0f2f5;color:#7b8493}
.z.q1{background:#fdf1dd;color:#96591a} .z.q2{background:#fadfc7;color:#8a4413}
.z.q3{background:#f6cdc9;color:#8f2723} .z.q4{background:#e8564f;color:#fff}
.z.neg1{background:#e6eefa;color:#2a5c9e} .z.neg2{background:#cfe0f6;color:#1f4c88}
.z.na{background:repeating-linear-gradient(135deg,#f2f4f7 0 4px,#e9ecf1 4px 8px);color:#a7aeb9}
/* ---- L4 ---- */
.item{border:1px solid var(--line);border-left-width:3px;border-radius:6px;padding:10px 12px;margin-bottom:9px;background:#fcfcfd}
.item.l2{border-left-color:var(--bad);background:#fffafa}
.item.l1{border-left-color:var(--warn);background:#fffdf7}
.item.l0{border-left-color:var(--cool)}
.item-h{display:flex;gap:9px;align-items:baseline;flex-wrap:wrap}
.item-h b{font-size:13.5px}
.item-h .cat-tag{font-size:11px;color:var(--dim);border:1px solid var(--line);border-radius:4px;padding:0 5px}
.item p{font-size:12.5px;margin-top:5px}
.item p.kv{color:var(--dim)}
.item p.kv b{color:var(--ink);font-weight:600}
.item details{margin-top:6px;font-size:11.5px;color:var(--dim)}
.item summary{cursor:pointer;color:var(--accent)}
.item details ul{margin:5px 0 0 16px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace}
.notes{font-size:12px;color:var(--dim);margin-top:9px;padding-top:8px;border-top:1px dotted var(--line)}
/* ---- footer ---- */
.hgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(178px,1fr));gap:8px}
.hcell{border:1px solid var(--line);border-radius:6px;padding:7px 10px;background:#fcfcfd}
.hcell span{display:block;font-size:11px;color:var(--dim)}
.hcell b{font-size:14px;font-variant-numeric:tabular-nums}
footer{color:var(--dim);font-size:11.5px;text-align:center;padding:6px 0 14px}
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>nodedata<small id="host">—</small></h1>
<div class="hdr-right">
<span id="overall" class="badge idle">加载中</span>
<span>数据源 <b id="src">—</b></span>
<span>更新于 <b id="ts">--:--:--</b></span>
<button id="auto" class="on" onclick="toggleAuto()">自动刷新 20s</button>
<button onclick="refreshAll()">立即刷新</button>
</div>
</header>
<section>
<h2>绝对判定<span class="layer">L0 · 9 类 41 项硬阈值判定,不依赖历史</span>
<button class="fold" id="l0fold" onclick="toggleL0()">折叠</button></h2>
<div class="sub"><span id="l0sum">—</span>
<label class="only"><input type="checkbox" id="l0only"> 只看异常项</label></div>
<div id="l0body">
<div class="sub baseline">
<span id="blinfo">累计型计数器(conntrack 丢弃、listen 溢出、OOM、内核 taint)只判增量。</span>
<button id="blset" onclick="setBaseline()">把当前状态设为基线</button>
<button id="blclear" onclick="clearBaseline()" hidden>清除基线</button>
</div>
<div id="l0err"></div>
<div><div class="cats" id="cats"><div class="empty">加载中…</div></div></div>
</div>
</section>
<div class="panetabs" id="panetabs">
<button class="on" data-pane="key">关键指标</button>
<button data-pane="os">OS 指标</button>
<button data-pane="dev">整体偏离度和占用</button>
<button data-pane="svc">服务对比</button>
</div>
<div class="pane" data-pane="key">
<section>
<h2>关键指标<span class="layer">每类最关键的几项,随时间的变化曲线</span></h2>
<div class="sub">曲线看的是<b>形状</b>:阶跃(某个时刻突然换了水平)、爬坡(持续上涨,多是泄漏)、
周期尖峰(跑批、定时任务)—— 三种形状对应完全不同的排查方向。断开处 = 那段没有数据。
要看具体数值和完整采集项,去"OS 指标"。</div>
<div class="toolbar">
<div class="grp" id="kwins">
<button data-kwin="1h">1h</button>
<button data-kwin="6h" class="on">6h</button>
<button data-kwin="24h">24h</button>
<button data-kwin="7d">7d</button>
<button data-kwin="14d">14d</button>
</div>
<span class="tabhint">鼠标悬停看具体时刻的值</span>
</div>
<div id="keycharts"><p class="empty">加载中…</p></div>
</section>
<section>
<h2>诊断链<span class="layer">L4 · 由 L0 判定与 L3 偏离推导,不是模板文案</span></h2>
<div class="sub" id="l4sum">—</div>
<div id="l4err"></div>
<div id="items"></div>
<div class="notes" id="l4notes"></div>
<h3 class="procs-h">事故留证<span>出现带责任方的结论时自动保存现场;同一责任方 30 分钟内只留一次。</span>
<button class="cmd" style="cursor:pointer" onclick="captureNow(this)">立即留证</button></h3>
<div class="tbl-wrap" id="incidents"><p class="empty">加载中…</p></div>
</section>
</div>
<div class="pane" data-pane="os" hidden>
<section>
<h2>OS 指标<span class="layer">CPU / 内存 / 磁盘 / 网络 / 套接字 / 进程 —— 全部采集项</span></h2>
<div class="sub">含逐块盘、逐网卡、PSI、包速率、上下文切换等。列的含义与"关键指标"相同。</div>
<div id="cmpall"><p class="empty">加载中…</p></div>
</section>
</div>
<div class="pane" data-pane="dev" hidden>
<section>
<h2>整体偏离度和占用<span class="layer">L3 · 10 个 lag 的 z-score,以及此刻谁在用资源</span></h2>
<div class="sub">Δ = 当前值 − 一档之前的值;z = (Δ − 同时段 Δ 的历史中位数) / 稳健尺度(MAD、四分位差、十分位差取大者),按小时分桶,样本少时向 0 收缩,截断到 ±6。减中位数这一步把"平时就在涨"排除在外,所以累计计数器、page cache 这类指标不会恒定顶格。斜纹格 = 该 lag 的历史还没攒够(少于 8 个同时段样本)。</div>
<div class="toolbar">
<div class="grp" id="tabs">
<button class="on" data-tab="acute">急性</button>
<button data-tab="degrade">劣化</button>
<button data-tab="absolute">绝对值</button>
</div>
<div class="grp" id="wins">
<button data-win="1h" class="on">1h</button>
<button data-win="6h">6h</button>
<button data-win="24h">24h</button>
<button data-win="7d">7d</button>
<button data-win="14d">14d</button>
</div>
<span class="tabhint" id="tabhint"></span>
</div>
<div id="l3err"></div>
<div class="tbl-wrap"><table id="l3tbl"><tbody><tr><td class="empty">加载中…</td></tr></tbody></table></div>
<h3 class="procs-h">此刻谁在用资源<span id="procsub">CPU 前 10 ∪ 读写前 10 ∪ RSS 增长前 5 ∪ RSS 最大前 5 ∪ D 状态 ∪ nodedata 自身。CPU 以"占一个核的百分比"计,读写为块设备口径。点命令即复制。</span></h3>
<div class="tbl-wrap"><table id="procstbl" data-sid="procs"><tbody><tr><td class="empty">加载中…</td></tr></tbody></table></div>
</section>
</div>
<div class="pane" data-pane="svc" hidden>
<section>
<h2>服务对比<span class="layer">这台机器上跑着什么,各自什么时候起来的,以前在不在</span></h2>
<div class="sub">只读 /proc 识别:可执行文件名、监听端口、systemd unit 三路交叉,不连服务、不读配置、不执行任何命令。
<b>✓</b> = 那时在;<b>—</b> = 那时确实不在;<b>?</b> = nodedata 当时没在跑(不知道,不等于不在);
<b>重启</b> = 那时在,之后换过一次。消失的服务留在表里标灰,不会凭空不见。</div>
<div class="tbl-wrap" id="svctbl"><p class="empty">加载中…</p></div>
</section>
</div>
<div id="cmask" hidden>
<div id="cmodal">
<button class="mclose" id="cclose" aria-label="关闭">✕</button>
<h3 id="cmtitle">—<b id="cmval">—</b></h3>
<div id="cmsubval" style="font-size:13px;color:var(--dim);margin-bottom:6px"></div>
<div class="grp" id="cmwins">
<button data-kwin="1h">1h</button><button data-kwin="6h">6h</button>
<button data-kwin="24h">24h</button><button data-kwin="7d">7d</button><button data-kwin="14d">14d</button>
</div>
<svg id="cmsvg"></svg>
<div class="chartcap" id="cmcap">把鼠标移到图上读某一时刻的值</div>
<div id="cmstats"></div>
<div class="cmtools">
<button id="cmall" class="cmbtn">显示全部点</button>
<button id="cmcsv" class="cmbtn">下载 CSV</button>
<button id="cmcopy" class="cmbtn">复制到剪贴板</button>
<span id="cmnote"></span>
</div>
<div id="cmtable"></div>
</div>
</div>
<footer>nodedata <span id="ver">—</span> · 单静态二进制 · L0 巡检 / L1 采集 / L2 转储 / L3 偏离 / L4 诊断</footer>
</div>
<script>
"use strict";
var state={pane:"key",kwin:"6h",cmAll:false,l0fold:false,tab:"acute",win:"1h",heat:null,timer:null,auto:true,l0cats:null,sort:{k:"peak",d:-1}};
var ACUTE=[0,1,2,3]; // L1..L4 : 300s .. 2400s
var DEGRADE=[4,5,6,7,8,9]; // L5..L10 : 5400s .. 7d
var HINT={acute:"只看 L1–L4(5 分钟 ~ 40 分钟),突发抖动在这里最先出现。",
degrade:"只看 L5–L10(1.5 小时 ~ 7 天),缓慢劣化在这里才显形。L9 起需要 ≥2 天历史、L10 需要 ≥14 天;历史落盘,重启不丢。",
absolute:"不做任何归一化,直接给当前原始读数。"};
function esc(s){return String(s==null?"":s).replace(/[&<>"']/g,function(c){
return {"&":"&","<":"<",">":">",'"':""","'":"'"}[c];});}
function showErr(id,url,msg){
document.getElementById(id).innerHTML='<div class="err">'+esc(url)+' → '+esc(msg)+'</div>';}
function clearErr(id){document.getElementById(id).innerHTML="";}
// 拿 JSON,同时把 HTTP 码和响应体长度带进错误信息,方便定位。
async function getJSON(url){
var r=await fetch(url,{cache:"no-store"});
var body=await r.text();
if(!r.ok) throw new Error("HTTP "+r.status+" "+r.statusText+"("+body.length+" 字节)");
if(body.length===0) throw new Error("HTTP 200 但响应体是空的(转储可能正在写入)");
var j;
try{ j=JSON.parse(body); }
catch(e){ throw new Error("HTTP 200 但不是合法 JSON:"+e.message+"|开头 60 字节:"+body.slice(0,60)); }
return {json:j,src:r.headers.get("X-Nodedata-Source")||""};
}
function fmt(v,unit){
if(v==null||!isFinite(v)) return "—";
if(unit==="bytes"){
var u=["B","KiB","MiB","GiB","TiB"],i=0,x=Math.abs(v);
while(x>=1024&&i<u.length-1){x/=1024;i++;}
return (v<0?"-":"")+x.toFixed(x<10?2:1)+" "+u[i];
}
if(unit==="bytes/s") return fmt(v,"bytes")+"/s";
if(unit==="/s") return (Math.round(v*100)/100)+" /s";
if(unit==="percent") return v.toFixed(2)+" %";
if(unit==="ms") return v.toFixed(2)+" ms";
if(Math.abs(v)>=1e6) return (v/1e6).toFixed(2)+"M";
if(Math.abs(v)>=1000)return v.toFixed(0);
return (Math.round(v*100)/100).toString();
}
// z 存成 int8(z×20),null = 该 lag 未就绪。
function zval(p,i){var a=p&&p.z;if(!a)return null;var v=a[i];return v==null?null:v/20;}
function zclass(z){
if(z==null) return "na";
var a=Math.abs(z);
if(a<2) return "q0";
if(z<0) return a<4?"neg1":"neg2";
if(a<3) return "q1";
if(a<4.5)return "q2";
if(a<6) return "q3";
return "q4";
}
function zcell(z,ready){
if(z==null) return '<span class="z na" title="'+(ready?"该指标此 lag 无基线":"历史不足")+'">—</span>';
return '<span class="z '+zclass(z)+'">'+(z>0?"+":"")+z.toFixed(2)+'</span>';
}
/* ================= L0 ================= */
async function loadL0(){
var d;
try{ d=(await getJSON("/api/check")).json; clearErr("l0err"); }
catch(e){ showErr("l0err","/api/check",e.message);
document.getElementById("cats").innerHTML=""; return; }
var cats=d.categories||[],P=0,W=0,F=0;
cats.forEach(function(c){(c.checks||[]).forEach(function(k){
if(k.level>=2)F++; else if(k.level===1)W++; else P++;});});
var total=P+W+F;
document.getElementById("l0sum").textContent=
cats.length+" 类 / "+total+" 项:通过 "+P+",告警 "+W+",失败 "+F+"(exit_code "+(d.exit_code==null?"—":d.exit_code)+")";
var b=document.getElementById("overall");
b.className="badge "+(F>0?"bad":W>0?"warn":"ok");
b.textContent=F>0?("L0 失败 "+F):W>0?("L0 告警 "+W):"L0 全通过";
if(d.timestamp) document.getElementById("ts").textContent=new Date(d.timestamp).toLocaleTimeString();
if(!cats.length){document.getElementById("cats").innerHTML='<div class="empty">巡检结果还没生成。</div>';return;}
state.l0cats=cats;
renderL0Cards(cats);
}
// L0 卡片:异常排前,全部项直接展开,不折叠。
function renderL0Cards(cats){
var only=document.getElementById("l0only").checked;
var list=only?cats.filter(function(c){return (c.level||0)>0;}):cats;
if(!list.length){
document.getElementById("cats").innerHTML=
'<div class="empty">'+(only?"没有异常项。":"巡检结果还没生成。")+'</div>';
return;
}
document.getElementById("cats").innerHTML=list.map(function(c){
var ck=(c.checks||[]).slice().sort(function(a,b){return (b.level||0)-(a.level||0);});
if(only) ck=ck.filter(function(k){return (k.level||0)>0;});
var bad=0,wrn=0,pass=0;
(c.checks||[]).forEach(function(k){
if(k.level>=2) bad++; else if(k.level===1) wrn++; else pass++;});
var cls=bad?"hasfail":wrn?"haswarn":"";
var total=(c.checks||[]).length;
var fill=total?(pass/total*100):0;
// 全部判定一律展开显示,异常排在前面,不做折叠。
var rows=ck.map(function(k){
var lv=k.level>=2?"bad":k.level===1?"warn":"ok";
var mark=k.level>=2?"✕":k.level===1?"!":"✓";
return '<div class="chk"><i class="'+lv+'">'+mark+'</i><div class="m">'+
esc(k.id)+' '+esc(k.name)+'<span>'+esc(k.message||"")+'</span></div></div>';
}).join("");
return '<div class="cat '+cls+'">'+
'<div class="cat-h"><b>'+esc(c.name)+'</b><span class="cat-n">'+pass+'/'+total+'</span></div>'+
'<div class="bar"><i class="'+(bad?"bad":wrn?"warn":"")+'" style="width:'+fill.toFixed(0)+'%"></i></div>'+
'<div class="checks">'+rows+'</div></div>';
}).join("");
}
/* ================= L3 ================= */
async function loadL3(){
var r;
try{ r=await getJSON("/data/"+state.win+".json"); clearErr("l3err"); }
catch(e){ showErr("l3err","/data/"+state.win+".json",e.message);
state.heat=null;
document.getElementById("l3tbl").innerHTML="<tbody><tr><td class=\"empty\">无数据</td></tr></tbody>";
return; }
state.heat=r.json;
// 转储默认 30s 一轮,页面 5s 一刷 —— 把快照年龄显示出来,避免把旧快照当实时。
var age="";
if(r.json.generated_at){
var a=Math.max(0,Math.round(Date.now()/1000-r.json.generated_at));
age=" ("+(a<90?a+"s":Math.round(a/60)+"min")+" 前)";
}
document.getElementById("src").textContent=(r.src||"—")+age;
if(r.json.host) document.getElementById("host").textContent="· "+r.json.host;
renderL3();
}
// ---- 排序:点表头切换;数值列先降序,文本列先升序;z 列按 |z| 排;空值永远垫底。
var TEXTKEYS={id:1,dom:1,unit:1};
function sortVal(m,k){
if(k==="id")return m.id; if(k==="dom")return m.dom; if(k==="unit")return m.unit;
if(k==="v")return m.pt?m.pt.v:null;
if(k==="peak")return m.peak==null?null:Math.abs(m.peak);
if(k==="onset"){var o=m.pt&&m.pt.onset_lag;return o?parseInt(o.slice(1),10):null;}
if(k==="breadth")return m.pt?(m.pt.breadth||0):null;
if(k==="n")return m.n; if(k==="pri")return m.pri;
if(k.charAt(0)==="z"){var z=zval(m.pt,+k.slice(1));return z==null?null:Math.abs(z);}
return null;
}
function sortRows(ms,keys,dflt){
var st=state.sort; if(keys.indexOf(st.k)<0) st={k:dflt,d:TEXTKEYS[dflt]?1:-1};
ms.sort(function(a,b){
var A=sortVal(a,st.k),B=sortVal(b,st.k);
if(A==null&&B==null) return a.id<b.id?-1:1;
if(A==null) return 1; if(B==null) return -1;
if(A!==B) return (A<B?-1:1)*st.d;
return a.id<b.id?-1:1;
});
return st;
}
function th(k,label,cls,st,title){
var on=st.k===k;
return '<th data-k="'+k+'" class="'+(cls||"")+(on?" sorted":"")+'"'+(title?' title="'+esc(title)+'"':"")+'>'+
esc(label)+'<span class="ar">'+(on?(st.d<0?"▼":"▲"):"↕")+'</span></th>';
}
document.getElementById("l3tbl").addEventListener("click",function(e){
var h=e.target.closest("th[data-k]"); if(!h) return;
var k=h.getAttribute("data-k");
if(state.sort.k===k) state.sort.d=-state.sort.d;
else state.sort={k:k,d:TEXTKEYS[k]?1:-1};
renderL3();
});
// proc.cpu.<key> 行 → 快照里同名的 PID
function pidsFor(id){
var h=state.heat; if(!h||!h.procs||id.indexOf("proc.cpu.")!==0) return "";
var key=id.slice(9); if(key==="__others__") return "";
var ps=h.procs.filter(function(p){return p.key===key;});
if(!ps.length) return '<span class="pids">此刻不在前 10</span>';
return '<span class="pids">'+ps.slice(0,3).map(function(p){
return 'PID <b>'+p.pid+'</b> '+p.cpu.toFixed(1)+'%';}).join(",")+(ps.length>3?" …":"")+'</span>';
}
function renderL3(){
var h=state.heat,el=document.getElementById("l3tbl");
document.getElementById("tabhint").textContent=HINT[state.tab];
renderProcs();
if(!h){el.innerHTML="<tbody><tr><td class=\"empty\">无数据</td></tr></tbody>";return;}
var ms=(h.metrics||[]).map(function(m){
var last=(m.points&&m.points.length)?m.points[m.points.length-1]:null;
return {id:m.metric_id,dom:m.domain,unit:m.unit,pri:m.is_primary,pt:last,n:(m.points||[]).length};
});
if(!ms.length){el.innerHTML="<tbody><tr><td class=\"empty\">这个窗口里没有指标。</td></tr></tbody>";return;}
var lags=h.lags||[],ready=h.lag_ready||[];
if(state.tab==="absolute") return renderAbs(el,ms);
var idx=(state.tab==="acute"?ACUTE:DEGRADE).filter(function(i){return i<lags.length;});
ms.forEach(function(m){
var pk=null;
idx.forEach(function(i){var z=zval(m.pt,i);
if(z!=null&&(pk==null||Math.abs(z)>Math.abs(pk)))pk=z;});
m.peak=pk;
});
var keys=["id","dom","v","peak","onset","breadth"].concat(idx.map(function(i){return "z"+i;}));
var st=sortRows(ms,keys,"peak");
var head='<tr>'+th("id","指标","",st)+th("dom","域","",st)+th("v","当前值","num",st)+th("peak","峰值 z","num",st,"按 |z| 排序")+
idx.map(function(i){
var s=h.lag_seconds?h.lag_seconds[i]:0;
return th("z"+i,lags[i],"mid",st,s+"s,按 |z| 排序");}).join("")+
th("onset","起始 lag","mid",st)+th("breadth","广度","num",st)+'</tr>';
var rows=ms.map(function(m){
var pk=m.peak==null?'<span class="z na">—</span>':
'<span class="z '+zclass(m.peak)+'">'+(m.peak>0?"+":"")+m.peak.toFixed(2)+'</span>';
return '<tr><td class="mid-id">'+esc(m.id)+pidsFor(m.id)+'</td><td>'+esc(m.dom)+'</td>'+
'<td class="num">'+esc(fmt(m.pt?m.pt.v:null,m.unit))+'</td><td class="num">'+pk+'</td>'+
idx.map(function(i){return '<td class="mid">'+zcell(zval(m.pt,i),ready[i])+'</td>';}).join("")+
'<td class="mid">'+esc((m.pt&&m.pt.onset_lag)||"—")+'</td>'+
'<td class="num">'+((m.pt&&m.pt.breadth)||0)+'</td></tr>';
}).join("");
el.innerHTML="<thead>"+head+"</thead><tbody>"+rows+"</tbody>";
}
function renderAbs(el,ms){
var st=sortRows(ms,["id","dom","unit","v","n","pri"],"pri");
var head='<tr>'+th("id","指标","",st)+th("dom","域","",st)+th("unit","单位","",st)+th("v","当前值","num",st)+
th("n","窗口内样本","num",st)+th("pri","主视图","mid",st)+'</tr>';
el.innerHTML="<thead>"+head+"</thead><tbody>"+ms.map(function(m){
return '<tr><td class="mid-id">'+esc(m.id)+pidsFor(m.id)+'</td><td>'+esc(m.dom)+'</td>'+
'<td>'+esc(m.unit)+'</td><td class="num">'+esc(fmt(m.pt?m.pt.v:null,m.unit))+'</td>'+
'<td class="num">'+m.n+'</td><td class="mid">'+(m.pri?"●":"")+'</td></tr>';
}).join("")+"</tbody>";
}
// ---- 进程快照:把整机 CPU 偏离落到 PID,并直接给出下一步命令。
function renderProcs(){
var el=document.getElementById("procstbl"),h=state.heat;
var ps=(h&&h.procs)||[];
if(!ps.length){el.innerHTML='<tbody><tr><td class="empty">还没有进程快照(启动后第二轮采集才有增量)。</td></tr></tbody>';return;}
var cmds=function(p){
var c=["top -H -p "+p.pid,"pidstat -u -d -p "+p.pid+" 1 5","cat /proc/"+p.pid+"/status"];
if(p.state==="D") c.push("cat /proc/"+p.pid+"/stack");
return c;
};
var growth=function(p){
if(!p.growth_span) return "—";
var g=p.rss_growth, s=(g>=0?"+":"-")+fmt(Math.abs(g),"bytes");
return s+' <span class="pids">/'+Math.round(p.growth_span/60)+'分</span>';
};
el.innerHTML='<thead><tr><th class="num">PID</th><th>进程</th><th class="mid">状态</th><th class="num">CPU</th>'+
'<th class="num">读</th><th class="num">写</th><th class="num">RSS</th><th class="num">RSS 增长</th><th class="num">主缺页</th><th>下一步</th></tr></thead><tbody>'+
ps.map(function(p){
return '<tr><td class="num" data-sort="'+p.pid+'">'+p.pid+'</td><td class="mid-id">'+esc(p.comm)+
(p.self?'<span class="self-tag">nodedata 自身</span>':"")+'</td>'+
'<td class="mid">'+esc(p.state||"")+'</td>'+
'<td class="num" data-sort="'+p.cpu+'">'+p.cpu.toFixed(1)+' %</td>'+
'<td class="num" data-sort="'+(p.read_bps||"")+'">'+(p.read_bps?fmt(p.read_bps,"bytes/s"):"—")+'</td>'+
'<td class="num" data-sort="'+(p.write_bps||"")+'">'+(p.write_bps?fmt(p.write_bps,"bytes/s"):"—")+'</td>'+
'<td class="num" data-sort="'+p.rss+'">'+esc(fmt(p.rss,"bytes"))+'</td>'+
'<td class="num" data-sort="'+(p.growth_span?p.rss_growth:"")+'">'+growth(p)+'</td>'+
'<td class="num" data-sort="'+(p.majflt||"")+'">'+(p.majflt?p.majflt.toFixed(0)+" /s":"—")+'</td>'+
'<td>'+cmdButtons(cmds(p))+'</td></tr>';}).join("")+'</tbody>';
applySort(el);
}
/* ================= L4 ================= */
function cmdButtons(cs){
return cs.map(function(c){return '<button class="cmd" data-cmd="'+esc(c)+'">'+esc(c)+'</button>';}).join("");
}
function culpritsHTML(cs){
if(!cs||!cs.length) return "";
return cs.map(function(c,i){
var who=c.pid?esc(c.name)+' <span class="pids">PID <b>'+c.pid+'</b></span>':esc(c.name);
if(c.service&&c.service!==c.name) who=esc(c.service)+' <span class="pids">('+esc(c.name)+')</span>'+
(c.pid?' <span class="pids">PID <b>'+c.pid+'</b></span>':"");
if(c.self) who+='<span class="self-tag">nodedata 自身</span>';
return '<p class="kv"><b>'+(i===0?"责任方":" ")+'</b> '+who+' <span class="culp-d">'+esc(c.detail||"")+'</span></p>';
}).join("");
}
// 所有"下一步"命令按钮:点击复制(L4 卡片、进程表共用)
document.addEventListener("click",function(e){
var b=e.target.closest&&e.target.closest("button.cmd"); if(!b) return;
var c=b.getAttribute("data-cmd");
if(navigator.clipboard&&navigator.clipboard.writeText){
navigator.clipboard.writeText(c).then(function(){
var t=b.textContent; b.textContent="已复制"; setTimeout(function(){b.textContent=t;},900);
}).catch(function(){});
}
});
/* ================= 关键指标对比 ================= */
function cmpCell(r,p){
if(p==null) return '<span class="na-dash">—</span>';
var v=esc(fmt(p,r.unit)), now=r.now, ch;
if(p===0&&now===0) ch='<span class="chg">—</span>';
else if(p===0) ch='<span class="chg'+(r.bad>0?" bad":"")+'">▲ 新增</span>';
else{
var pct=(now-p)/Math.abs(p)*100;
if(Math.abs(pct)<0.5) ch='<span class="chg">—</span>';
else{
var up=pct>0, bad=(r.bad>0&&up)||(r.bad<0&&!up);
var cls=Math.abs(pct)>=20?(bad?" bad":(r.bad!==0?" good":"")):"";
var a=Math.abs(pct), txt=a>=1000?(Math.round(a/100)/10)+"k":a.toFixed(0);
ch='<span class="chg'+cls+'">'+(up?"▲":"▼")+" "+txt+'%</span>';
}
}
return v+" "+ch;
}
/* ================= 通用表格排序 =================
任何带 data-sid 的表格都可点表头排序。状态按 sid 记住,
20 秒自动刷新后排序保持——否则每次刷新都跳回原序,等于没有排序。
数值列在渲染时写 data-sort 原始值:单元格里是 "2.40 KiB/s"、"1 天 0 小时" 这类
格式化文本,按文本排会得出 "9 > 10" 这种错误顺序。*/
var SORT={};
function cellKey(tr,i){
var td=tr.children[i]; if(!td) return null;
var ds=td.getAttribute("data-sort");
if(ds!==null&&ds!==""){ var n=parseFloat(ds); return isNaN(n)?ds:n; }
var t=td.textContent.trim();
return (t===""||t==="—"||t==="?")?null:t;
}
function applySort(tb){
var sid=tb.getAttribute("data-sid"), st=SORT[sid]; if(!st) return;
var body=tb.tBodies[0]; if(!body) return;
var rows=[].slice.call(body.rows);
rows.sort(function(a,b){
var A=cellKey(a,st.i),B=cellKey(b,st.i);
if(A===null&&B===null) return 0;
if(A===null) return 1; // 空值永远垫底,不管升降
if(B===null) return -1;
if(typeof A==="number"&&typeof B==="number") return (A-B)*st.dir;
return String(A).localeCompare(String(B))*st.dir;
});
rows.forEach(function(r){body.appendChild(r);});
var ths=tb.tHead?tb.tHead.rows[0].children:[];
[].forEach.call(ths,function(th,i){
th.classList.toggle("sorted",i===st.i);
var ar=th.querySelector(".ar");
if(!ar){ ar=document.createElement("span"); ar.className="ar"; th.appendChild(ar); }
ar.textContent = i===st.i ? (st.dir<0?"▼":"▲") : "↕";
});
}
function applySortAll(){ [].forEach.call(document.querySelectorAll("table[data-sid]"),applySort); }
document.addEventListener("click",function(e){
var th=e.target.closest&&e.target.closest("table[data-sid] thead th"); if(!th) return;
var tb=th.closest("table"), sid=tb.getAttribute("data-sid");
var i=[].indexOf.call(th.parentNode.children,th);
if(SORT[sid]&&SORT[sid].i===i) SORT[sid].dir=-SORT[sid].dir;
else{
// 首次点某列:数值列先降序(先看最大的),文本列先升序
var first=null, rows=tb.tBodies[0]?tb.tBodies[0].rows:[];
for(var k=0;k<rows.length&&first===null;k++) first=cellKey(rows[k],i);
SORT[sid]={i:i,dir:(typeof first==="number")?-1:1};
}
applySort(tb);
});
function toggleL0(){
var body=document.getElementById("l0body"), btn=document.getElementById("l0fold");
state.l0fold=!state.l0fold;
body.hidden=state.l0fold;
btn.textContent=state.l0fold?"展开":"折叠";
}
/* ================= 关键指标 / OS 指标 ================= */
function cmpCell(r,p){
if(p==null) return '<span class="na-dash">—</span>';
var v=esc(fmt(p,r.unit)), now=r.now, ch;
if(p===0&&now===0) ch='<span class="chg">—</span>';
else if(p===0) ch='<span class="chg'+(r.bad>0?" bad":"")+'">▲ 新增</span>';
else{
var pct=(now-p)/Math.abs(p)*100;
if(Math.abs(pct)<0.5) ch='<span class="chg">—</span>';
else{
var up=pct>0, bad=(r.bad>0&&up)||(r.bad<0&&!up);
var cls=Math.abs(pct)>=20?(bad?" bad":(r.bad!==0?" good":"")):"";
var a=Math.abs(pct), txt=a>=1000?(Math.round(a/100)/10)+"k":a.toFixed(0);
ch='<span class="chg'+cls+'">'+(up?"▲":"▼")+" "+txt+'%</span>';
}
}
return v+" "+ch;
}
function cmpTable(sid,cols,rows){
return '<div class="tbl-wrap"><table class="cmp" data-sid="'+esc(sid)+'"><thead><tr><th>指标</th><th class="num">当前</th>'+
cols.map(function(c){return '<th class="num">'+esc(c)+'前</th>';}).join("")+'</tr></thead><tbody>'+
rows.map(function(r){
return '<tr title="'+esc(r.id)+'"><td data-sort="'+esc(r.label)+'">'+esc(r.label)+'</td>'+
'<td class="num cmp-now" data-sort="'+r.now+'">'+esc(fmt(r.now,r.unit))+'</td>'+
r.past.map(function(p){return '<td class="num" data-sort="'+(p===null?"":p)+'">'+cmpCell(r,p)+'</td>';}).join("")+'</tr>';
}).join("")+'</tbody></table></div>';
}
function cmpRender(el,d,coreOnly){
el.innerHTML=(d.groups||[]).map(function(g){
var rows=coreOnly?g.rows.filter(function(r){return r.core;}):g.rows;
if(!rows.length) return "";
return '<h3 class="cmp-h">'+esc(g.name)+'</h3>'+cmpTable("cmp:"+g.name,d.cols,rows);
}).join("")||'<p class="empty">还没有数据。</p>';
applySortAll();
}
async function loadCompare(){
var all=document.getElementById("cmpall"), d;
try{ d=(await getJSON("/api/compare")).json; }
catch(e){ all.innerHTML='<p class="empty">对比数据不可用:'+esc(e.message)+'</p>'; return; }
cmpRender(all,d,false);
}
/* ================= 关键指标曲线(内联 SVG,零依赖,离线可用) ================= */
var CH={w:600,h:120,l:44,r:8,t:10,b:18}; // viewBox 坐标系,靠 CSS 缩放
function niceStep(span){ // 取 1/2/5×10^n 里最接近 span/3 的
if(span<=0) return 1;
var p=Math.pow(10,Math.floor(Math.log10(span/3))), c=[1,2,5,10];
for(var i=0;i<c.length;i++) if(c[i]*p>=span/3) return c[i]*p;
return 10*p;
}
function chartSVG(s,from,to){
// 小图同样不拉伸:CSS 里给的是固定高度,一拉伸刻度数字就变形
return '<svg viewBox="0 0 '+CH.w+' '+CH.h+'">'+buildSVG(s,from,to,CH)+'</svg>';
}
function lastVal(s){
for(var i=s.v.length-1;i>=0;i--) if(s.v[i]!==null&&isFinite(s.v[i])) return s.v[i];
return null;
}
async function loadKeyCharts(){
var el=document.getElementById("keycharts"), d;
try{ d=(await getJSON("/api/keyseries?win="+encodeURIComponent(state.kwin))).json; }
catch(e){ el.innerHTML='<p class="empty">曲线数据不可用:'+esc(e.message)+'</p>'; return; }
if(!d.groups||!d.groups.length){ el.innerHTML='<p class="empty">还没有数据。</p>'; return; }
el.innerHTML=d.groups.map(function(g){
return '<h3 class="cmp-h">'+esc(g.name)+'</h3><div class="chartgrid">'+
g.series.map(function(s){
var lv=lastVal(s);
return '<div class="chartbox" data-id="'+esc(s.id)+'"><h4>'+esc(s.label)+
'<b>'+esc(lv===null?"—":fmt(lv,s.unit))+'</b></h4>'+chartSVG(s,d.from,d.at)+
'<div class="chartcap"></div></div>';
}).join("")+'</div>';
}).join("");
bindHover(d);
}
// 悬停读值:不引库,自己算最近的采样点
function bindHover(d){
var byId={};
d.groups.forEach(function(g){g.series.forEach(function(s){byId[s.id]=s;});});
[].forEach.call(document.querySelectorAll("#keycharts .chartbox"),function(box){
var s=byId[box.getAttribute("data-id")]; if(!s) return;
var svg=box.querySelector("svg"), cap=box.querySelector(".chartcap");
svg.addEventListener("mousemove",function(ev){
var rc=svg.getBoundingClientRect();
var frac=(ev.clientX-rc.left)/rc.width;
var inner=(CH.w-CH.l-CH.r)/CH.w, off=CH.l/CH.w;
var i=Math.round((frac-off)/inner*(s.v.length-1));
if(i<0)i=0; if(i>=s.v.length)i=s.v.length-1;
var v=s.v[i];
cap.textContent=new Date(s.ts[i]*1000).toLocaleString()+" "+(v===null||!isFinite(v)?"无数据":fmt(v,s.unit));
});
svg.addEventListener("mouseleave",function(){cap.textContent="";});
// Click to open modal
box.addEventListener("click",function(){openChartModal(s,d);});
});
}
/* ---- chart modal ---- */
var _mS=null, _mD=null;
var MCH={w:800,h:260,l:62,r:14,t:14,b:26};
function openChartModal(s,d){
_mS=s; _mD=d;
document.getElementById("cmtitle").firstChild.nodeValue=s.label+"\u3000";
var lv=lastVal(s);
document.getElementById("cmval").textContent=lv===null?"\u2014":fmt(lv,s.unit);
document.getElementById("cmsubval").textContent=s.id+(s.unit?"\uff08"+s.unit+"\uff09":"");
[].forEach.call(document.querySelectorAll("#cmwins button"),function(x){
x.classList.toggle("on",x.getAttribute("data-kwin")===state.kwin);});
state.cmAll=false;
var ab=document.getElementById("cmall");
ab.classList.remove("on"); ab.textContent="显示全部点";
document.getElementById("cmnote").textContent="共 "+s.v.length+" 个采样点";
renderModal();
document.getElementById("cmask").hidden=false;
}
function renderModal(){
var s=_mS,d=_mD,svg=document.getElementById("cmsvg");
svg.setAttribute("viewBox","0 0 "+MCH.w+" "+MCH.h);
svg.innerHTML=buildSVG(s,d.from,d.at,MCH);
document.getElementById("cmstats").innerHTML=statsHTML(s);
document.getElementById("cmtable").innerHTML=valuesHTML(s);
bindCross(svg,s);
}
// 图下的统计摘要:一眼看出这段时间的范围与中心,不用把鼠标划一遍
function statsHTML(s){
var vals=[],first=null,last=null,firstT=null,lastT=null;
for(var i=0;i<s.v.length;i++){
var v=s.v[i];
if(v===null||!isFinite(v)) continue;
vals.push(v);
if(first===null){first=v;firstT=s.ts[i];}
last=v; lastT=s.ts[i];
}
if(!vals.length) return '<div>这段时间没有数据</div>';
var sorted=vals.slice().sort(function(a,b){return a-b;});
var sum=0; vals.forEach(function(v){sum+=v;});
var q=function(p){return sorted[Math.min(sorted.length-1,Math.round((sorted.length-1)*p))];};
var chg="\u2014";
if(first!==null&&first!==0){
var pc=(last-first)/Math.abs(first)*100;
chg=(pc>=0?"+":"")+(Math.abs(pc)>=1000?(pc/1000).toFixed(1)+"k":pc.toFixed(1))+"%";
}else if(first===0&&last!==0){ chg="从 0 起"; }
var cell=function(k,v){return '<div>'+esc(k)+'<b>'+esc(v)+'</b></div>';};
return cell("最新",fmt(last,s.unit))+cell("最小",fmt(sorted[0],s.unit))+
cell("最大",fmt(sorted[sorted.length-1],s.unit))+cell("平均",fmt(sum/vals.length,s.unit))+
cell("中位数",fmt(q(0.5),s.unit))+cell("P95",fmt(q(0.95),s.unit))+
cell("区间首\u2192末",chg)+cell("有效点 / 总点",vals.length+" / "+s.v.length)+
cell("起",new Date(firstT*1000).toLocaleString())+
cell("止",new Date(lastT*1000).toLocaleString());
}
// 图下的数值表:曲线看形状,表读具体数字。最多 40 行等间隔抽样,
// 但最后一个点必须在表里——那是“现在是多少”。
function valuesHTML(s){
var idx=[];
for(var i=0;i<s.v.length;i++) idx.push(i);
var MAXROWS=state.cmAll?Infinity:40;
if(idx.length>MAXROWS){
var step=(idx.length-1)/(MAXROWS-1), picked=[];
for(var k=0;k<MAXROWS;k++) picked.push(Math.round(k*step));
picked[picked.length-1]=idx.length-1;
idx=picked;
}
// 连续的空档折叠成一行:实例刚装上时 40 行里 38 行"无数据",那张表没法看
var rows="",prev=null,gapFrom=null,gapN=0;
var flushGap=function(toTS){
if(!gapN) return;
var mins=Math.round((toTS-gapFrom)/60);
rows+='<tr class="gap"><td class="t">'+esc(new Date(gapFrom*1000).toLocaleString())+'</td>'+
'<td colspan="2">无数据('+(mins>=120?Math.round(mins/60)+" 小时":mins+" 分钟")+',共 '+gapN+' 个采样点)</td></tr>';
gapN=0; gapFrom=null;
};
for(var k=0;k<idx.length;k++){
var i=idx[k], v=s.v[i];
if(v===null||!isFinite(v)){
// 抽样模式下折叠空档(刚装上时 40 行里 38 行是空的,没法看);
// "显示全部点"就该逐点列出,否则按钮名不副实。
if(!state.cmAll){
if(gapFrom===null) gapFrom=s.ts[i];
gapN++;
continue;
}
rows+='<tr class="gap"><td class="t">'+esc(new Date(s.ts[i]*1000).toLocaleString())+
'</td><td colspan="2">无数据</td></tr>';
continue;
}
flushGap(s.ts[i]);
var delta="\u2014";
if(prev!==null&&prev!==0){
var pc=(v-prev)/Math.abs(prev)*100;
if(Math.abs(pc)>=0.5) delta=(pc>0?"\u25b2 ":"\u25bc ")+Math.abs(pc).toFixed(0)+"%";
}
prev=v;
rows+='<tr><td class="t">'+esc(new Date(s.ts[i]*1000).toLocaleString())+'</td>'+
'<td>'+esc(fmt(v,s.unit))+'</td><td>'+esc(delta)+'</td></tr>';
}
flushGap(s.ts[idx[idx.length-1]]);
if(!rows) rows='<tr class="gap"><td colspan="3">这段时间没有数据</td></tr>';
return '<table><thead><tr><th>时刻</th><th>值</th><th>较上一行</th></tr></thead><tbody>'+rows+'</tbody></table>';
}
// 悬停:十字线 + 圆点 + 读数
function bindCross(svg,s){
var cap=document.getElementById("cmcap");
svg.addEventListener("mousemove",function(ev){
var rc=svg.getBoundingClientRect();
var frac=(ev.clientX-rc.left)/rc.width;
var inner=(MCH.w-MCH.l-MCH.r)/MCH.w, off=MCH.l/MCH.w;
var i=Math.round((frac-off)/inner*(s.v.length-1));
i=Math.max(0,Math.min(s.v.length-1,i));
var v=s.v[i];
cap.textContent=new Date(s.ts[i]*1000).toLocaleString()+"\u3000"+
(v===null||!isFinite(v)?"无数据":fmt(v,s.unit));
var old=svg.querySelector(".crossg"); if(old) old.remove();
if(v===null||!isFinite(v)) return;
var px=MCH.l+(MCH.w-MCH.l-MCH.r)*(s.v.length<2?0:i/(s.v.length-1));
var py=svg._yOf?svg._yOf(v):null;
var g=document.createElementNS("http://www.w3.org/2000/svg","g");
g.setAttribute("class","crossg");
g.innerHTML='<line class="cross" x1="'+px.toFixed(1)+'" y1="'+MCH.t+'" x2="'+px.toFixed(1)+
'" y2="'+(MCH.h-MCH.b)+'"/>'+
(py===null?"":'<circle class="dot" cx="'+px.toFixed(1)+'" cy="'+py.toFixed(1)+'" r="3.5"/>');
svg.appendChild(g);
});
svg.addEventListener("mouseleave",function(){
cap.textContent="把鼠标移到图上读某一时刻的值";
var old=svg.querySelector(".crossg"); if(old) old.remove();
});
}
// 放大图里直接换时间窗口,不用关掉再回去改
document.getElementById("cmwins").addEventListener("click",async function(e){
var b=e.target.closest("button[data-kwin]"); if(!b) return;
[].forEach.call(this.querySelectorAll("button"),function(x){x.classList.toggle("on",x===b);});
var win=b.getAttribute("data-kwin");
try{
var d=(await getJSON("/api/keyseries?win="+encodeURIComponent(win))).json;
for(var gi=0;gi<d.groups.length;gi++)
for(var si=0;si<d.groups[gi].series.length;si++)
if(d.groups[gi].series[si].id===_mS.id){ _mS=d.groups[gi].series[si]; _mD=d; renderModal(); return; }
document.getElementById("cmcap").textContent="这个窗口里没有该指标的数据";
}catch(err){ document.getElementById("cmcap").textContent="切换窗口失败:"+err.message; }
});
// 原始数据:CSV 用原始时间戳与未格式化的数值,方便直接进 Excel / awk,
// 不做 "2.40 KiB/s" 这种人读格式——那种字符串再解析回数字是自找麻烦。
function seriesCSV(s){
var out="time_iso,unix_ts,value,unit,metric\n";
var q=function(x){return /[",]/.test(x)?'"'+String(x).replace(/"/g,'""')+'"':x;};
for(var i=0;i<s.v.length;i++){
var v=s.v[i];
out+=new Date(s.ts[i]*1000).toISOString()+","+s.ts[i]+","+
(v===null||!isFinite(v)?"":v)+","+q(s.unit||"")+","+q(s.id)+"\n";
}
return out;
}
function csvName(s){
return (s.id.replace(/[^A-Za-z0-9._@+-]+/g,"_")+"_"+state.kwin+".csv").replace(/^_+/,"");
}
document.getElementById("cmall").addEventListener("click",function(){
state.cmAll=!state.cmAll;
this.classList.toggle("on",state.cmAll);
this.textContent=state.cmAll?"只看 40 行":"显示全部点";
document.getElementById("cmtable").innerHTML=valuesHTML(_mS);
});
document.getElementById("cmcsv").addEventListener("click",function(){
var blob=new Blob([seriesCSV(_mS)],{type:"text/csv;charset=utf-8"});
var a=document.createElement("a");
a.href=URL.createObjectURL(blob); a.download=csvName(_mS);
document.body.appendChild(a); a.click();
setTimeout(function(){URL.revokeObjectURL(a.href); a.remove();},0);
document.getElementById("cmnote").textContent="已导出 "+_mS.v.length+" 个点";
});
document.getElementById("cmcopy").addEventListener("click",function(){
var txt=seriesCSV(_mS), note=document.getElementById("cmnote");
if(navigator.clipboard&&navigator.clipboard.writeText){
navigator.clipboard.writeText(txt).then(function(){
note.textContent="已复制 "+_mS.v.length+" 行";
}).catch(function(){ note.textContent="复制失败,请用下载 CSV"; });
}else note.textContent="这个浏览器不支持剪贴板,请用下载 CSV";
});
function closeChartModal(){document.getElementById("cmask").hidden=true;}
document.getElementById("cclose").addEventListener("click",closeChartModal);
document.getElementById("cmask").addEventListener("click",function(e){
if(e.target===this) closeChartModal();
});
document.addEventListener("keydown",function(e){if(e.key==="Escape") closeChartModal();});
// Refactor chartSVG to accept CH param (keep backward compat)
function buildSVG(s,from,to,ch){
if(!ch) ch=CH;
var n=s.v.length, vals=s.v.filter(function(v){return v!==null&&isFinite(v);});
if(!vals.length) return '<text class="flat" x="'+ch.l+'" y="'+(ch.h/2)+'">这段时间没有数据</text>';
var mn=Math.min.apply(null,vals), mx=Math.max.apply(null,vals);
if(mx===mn){ mx=mn+(Math.abs(mn)||1)*0.05; mn=mn-(Math.abs(mn)||1)*0.05; }
else { var pad=(mx-mn)*0.12; mx+=pad; mn-=pad; }
if(mn>0&&mn<(mx-mn)) mn=0;
var x=function(i){return ch.l+(ch.w-ch.l-ch.r)*(n<2?0:i/(n-1));},
y=function(v){return ch.t+(ch.h-ch.t-ch.b)*(1-(v-mn)/(mx-mn));};
var segs=[],cur=[];
for(var i=0;i<n;i++){
var v=s.v[i];
if(v===null||!isFinite(v)){ if(cur.length)segs.push(cur); cur=[]; continue; }
cur.push(x(i).toFixed(1)+","+y(v).toFixed(1));
}
if(cur.length)segs.push(cur);
var line=segs.map(function(g){return '<path class="ln" d="M'+g.join("L")+'"/>';}).join("");
var area=segs.filter(function(g){return g.length>1;}).map(function(g){
var a=g[0].split(",")[0], b=g[g.length-1].split(",")[0], base=y(Math.max(mn,0)).toFixed(1);
return '<path class="ar" d="M'+a+','+base+'L'+g.join("L")+'L'+b+','+base+'Z"/>';}).join("");
var st=niceStep(mx-mn), gl="";
for(var g0=Math.ceil(mn/st)*st; g0<=mx; g0+=st){
var yy=y(g0).toFixed(1);
gl+='<line class="gl" x1="'+ch.l+'" y1="'+yy+'" x2="'+(ch.w-ch.r)+'" y2="'+yy+'"/>'+
'<text class="ax" x="'+(ch.l-4)+'" y="'+(+yy+3)+'" text-anchor="end">'+esc(fmt(g0,s.unit))+'</text>';
}
// 时间轴:小图两端各一个标签就够;放大图给 5 个刻度,否则中间任何位置都读不出时刻
var hm=function(d){