-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathindex.html
More file actions
1630 lines (1612 loc) · 125 KB
/
Copy pathindex.html
File metadata and controls
1630 lines (1612 loc) · 125 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="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sentinel Node Scorecard</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=Sora:wght@300;400;600;700;800&family=Space+Grotesk:wght@400;500;600;700&family=Orbitron:wght@600;700;800&display=swap');
:root{--bg:#0a0e14;--panel:rgba(17,23,34,.80);--panel-2:rgba(13,18,26,.78);--line:#1e2937;--txt:#e6edf3;--muted:#a7c4bf;--dim:#7d9b95;--accent:#ff4d5e;--accent-2:#4b8bf4;--good:#2dd4a7;--warn:#f5a524;--bad:#e0303f;--gold:#f5c518;--chip:#192230}
*{box-sizing:border-box;margin:0;padding:0}
body{background:radial-gradient(1000px 520px at 88% -8%,rgba(45,212,167,.07),transparent 60%),radial-gradient(820px 600px at -5% 105%,rgba(75,139,244,.06),transparent 55%),var(--bg);color:var(--txt);font-family:'Sora',sans-serif;min-height:100vh;padding:22px}
.wrap{max-width:1320px;margin:0 auto}
header{display:flex;align-items:flex-end;justify-content:space-between;gap:16px;flex-wrap:wrap;margin-bottom:6px}
h1{font-size:34px;font-weight:700;letter-spacing:-1px;line-height:1;font-family:'Space Grotesk',sans-serif}
h1 .it-g{background:linear-gradient(135deg,#2dd4a7,#3df0d0);-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;filter:drop-shadow(0 0 14px rgba(45,212,167,.35))}
h1 .it-w{color:#f0f6fc;filter:drop-shadow(0 0 8px rgba(255,255,255,.15))}
h1 .it-r{background:linear-gradient(135deg,#ff4d5e,#ff8a5c);-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;filter:drop-shadow(0 0 14px rgba(255,77,94,.35))}
.tagline{color:var(--txt);opacity:.92;font-size:13px;margin-top:7px;font-family:'Sora',sans-serif;letter-spacing:.2px}
.updated{font-family:'Space Mono',monospace;font-size:12px;color:var(--muted);text-align:right;line-height:1.6} .updated b{color:var(--good)}
.livedot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--good);margin-right:5px;box-shadow:0 0 0 0 rgba(45,212,167,.6);animation:pulse 2s infinite}
@keyframes pulse{0%{box-shadow:0 0 0 0 rgba(45,212,167,.5)}70%{box-shadow:0 0 0 7px rgba(45,212,167,0)}100%{box-shadow:0 0 0 0 rgba(45,212,167,0)}}
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:11px;margin:18px 0}
.stat{background:var(--panel);border:1px solid var(--line);border-radius:13px;padding:15px 17px;position:relative;overflow:hidden;transition:transform .15s,border-color .15s}
.stat:hover{transform:translateY(-2px);border-color:var(--dim)}
.stat::after{content:'';position:absolute;inset:0 auto 0 0;width:3px;background:var(--good);opacity:.75}
.stat .v{font-size:27px;font-weight:700;font-family:'Space Grotesk',sans-serif;letter-spacing:-.5px;color:var(--good)} .stat .l{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:1.8px;margin-top:4px;font-family:'Space Grotesk',sans-serif;font-weight:500}
.stat.b2::after{background:var(--accent-2)} .stat.b3::after{background:var(--warn)} .stat.b4::after{background:var(--gold)} .stat.b5::after{background:var(--good)}
.tabs{display:flex;gap:4px;margin:16px 0 0;border-bottom:1px solid var(--line);flex-wrap:wrap}
.tab{padding:11px 18px;cursor:pointer;color:var(--muted);font-weight:600;font-size:13.5px;border-bottom:2px solid transparent;transition:color .15s,border-color .2s;position:relative;top:1px;font-family:'Space Grotesk',sans-serif;font-weight:500}
@keyframes viewfade{from{opacity:0;transform:translateY(5px)}to{opacity:1;transform:none}}
.viewfade{animation:viewfade .25s ease-out}
.tab:hover{color:var(--txt)} .tab.active{color:var(--good);border-bottom-color:var(--good)}
.panel-desc{color:var(--txt);opacity:.92;font-size:13px;line-height:1.5;font-family:'Sora',sans-serif;margin:14px 0;max-width:780px}
.net-label{color:var(--good);font-weight:700;opacity:1}
.showing{color:var(--muted);font-size:12px;font-family:'Space Mono',monospace;margin:0 0 10px 2px}
.showing b{color:var(--good);font-weight:700}
.controls{display:flex;gap:9px;flex-wrap:wrap;align-items:center;margin-bottom:13px}
input[type=text],select{background:var(--panel-2);border:1px solid var(--line);color:var(--txt);padding:10px 12px;border-radius:10px;font-family:'Sora',sans-serif;font-size:13px;outline:none}
input[type=text]{flex:1;min-width:200px}
input[type=search]{flex:1;min-width:200px;background:var(--panel-2);border:1px solid var(--line);color:var(--txt);padding:10px 12px;border-radius:10px;font-family:'Sora',sans-serif;font-size:13px;outline:none}
input[type=search]:focus{border-color:var(--good);box-shadow:0 0 0 3px rgba(45,212,167,.12)}
input[type=search]:-webkit-autofill,input[type=search]:-webkit-autofill:hover,input[type=search]:-webkit-autofill:focus,input[type=text]:-webkit-autofill{-webkit-text-fill-color:var(--txt) !important;-webkit-box-shadow:0 0 0 1000px var(--panel-2) inset !important;caret-color:var(--txt);transition:background-color 9999s ease-in-out 0s}
input[type=search]:focus{background:var(--panel-2)}
.dot-warn{color:var(--warn,#e8a13a)} .dot-bad{color:var(--bad,#e05c5c)} .dot-good{color:var(--good,#3fb950)}
input[type=search]::-webkit-search-cancel-button{-webkit-appearance:none;appearance:none;height:14px;width:14px;background:var(--muted);cursor:pointer;border-radius:50%;-webkit-mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M18 6L6 18M6 6l12 12' stroke='black' stroke-width='3'/%3E%3C/svg%3E") center/contain no-repeat}
input[type=search]::-webkit-search-cancel-button:hover{background:var(--txt)} input[type=text]:focus,select:focus{border-color:var(--good)}
/* custom dropdowns (country/protocol) - themed, keyboard type-ahead */
.sdd{position:relative}
.sdd-btn{background:var(--panel-2);border:1px solid var(--line);color:var(--txt);padding:10px 32px 10px 12px;border-radius:10px;font-family:'Sora',sans-serif;font-size:13px;cursor:pointer;min-width:150px;text-align:left;position:relative}
.sdd-btn::after{content:'';position:absolute;right:13px;top:50%;width:7px;height:7px;border-right:2px solid var(--muted);border-bottom:2px solid var(--muted);transform:translateY(-70%) rotate(45deg)}
.sdd.open .sdd-btn,.sdd-btn:focus{border-color:var(--good);outline:none}
.sdd-menu{display:none;position:absolute;top:calc(100% + 6px);left:0;min-width:235px;max-height:295px;overflow-y:auto;background:var(--panel-2);border:1px solid var(--line);border-radius:12px;padding:5px;z-index:60;box-shadow:0 12px 34px rgba(0,0,0,.55);scrollbar-width:thin;scrollbar-color:var(--line) transparent}
.sdd.open .sdd-menu{display:block}
.sdd-menu::-webkit-scrollbar{width:8px}
.sdd-menu::-webkit-scrollbar-track{background:transparent}
.sdd-menu::-webkit-scrollbar-thumb{background:var(--line);border-radius:4px}
.sdd-menu::-webkit-scrollbar-thumb:hover{background:var(--good)}
.sdd-it{padding:8px 11px;border-radius:8px;font-size:13px;color:var(--muted);cursor:pointer;white-space:nowrap}
.sdd-it:hover{background:rgba(45,212,167,.10);color:var(--txt)}
.sdd-it.sel{color:var(--good);background:rgba(45,212,167,.13)}
.sdd-it.kbd{outline:1px solid var(--good)}
.chip-filters{display:flex;gap:6px;flex-wrap:wrap}
.cf{padding:9px 14px;border-radius:999px;background:var(--chip);border:1px solid var(--line);color:var(--muted);font-size:12px;cursor:pointer;font-weight:600;transition:.15s}
.cf:hover{color:var(--txt)} .cf.on{background:rgba(45,212,167,.13);border-color:var(--good);color:var(--good)}
.tablewrap{background:var(--panel);border:1px solid var(--line);border-radius:15px;overflow:hidden}
.tscroll{overflow-x:auto;scrollbar-width:thin;scrollbar-color:var(--line) transparent}
.tscroll::-webkit-scrollbar{height:8px}
.tscroll::-webkit-scrollbar-track{background:var(--panel-2);border-radius:4px}
.tscroll::-webkit-scrollbar-thumb{background:var(--line);border-radius:4px}
.tscroll::-webkit-scrollbar-thumb:hover{background:var(--dim)}
table{width:100%;border-collapse:collapse;font-size:13px;min-width:680px}
thead th{text-align:left;padding:12px 13px;font-size:10.5px;letter-spacing:.8px;text-transform:uppercase;color:var(--muted);background:var(--panel-2);border-bottom:1px solid var(--line);cursor:pointer;white-space:nowrap;user-select:none;position:sticky;top:0;z-index:2}
thead th:hover{color:var(--txt)} thead th .ar{opacity:.5;font-size:9px}
tbody td{padding:11px 13px;border-bottom:1px solid var(--line);white-space:nowrap}
tbody tr:last-child td{border-bottom:none}
tbody tr.node-row{cursor:pointer;transition:background .12s} tbody tr.node-row:hover{background:rgba(255,255,255,.035)}
.mono{font-family:'Space Mono',monospace} .addr{color:var(--dim);font-size:10.5px} .mon{font-weight:600}
.rank{color:var(--muted);font-family:'Space Mono',monospace;width:30px} .rank.top{color:var(--gold);font-weight:700}
.score{display:inline-flex;align-items:center;justify-content:center;min-width:42px;padding:5px 9px;border-radius:7px;font-family:'Space Mono',monospace;font-weight:700;font-size:13px}
.s-hi{background:rgba(45,212,167,.15);color:var(--good)} .s-mid{background:rgba(245,165,36,.15);color:var(--warn)} .s-lo{background:rgba(224,48,63,.15);color:var(--bad)}
.sbar{height:4px;border-radius:3px;background:var(--line);overflow:hidden;margin-top:5px;width:80px} .sbar>i{display:block;height:100%;background:linear-gradient(90deg,var(--accent-2),var(--good))}
.badge{display:inline-block;padding:3px 8px;border-radius:6px;font-size:10.5px;font-weight:600}
.b-act{background:rgba(45,212,167,.15);color:var(--good)} .b-ina{background:rgba(239,75,87,.13);color:var(--bad)} .b-sta{background:rgba(245,165,36,.14);color:var(--warn)}
.proto{display:inline-block;padding:3px 8px;border-radius:6px;font-size:10.5px;font-weight:600;background:var(--chip);color:var(--muted)}
.proto.v2ray{background:rgba(75,139,244,.14);color:var(--accent-2)} .proto.wireguard{background:rgba(245,165,36,.14);color:var(--warn)} .proto.openvpn{background:rgba(168,85,247,.14);color:#c084fc}
.proto.xray{background:rgba(45,212,167,.18);color:var(--good)} .proto.amneziawg{background:rgba(96,165,250,.16);color:#7cc4ff} .proto.hysteria2{background:rgba(244,114,182,.16);color:#f9a8d4}
.acens{display:inline-block;padding:2px 7px;border-radius:5px;font-size:10px;font-weight:700;font-family:Space Mono,monospace}
.acens.high{background:rgba(45,212,167,.18);color:var(--good)} .acens.med{background:rgba(245,165,36,.16);color:var(--warn)} .acens.low{background:var(--chip);color:var(--muted)}
.flag{color:var(--muted);font-size:12px} .price{font-family:'Space Mono',monospace} .na{color:var(--dim)}
.pager{display:flex;align-items:center;justify-content:space-between;padding:13px;gap:12px;flex-wrap:wrap}
.pager .info{color:var(--muted);font-size:12px;font-family:'Space Mono',monospace}
.pager .btns{display:flex;gap:6px;align-items:center}
.pg{padding:7px 13px;border-radius:8px;background:var(--panel-2);border:1px solid var(--line);color:var(--txt);cursor:pointer;font-size:13px;font-family:'Space Mono',monospace}
.pg:hover:not(:disabled){border-color:var(--good)} .pg:disabled{opacity:.35;cursor:not-allowed}
.pp{background:var(--panel-2);border:1px solid var(--line);color:var(--txt);border-radius:8px;padding:7px 9px;font-size:12px}
.loading,.err{padding:56px;text-align:center;color:var(--muted);font-family:'Space Mono',monospace} .err{color:var(--bad)}
.spinner{width:32px;height:32px;border:3px solid var(--line);border-top-color:var(--good);border-radius:50%;margin:0 auto 15px;animation:spin 1s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
footer{margin-top:16px;text-align:center;color:var(--dim);font-size:11px;font-family:'Space Mono',monospace}
.support{margin:14px auto 0;max-width:680px;text-align:center;font-size:11.5px;color:var(--dim);line-height:1.6}
.support .donrow{display:flex;flex-direction:column;align-items:center;gap:6px;margin-top:7px}
.support .addr{display:inline-block;font-family:'Space Mono',monospace;font-size:11px;color:var(--muted);background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:5px 11px;margin-top:5px;cursor:pointer;transition:all .15s}
.support .addr:hover{border-color:var(--good);color:var(--good)}
.ecolinks{margin:26px auto 0;max-width:680px;background:var(--panel);border:1px solid var(--line);border-radius:13px;padding:15px 18px;text-align:center}
.ecolinks .et{font-size:11px;text-transform:uppercase;letter-spacing:1.2px;color:var(--muted);margin-bottom:11px;font-weight:600}
.ecolinks a{display:inline-block;color:var(--txt);text-decoration:none;font-size:12.5px;font-family:'Space Mono',monospace;background:var(--chip);border:1px solid var(--line);border-radius:9px;padding:7px 13px;margin:3px 4px;transition:all .15s}
.ecolinks a:hover{border-color:var(--good);color:var(--good);transform:translateY(-1px)}
/* ===== INSPECTOR ===== */
.insp-search{display:flex;gap:9px;margin-bottom:16px;flex-wrap:wrap}
.insp-empty{padding:50px;text-align:center;color:var(--dim);font-family:'Space Mono',monospace}
.insp-grid{display:grid;grid-template-columns:1.3fr 1fr;gap:14px}
@media(max-width:860px){.insp-grid{grid-template-columns:1fr}}
.card{background:var(--panel);border:1px solid var(--line);border-radius:15px;padding:18px 20px}
.card h3{font-size:12px;text-transform:uppercase;letter-spacing:1px;color:var(--muted);margin-bottom:14px}
.ihead{display:flex;align-items:flex-start;justify-content:space-between;gap:14px;flex-wrap:wrap;margin-bottom:4px}
.ihead .imon{font-size:22px;font-weight:800} .ihead .iaddr{font-family:'Space Mono',monospace;font-size:11px;color:var(--dim);margin-top:3px;word-break:break-all}
.ibig{font-size:46px;font-weight:800;font-family:'Space Mono',monospace;line-height:1}
.imeta{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}
.kv{display:grid;grid-template-columns:auto 1fr;gap:7px 14px;font-size:13px}
.kv .k{color:var(--muted)} .kv .v{font-family:'Space Mono',monospace;text-align:right;word-break:break-all}
.br{display:flex;align-items:center;gap:9px;margin-bottom:8px;font-size:12px}
.br .lbl{width:92px;color:var(--muted)} .br .bar{flex:1;height:7px;background:var(--line);border-radius:4px;overflow:hidden} .br .bar>i{display:block;height:100%;background:linear-gradient(90deg,var(--accent-2),var(--good))} .br .pct{width:34px;text-align:right;font-family:'Space Mono',monospace}
.timeline{display:flex;gap:2px;flex-wrap:wrap;margin-top:6px}
.tlc{width:7px;height:20px;border-radius:2px} .tlc.a{background:var(--good)} .tlc.i{background:var(--bad)}
.rankbox{font-size:14px} .rankbox b{font-size:28px;font-family:'Space Mono',monospace;color:var(--good)}
.pchip{display:inline-block;font-size:11px;font-family:'Space Mono',monospace;background:rgba(45,212,167,.09);color:var(--good);border:1px solid rgba(45,212,167,.22);border-radius:7px;padding:3px 9px;margin:2px 3px 2px 0}
.lchk{margin-top:14px;border:1px solid var(--line);border-radius:11px;background:var(--panel-2)}
.lchk summary{cursor:pointer;padding:11px 14px;font-size:12px;font-weight:700;color:var(--txt);user-select:none;list-style:none}
.lchk summary::-webkit-details-marker{display:none}
.lchk summary:hover{color:var(--good)}
.lchk[open] summary{border-bottom:1px solid var(--line)}
.lc-intro{padding:10px 14px 2px;font-size:11.5px;color:var(--muted);line-height:1.5}
.lc-item{padding:9px 14px;border-bottom:1px solid rgba(30,41,55,.5)}
.lc-item:last-child{border-bottom:none}
.lc-item.hot{border-left:3px solid var(--warn);background:rgba(245,165,36,.04)}
.lc-name{font-size:12px;font-weight:600;cursor:help}
.lc-name .why{color:var(--dim);font-weight:400;font-size:11px}
.lc-item code{display:block;margin-top:5px;font-family:'Space Mono',monospace;font-size:11px;color:var(--good);background:var(--bg);border:1px solid var(--line);border-radius:7px;padding:6px 9px;cursor:pointer;overflow-x:auto;white-space:nowrap;transition:border-color .15s}
.lc-item code:hover{border-color:var(--good)}
.repbtn.tg{background:rgba(75,139,244,.12);border-color:rgba(75,139,244,.35);color:var(--accent-2);margin-left:8px}
.repbtn.tg:hover{background:rgba(75,139,244,.2);border-color:var(--accent-2)}
.problem{display:flex;gap:9px;align-items:flex-start;padding:9px 0;border-bottom:1px solid var(--line);font-size:13px}
.problem:last-child{border:none} .problem .pic{font-size:15px} .problem.ok .pic{filter:none}
.repbtn{margin-top:12px;width:100%;padding:12px;border-radius:10px;background:var(--good);color:#04241a;border:none;font-weight:700;font-size:13px;cursor:pointer;font-family:'Sora',sans-serif}
.repbtn:hover{filter:brightness(1.08)}
.report{margin-top:12px;background:var(--panel-2);border:1px solid var(--line);border-radius:10px;padding:13px;font-family:'Space Mono',monospace;font-size:11.5px;white-space:pre-wrap;color:var(--txt);display:none;line-height:1.55}
.back{cursor:pointer;color:var(--accent-2);font-size:12px;font-family:'Space Mono',monospace;margin-bottom:10px;display:inline-block}
@media(max-width:760px){.hide-sm{display:none}h1{font-size:22px}body{padding:12px}.stat{padding:11px 13px}.stat .v{font-size:20px}.tab{padding:10px 12px;font-size:12.5px}.updated{font-size:10.5px}}
/* NodeAdvisor */
#nodeadvisor h3{margin-bottom:10px}
.na-loading,.na-err,.na-empty{font-family:'Space Mono',monospace;font-size:12px;color:var(--muted)}
.na-err{color:var(--warn)}
.na-count{font-family:'Space Mono',monospace;font-size:12px;color:var(--muted);margin-bottom:8px}
.na-tags-sum{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:14px}
.na-tagchip{font-size:11.5px;padding:3px 9px;border-radius:20px;border:1px solid var(--line);color:var(--txt)}
.na-tagchip.pos{border-color:rgba(52,211,153,.4);background:rgba(52,211,153,.08)}
.na-tagchip.neg{border-color:rgba(248,113,113,.4);background:rgba(248,113,113,.08)}
.na-tagchip b{color:var(--good)}
.na-tagchip.neg b{color:var(--bad)}
.na-sub{font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--muted);margin:14px 0 8px}
.na-comments{margin:10px 0}
.na-comment{display:flex;justify-content:space-between;gap:10px;padding:7px 0;border-bottom:1px solid var(--line);font-size:12.5px}
.na-ctext{color:var(--txt)}
.na-cdate{color:var(--dim);font-family:'Space Mono',monospace;font-size:10.5px;white-space:nowrap}
.na-form{margin-top:6px}
.na-checks{display:flex;flex-wrap:wrap;gap:7px;margin-bottom:10px}
.na-chk{display:inline-flex;align-items:center;gap:5px;font-size:12px;padding:5px 10px;border-radius:8px;border:1px solid var(--line);cursor:pointer;user-select:none}
.na-chk.pos:hover{border-color:rgba(52,211,153,.5)}
.na-chk.neg:hover{border-color:rgba(248,113,113,.5)}
.na-chk input{accent-color:var(--good);cursor:pointer}
#na-comment{width:100%;box-sizing:border-box;background:var(--bg);border:1px solid var(--line);border-radius:8px;color:var(--txt);padding:8px 10px;font-family:inherit;font-size:13px;resize:vertical;min-height:54px}
.na-submit{margin-top:10px;background:var(--good);color:#04110b;border:none;border-radius:8px;padding:9px 16px;font-weight:600;cursor:pointer;font-size:13px}
.na-submit:disabled{opacity:.6;cursor:default}
.na-note{font-size:10.5px;color:var(--dim);margin-top:8px}
.na-privacy{font-size:10px;color:var(--dim);margin-top:6px;line-height:1.5;opacity:.85;border-top:1px solid var(--line);padding-top:7px}
.na-voted{font-size:12.5px;color:var(--good);padding:8px 0}
/* medref: median context next to values */
.medref{font-size:9.5px;color:var(--dim);font-family:'Space Mono',monospace;margin-left:6px;opacity:.8}
/* tooltip score breakdown */
.qm{display:inline-flex;align-items:center;justify-content:center;width:13px;height:13px;border-radius:50%;border:1px solid var(--line);color:var(--muted);font-size:9px;margin-left:5px;cursor:help;position:relative;font-family:'Space Mono',monospace;vertical-align:middle}
.qm:hover{border-color:var(--good);color:var(--good)}
.qm::after{content:attr(data-tip);position:absolute;left:50%;bottom:140%;transform:translateX(-50%);width:240px;white-space:normal;background:var(--bg);border:1px solid var(--line);border-radius:8px;padding:9px 11px;font-size:11px;line-height:1.5;color:var(--txt);font-family:inherit;text-align:left;opacity:0;pointer-events:none;transition:opacity .15s;z-index:30;box-shadow:0 6px 24px rgba(0,0,0,.5)}
.qm:hover::after{opacity:1}
@media(max-width:600px){.qm::after{width:160px}}
/* ASN Intelligence redesign */
.asn-head{margin:16px 0 18px}
.asn-title{font-size:23px;font-weight:700;font-family:'Space Grotesk',sans-serif;letter-spacing:-.5px}
.asn-sub{color:var(--muted);font-size:13px;line-height:1.55;margin-top:5px;max-width:680px}
.asn-kpis{display:flex;gap:10px;margin-top:15px;flex-wrap:wrap}
.kpi{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:11px 18px;min-width:96px}
.kpi-v{font-size:23px;font-weight:800;font-family:'Space Mono',monospace;color:var(--good);line-height:1}
.kpi-l{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:1px;margin-top:5px}
.asn-search{position:relative;margin:0 0 18px}
.asn-search-ic{position:absolute;left:13px;top:50%;transform:translateY(-50%);font-size:13px;opacity:.5;pointer-events:none}
.asn-search input{width:100%;box-sizing:border-box;padding-left:38px!important}
.vcards{display:grid;grid-template-columns:repeat(auto-fill,minmax(232px,1fr));gap:12px;margin:0 0 22px}
.vcard{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:15px 16px;position:relative;overflow:hidden;transition:transform .15s,border-color .15s}
.vcard:hover{transform:translateY(-2px)}
.vcard.clickable:hover{border-color:var(--good)}
.vcard::before{content:'';position:absolute;inset:0 auto 0 0;width:3px}
.vcard.good::before{background:var(--good)} .vcard.warn::before{background:var(--warn)} .vcard.bad::before{background:var(--bad)}
.vcard.good:hover{border-color:rgba(45,212,167,.4)} .vcard.warn:hover{border-color:rgba(245,165,36,.4)} .vcard.bad:hover{border-color:rgba(224,48,63,.4)}
.vcard.wide{grid-column:1/-1}
.vcard-top{display:flex;justify-content:space-between;align-items:center;gap:8px}
.vcard-asn{font-family:'Space Mono',monospace;font-size:13px;font-weight:700;color:var(--txt)}
.vbadge{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;padding:3px 9px;border-radius:20px}
.vbadge.good{background:rgba(45,212,167,.14);color:var(--good)} .vbadge.warn{background:rgba(245,165,36,.14);color:var(--warn)} .vbadge.bad{background:rgba(224,48,63,.14);color:var(--bad)}
.vcard-name{font-size:13px;color:var(--muted);margin:7px 0 12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.vmeter{height:6px;background:var(--panel-2);border-radius:4px;overflow:hidden;border:1px solid var(--line)}
.vmeter-fill{height:100%;border-radius:4px;transition:width .4s}
.vmeter-fill.good{background:var(--good)} .vmeter-fill.warn{background:var(--warn)} .vmeter-fill.bad{background:var(--bad)}
.vcard-stats{display:flex;justify-content:space-between;margin-top:10px;font-size:12px;color:var(--muted)}
.vcard-stats b{color:var(--txt);font-family:'Space Mono',monospace}
.vcard-stats .g b{color:var(--good)} .vcard-stats .w b{color:var(--warn)} .vcard-stats .b b{color:var(--bad)}
.vcard-geo{margin-top:9px;font-size:11px;color:var(--dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.vcard-note{font-size:12px;color:var(--muted);line-height:1.5;margin-top:8px}
.oppgrid{display:grid;grid-template-columns:1fr 1fr;gap:13px;margin:0 0 22px}
@media(max-width:620px){.oppgrid{grid-template-columns:1fr}}
.oppcard{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:15px 17px}
.oppcard.good{border-top:2px solid var(--good)} .oppcard.bad{border-top:2px solid var(--bad)}
.oppcard-h{display:flex;gap:11px;align-items:center;margin-bottom:13px}
.oppcard-ic{font-size:20px}
.oppcard-t{font-size:13.5px;font-weight:700;color:var(--txt)}
.oppcard-s{font-size:11px;color:var(--dim);margin-top:2px}
.ccol{display:grid;grid-template-columns:1fr 64px 30px;align-items:center;gap:10px;padding:6px 8px;margin:0;border-radius:8px;cursor:pointer;transition:background .12s}
.ccol:hover{background:rgba(45,212,167,.07)}
.ccol-c{font-size:12.5px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ccol:hover .ccol-c{color:var(--txt)}
.ccol-bar{height:6px;background:rgba(127,140,160,.12);border-radius:4px;overflow:hidden;width:100%}
.ccol-bar span{display:block;height:100%;border-radius:4px;min-width:3px;transition:width .3s}
.oppcard.good .ccol-bar span{background:linear-gradient(90deg,var(--good),rgba(45,212,167,.5))} .oppcard.bad .ccol-bar span{background:linear-gradient(90deg,rgba(224,48,63,.5),var(--bad))}
.ccol-n{font-family:'Space Mono',monospace;font-size:12px;color:var(--muted);text-align:right}
.asn-table-h{font-size:13px;font-weight:700;color:var(--muted);margin:0 0 10px;text-transform:uppercase;letter-spacing:.8px}
.asn-table-c{display:inline-block;background:var(--chip);color:var(--dim);font-family:'Space Mono',monospace;font-size:11px;padding:1px 8px;border-radius:10px;margin-left:6px}
.asn-tbl tr{transition:background .1s} .asn-tbl tbody tr:hover{background:rgba(45,212,167,.05)}
.asn-tbl .tdim{color:var(--dim);font-size:10px}
.asn-badge.warn{background:rgba(245,165,36,.13);color:var(--warn)}
.asn-badge.cloud{background:rgba(245,165,36,.13);color:var(--warn);cursor:help}
.asn-badge.isp{background:rgba(224,48,63,.12);color:var(--bad);cursor:help}
.asn-badge.unver{background:rgba(127,140,160,.10);color:var(--dim);border:1px solid rgba(127,140,160,.22);cursor:help}
.cloudwarn.unver{color:var(--dim);background:rgba(127,140,160,.07);border-color:rgba(127,140,160,.25)}
.cloudwarn{margin-top:8px;font-size:10.5px;color:var(--warn);background:rgba(245,165,36,.07);border:1px solid rgba(245,165,36,.2);border-radius:7px;padding:4px 8px;cursor:help;line-height:1.3}
.provlink{display:inline-block;margin-left:5px;color:var(--good);text-decoration:none;font-weight:700;font-size:12px;opacity:.75;transition:opacity .15s}
.provlink:hover{opacity:1}
.ttip{display:inline-flex;align-items:center;justify-content:center;width:13px;height:13px;border-radius:50%;background:var(--line);color:var(--muted);font-size:9px;font-weight:700;font-family:'Sora',sans-serif;cursor:help;vertical-align:middle;margin-left:2px;transition:background .15s,color .15s}
*:hover>.ttip{background:var(--good);color:var(--bg)}
.asn-intro{color:var(--txt);opacity:.75;font-size:13px;line-height:1.55;font-family:'Sora',sans-serif;margin:14px 0 16px;max-width:780px} .asn-intro b{opacity:1;color:var(--good);font-weight:600}
.asn-sat{color:var(--bad);font-weight:700}
.asn-ok{color:var(--good);font-weight:700}
.asn-badge{display:inline-block;padding:2px 7px;border-radius:5px;font-size:10px;font-weight:600}
.asn-badge.sat{background:rgba(239,75,87,.13);color:var(--bad)}
.asn-badge.ok{background:rgba(45,212,167,.13);color:var(--good)}
.asn-search{margin:0 0 13px}
.cmp{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin:13px 0 18px}
@media(max-width:560px){.cmp{grid-template-columns:1fr}}
.cmp-card{background:var(--panel);border:1px solid var(--line);border-radius:13px;padding:15px 17px}
.cmp-card h4{font-size:11px;text-transform:uppercase;letter-spacing:1px;color:var(--muted);margin-bottom:10px;font-weight:600}
.cmp-row{display:flex;justify-content:space-between;gap:10px;font-size:13px;padding:4px 0;font-family:'Space Mono',monospace}
.cmp-row .ck{color:var(--muted)} .cmp-row .cv{font-weight:700;text-align:right}
.cscroll{max-height:300px;overflow-y:auto;overflow-x:hidden;padding-right:8px}
.cscroll::-webkit-scrollbar{width:7px}
.cscroll::-webkit-scrollbar-track{background:transparent}
.cscroll::-webkit-scrollbar-thumb{background:var(--line);border-radius:4px}
.cscroll::-webkit-scrollbar-thumb:hover{background:var(--dim)}
.cscroll::-webkit-scrollbar{width:8px} .cscroll::-webkit-scrollbar-track{background:var(--panel-2);border-radius:5px}
.cscroll::-webkit-scrollbar-thumb{background:var(--line);border-radius:5px} .cscroll::-webkit-scrollbar-thumb:hover{background:var(--dim)}
.crow{cursor:pointer;border-radius:6px;padding:4px 6px;margin:0 -6px;transition:background .12s}
.crow:hover{background:rgba(45,212,167,.08)} .crow:hover .ck{color:var(--txt)}
.deccard{display:flex;gap:24px;align-items:center;background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:20px 22px;margin:10px 0;flex-wrap:wrap}
.decscore{display:flex;align-items:center;gap:14px;min-width:160px}
.decnum{font-size:52px;font-weight:800;line-height:1;font-variant-numeric:tabular-nums}
.decmax{font-size:18px;color:var(--dim);font-weight:600}
.decgrade{font-size:26px;font-weight:800;color:#0a0e14;width:46px;height:46px;border-radius:11px;display:flex;align-items:center;justify-content:center}
.decbars{flex:1;min-width:240px;display:flex;flex-direction:column;gap:9px}
.decrow-top{display:flex;justify-content:space-between;font-size:13px;margin-bottom:3px}
.decw{color:var(--dim);font-size:11px}
.decbar{height:7px;background:rgba(127,140,160,.12);border-radius:4px;overflow:hidden}
.decbar span{display:block;height:100%;border-radius:4px;transition:width .4s}
.decinsight{display:flex;flex-direction:column;gap:8px;margin-top:6px}
.deci{display:flex;gap:12px;font-size:13px;padding:9px 12px;background:var(--panel);border:1px solid var(--line);border-radius:9px}
.decik{color:var(--muted);min-width:120px;font-weight:600}
.kpidec .kpi-v{font-variant-numeric:tabular-nums}
/* keep page content above the fixed background canvas */
body > *:not(#bgCanvas){position:relative;z-index:1}
#bgPicker{z-index:9998 !important}
</style>
</head>
<body>
<canvas id="bgCanvas" style="position:fixed !important;top:0;left:0;right:0;bottom:0;width:100vw;height:100vh;z-index:0;pointer-events:none;opacity:0"></canvas>
<div id="bgPicker" style="position:fixed;bottom:14px;right:14px;z-index:9998;display:flex;align-items:center;gap:9px;background:rgba(13,18,26,.92);border:1px solid var(--line);border-radius:30px;padding:7px 13px;font-family:'Space Mono',monospace;font-size:11px;color:var(--muted);box-shadow:0 6px 22px rgba(0,0,0,.55);backdrop-filter:blur(6px)">
<span style="letter-spacing:.5px;text-transform:uppercase;font-size:10px">Live background</span>
<button id="bgToggle" role="switch" aria-checked="false" style="position:relative;width:38px;height:20px;border-radius:20px;border:1px solid var(--line);background:var(--panel-2);cursor:pointer;padding:0;transition:background .25s,border-color .25s">
<span id="bgKnob" style="position:absolute;top:1px;left:1px;width:16px;height:16px;border-radius:50%;background:var(--dim);transition:transform .25s,background .25s"></span>
</button>
<span id="bgHelp" tabindex="0" data-tip="Live network backdrop. Cycles every 3 min: Earth (nodes by country), Mesh (the network as a web), Streams (traffic flow). Brightens with network health." style="display:inline-flex;align-items:center;justify-content:center;width:17px;height:17px;border-radius:50%;border:1px solid var(--line);color:var(--muted);cursor:help;font-size:11px;user-select:none">?</span>
</div>
<div class="wrap">
<header>
<div><h1><span class="it-g">Sentinel</span> <span class="it-w">Node</span> <span class="it-r">Scorecard</span></h1>
<div class="tagline">network intelligence · historical uptime · node diagnostics</div></div>
<div class="updated" id="updated">loading…</div>
</header>
<div class="stats" id="stats"></div>
<div class="panel-desc" id="paneldesc"></div>
<!-- EXPLORER (unica vista; l'inspector si apre qui sopra al click) -->
<div class="tabs" id="maintabs">
<div class="tab active" data-view="explorer">Nodes</div>
<div class="tab" data-view="asnintel">ASN Intelligence</div>
<div class="tab" data-view="leaderboard">Top Reliable</div>
<div class="tab" data-view="trends">Trends</div>
<div class="tab" data-view="decent">Decentralization</div>
</div>
<div id="view-asnintel" style="display:none"></div>
<div id="view-leaderboard" style="display:none"></div>
<div id="view-trends" style="display:none"></div>
<div id="view-decent" style="display:none"></div>
<div id="view-explorer">
<!-- INSPECTOR PANEL: hidden until a node is clicked -->
<div id="view-inspector" style="display:none">
<div class="insp-search">
<input type="search" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" id="insp-input" placeholder="paste a node address (sentnode1…) or moniker">
<button class="pg" id="insp-go">Inspect</button>
</div>
<div id="insp-body"></div>
</div>
<!-- LISTA: controlli + tabella -->
<div id="explorer-list">
<div class="controls">
<input type="search" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" id="search" placeholder="search moniker, address, city…">
<div class="sdd" id="dd-country"><button type="button" class="sdd-btn">All countries</button><div class="sdd-menu"></div></div>
<div class="sdd" id="dd-protocol"><button type="button" class="sdd-btn">All protocols</button><div class="sdd-menu"></div></div>
<div class="chip-filters"><div class="cf" id="cf-stale">Inactive</div><div class="cf" data-f="api">API reachable</div></div>
</div>
<div class="showing" id="showing"></div>
<div class="tablewrap">
<div class="tscroll"><div id="content"><div class="loading"><div class="spinner"></div>connecting to Sentinel network…</div></div></div>
<div class="pager" id="pager" style="display:none">
<div class="info" id="pginfo"></div>
<div class="btns"><select class="pp" id="perpage"><option value="25">25</option><option value="50" selected>50</option><option value="100">100</option></select>
<button class="pg" id="prev">‹ prev</button><button class="pg" id="next">next ›</button></div>
</div>
</div>
</div>
</div>
<div class="ecolinks"><div class="et">Sentinel Ecosystem — Frens & tools</div>
<a href="https://sentinel.co" target="_blank" rel="noopener">🛡 Sentinel</a>
<a href="https://docs.sentinel.co/get-started/apps" target="_blank" rel="noopener">dVPN apps</a>
<a href="https://stats.sentinel.co" target="_blank" rel="noopener">stats.sentinel.co</a>
<a href="https://hub.bluefrens.xyz" target="_blank" rel="noopener">💙 BlueFrens Hub</a>
<a href="https://sentnodes.com" target="_blank" rel="noopener">SentNodes</a>
<a href="https://nodes.suchnode.net" target="_blank" rel="noopener">SuchNode</a>
<a href="https://p2pscan.com" target="_blank" rel="noopener">p2pscan</a></div>
<div class="support" id="support"></div>
<footer>Sentinel Scorecard · historical data collected on-device · client-side scoring · built for node operators</footer>
</div>
<script>
const REPO="superpios/node-scorecard";
// ===== NodeAdvisor (Supabase) =====
const SB_URL="https://upalnwxnmavcrgifoftt.supabase.co";
const SB_KEY="sb_publishable_cA2TS9Gq4b9rdV26bb2VfQ_NSxGU2Ds";
const NA_TAGS=[
{id:"fast",label:"Fast connection",pos:true},
{id:"stable",label:"Stable / no drops",pos:true},
{id:"good_uptime",label:"Good uptime",pos:true},
{id:"good_value",label:"Good price/quality",pos:true},
{id:"connects_first",label:"Connects first try",pos:true},
{id:"drops",label:"Drops often",pos:false},
{id:"high_latency",label:"High latency",pos:false},
{id:"slow",label:"Slow",pos:false},
{id:"unreachable",label:"Hard to reach",pos:false}
];
const NA_TAGMAP=Object.fromEntries(NA_TAGS.map(t=>[t.id,t]));
const SRC={latest:["./latest.json","https://raw.githubusercontent.com/"+REPO+"/main/latest.json","https://raw.githubusercontent.com/"+REPO+"/master/latest.json"],
history:["./history-summary.json","https://raw.githubusercontent.com/"+REPO+"/main/history-summary.json","https://raw.githubusercontent.com/"+REPO+"/master/history-summary.json"],
trends:["./trends.json","https://raw.githubusercontent.com/"+REPO+"/main/trends.json","https://raw.githubusercontent.com/"+REPO+"/master/trends.json"],
capsule:["./capsule.json","https://raw.githubusercontent.com/"+REPO+"/main/capsule.json","https://raw.githubusercontent.com/"+REPO+"/master/capsule.json"]};
let NODES=[],MAP={},HIST={},MAXN=1,MED={},TRENDS=null,CAPSULE=null;
let state={tab:"explorer",page:0,perPage:50,sortKey:"score",sortDir:-1,search:"",country:"",protocol:"",filters:{active:false,api:false},showInactive:false};
async function tryFetch(u){for(const x of u){try{const cb=x.includes("githubusercontent")?"":"?t="+Date.now();const r=await fetch(x+cb,{cache:"no-store"});if(r.ok)return await r.json();}catch(e){}}return null;}
async function load(){
tryFetch(SRC.trends).then(t=>{TRENDS=t;});
tryFetch(SRC.capsule).then(c=>{CAPSULE=c;});
const [latest,history]=await Promise.all([tryFetch(SRC.latest),tryFetch(SRC.history)]);
if(!latest){document.getElementById("content").innerHTML='<div class="err">Could not load latest.json from '+REPO+'.</div>';return;}
if(Array.isArray(history)){history.forEach(h=>HIST[h.a]=h);MAXN=Math.max(1,...history.map(h=>h.n||1));}
NODES=latest.map(enrich);NODES.forEach(n=>MAP[n.addr]=n);
computeMedians();
buildCountry();renderStats();render();
const allTs=latest.map(n=>n.ts).filter(Boolean).sort();
const ts=allTs.length?allTs[allTs.length-1]:new Date().toISOString();
document.getElementById("updated").innerHTML='<span class="livedot"></span>'+"updated <b>"+fmtAgo(ts)+"</b><br>"+NODES.filter(n=>n.status==="active").length+" active";
}
function enrich(n){
const h=HIST[n.addr]||{};const active=n.status==="active";
const uptime=(typeof h.uptime==="number")?h.uptime:(active?100:0);
const stab=(typeof h.stab==="number")?h.stab:(active?100:0);
const longevity=h.n?Math.min(100,(h.n/MAXN)*100):0;
// stale: summary marks stale=true if last sample > 24h old
const stale=h.stale===true;
// freshness: compare inactive_at with SAMPLE TIME (n.ts), not with now.
// The collector runs every ~3h, so comparing with Date.now() would always read "expired".
// What matters: at the moment the collector saw the node, was the heartbeat valid?
let fresh=0;
if(n.inactive_at && n.ts){
const sampleTime=new Date(n.ts).getTime();
const m=(new Date(n.inactive_at)-sampleTime)/60000; // margin in minutes AT SAMPLE TIME
if(m>=15)fresh=100;else if(m>=0)fresh=70+(m/15)*30;else if(m>=-60)fresh=Math.max(0,70+(m/60)*70);else fresh=0;
}else fresh=active?70:0;
if(stale) fresh=0; // a truly stale node (>24h) cannot be fresh
let health=(active && !stale)?60:0;if(n.api_ok===true && !stale)health+=40;
const tok=Math.min(100,((n.gb_tokens||0)/3)*100);
const comps={uptime:uptime*0.32,stability:stab*0.15,longevity:longevity*0.25,freshness:fresh*0.13,health:health*0.10,tokens:tok*0.05};
const score=Math.round(Object.values(comps).reduce((a,b)=>a+b,0));
// displayStatus: if summary says stale -> "stale" (overrides latest status which may be outdated)
const displayStatus = stale ? "stale" : n.status;
const dispMoniker = n.moniker || (n.addr ? "("+n.addr.slice(0,11)+"\u2026"+n.addr.slice(-4)+")" : "(unnamed)");
return {addr:n.addr,moniker:dispMoniker,hasName:!!n.moniker,status:displayStatus,country:n.country||"\u2014",city:n.city||"\u2014",
api_ok:n.api_ok===true,gb_tokens:n.gb_tokens||0,samples:h.n||0,uptimeRaw:uptime,stabRaw:stab,
uptime:uptime,stab:stab,longevity:Math.round(longevity),freshness:Math.round(fresh),
stale:stale,age_h:h.age_h||null,
price_hr:(typeof n.price_hr==="number")?n.price_hr:null,price_gb:(typeof n.price_gb==="number")?n.price_gb:null,
protocol:n.protocol||null,peers:(typeof n.peers==="number")?n.peers:null,version:n.version||null,
dl_mbps:n.dl_mbps,ul_mbps:n.ul_mbps,inactive_at:n.inactive_at,remote:n.remote||"",asn:n.asn||null,
sla_pass:(n.sla_pass!=null?n.sla_pass:null),sla_mbps:(n.sla_mbps!=null?n.sla_mbps:null),sla_baseline:(n.sla_baseline!=null?n.sla_baseline:null),sla_err:n.sla_err||null,
tl:h.tl||"",tl_from:h.tl_from,tl_to:h.tl_to,
score:Math.max(0,Math.min(100,score)),comps:comps};
}
function pctRank(sortedArr,v){ // % di valori <= v (0-100)
if(!sortedArr.length||v==null)return null;
let lo=0,hi=sortedArr.length;
while(lo<hi){const mid=(lo+hi)>>1;if(sortedArr[mid]<=v)lo=mid+1;else hi=mid;}
return Math.round((lo/sortedArr.length)*100);
}
function median(arr){
if(!arr.length)return null;
const s=[...arr].sort((a,b)=>a-b);const m=Math.floor(s.length/2);
return s.length%2?s[m]:(s[m-1]+s[m])/2;
}
function computeMedians(){
const act=NODES.filter(n=>n.status==="active");
const hr=act.map(n=>n.price_hr).filter(v=>typeof v==="number").sort((a,b)=>a-b);
const gb=act.map(n=>n.price_gb).filter(v=>typeof v==="number").sort((a,b)=>a-b);
const ul=act.map(n=>n.ul_mbps).filter(v=>typeof v==="number"&&v>0).sort((a,b)=>a-b);
const vcount={};act.forEach(n=>{if(n.version)vcount[n.version]=(vcount[n.version]||0)+1;});
const topVer=Object.entries(vcount).sort((a,b)=>b[1]-a[1])[0];
MED={
price_hr:median(hr),price_gb:median(gb),ul_mbps:median(ul),
hrArr:hr,gbArr:gb,ulArr:ul,
topVersion:topVer?topVer[0]:null,
versionCounts:vcount
};
}
function buildCountry(){const c={};
NODES.forEach(n=>{if(n.country&&n.country!=="\u2014")c[n.country]=(c[n.country]||0)+1;});
const items=[{v:"",t:"All countries"}].concat(Object.entries(c).sort((a,b)=>b[1]-a[1]).map(([k,v])=>({v:k,t:k+" ("+v+")"})));
sddBuild("dd-country",items,state.country,function(v){state.country=v;state.page=0;render();});}
function animateCount(el,target){
const dur=900,t0=performance.now();
function step(now){
const p=Math.min(1,(now-t0)/dur);
const eased=1-Math.pow(1-p,3);
el.textContent=Math.round(target*eased);
if(p<1)requestAnimationFrame(step);else el.textContent=target;
}
requestAnimationFrame(step);
}
function renderStats(){
const active=NODES.filter(n=>n.status==="active").length,avg=Math.round(NODES.reduce((s,n)=>s+n.score,0)/NODES.length);
const top=NODES.filter(n=>n.score>=85).length,reach=NODES.filter(n=>n.api_ok).length;
const activeReach=NODES.filter(n=>n.status==="active"&&n.api_ok).length;
const activeTop=NODES.filter(n=>n.status==="active"&&n.score>=85).length;
const activeAvg=active?Math.round(NODES.filter(n=>n.status==="active").reduce((s,n)=>s+n.score,0)/active):0;
document.getElementById("stats").innerHTML=
'<div class="stat"><div class="v" data-c="'+active+'">0</div><div class="l">Active nodes</div></div>'+
'<div class="stat b2"><div class="v" data-c="'+activeReach+'">0</div><div class="l">API reachable</div></div>'+
'<div class="stat b3"><div class="v" data-c="'+activeAvg+'">0</div><div class="l">Avg score</div></div>'+
'<div class="stat b4"><div class="v" data-c="'+activeTop+'">0</div><div class="l">Score ≥ 85</div></div>';
const leaseData=NODES.some(n=>(HIST[n.addr]||{}).l_now!=null);
if(leaseData){
const leased=NODES.filter(n=>n.status==="active"&&((HIST[n.addr]||{}).l_now||0)>0).length;
document.getElementById("stats").innerHTML+='<div class="stat b5" data-tip="Active nodes currently leased by a plan provider (on-chain). Leases rotate every 2\u20133 days \u2014 only leased nodes earn from plans."><div class="v" data-c="'+leased+'">0</div><div class="l">Leased now</div></div>';
}
document.querySelectorAll("#stats .v[data-c]").forEach(el=>animateCount(el,+el.dataset.c));
}
function filtered(){let r=NODES.slice();
if(state.showInactive)r=r.filter(n=>n.status!=="active");
else r=r.filter(n=>n.status==="active");
if(state.filters.api)r=r.filter(n=>n.api_ok);
if(state.country)r=r.filter(n=>n.country===state.country);
if(state.protocol)r=r.filter(n=>n.protocol===state.protocol);
if(state.search){const q=state.search.toLowerCase();r=r.filter(n=>n.moniker.toLowerCase().includes(q)||n.addr.toLowerCase().includes(q)||n.city.toLowerCase().includes(q));}
const k=state.sortKey,d=state.sortDir;
r.sort((a,b)=>{let av=a[k],bv=b[k];if(av==null)av=-1;if(bv==null)bv=-1;
if(typeof av==="string"){av=av.toLowerCase();bv=(""+bv).toLowerCase();return av<bv?-d:av>bv?d:0;}return (av-bv)*d;});
return r;}
function sc(s){return s>=80?"s-hi":s>=55?"s-mid":"s-lo";}
// Reliability grade: letter rating from the 0-100 historical score.
function grade(s){
if(s>=90)return "A+"; if(s>=82)return "A"; if(s>=73)return "B";
if(s>=60)return "C"; if(s>=45)return "D"; return "E";
}
// Global rank of a node by score among ACTIVE nodes (1 = best).
function globalRank(n){
if(n.status!=="active")return null;
const act=NODES.filter(x=>x.status==="active").sort((a,b)=>b.score-a.score);
const pos=act.findIndex(x=>x.addr===n.addr)+1;
return pos>0?{pos:pos,total:act.length}:null;
}
function pr(p){return p==null?'<span class="na">\u2014</span>':'<span class="price">'+p+'</span>';}
function updateShowing(total){
const el=document.getElementById("showing");if(!el)return;
let what="active nodes";
if(state.showInactive)what="inactive nodes (inactive + stale)";
if(state.filters.api)what=what.replace("nodes","API-reachable nodes");
const parts=[];
if(state.country)parts.push("in "+state.country);
if(state.protocol)parts.push(state.protocol);
if(state.search)parts.push('matching "'+state.search+'"');
const suffix=parts.length?(" "+parts.join(", ")):"";
el.innerHTML="Showing <b>"+total+"</b> "+what+suffix;
}
function render(){
const data=filtered();
const total=data.length,start=state.page*state.perPage,rows=data.slice(start,start+state.perPage);
updateShowing(total);
if(!data.length){updateShowing(0);document.getElementById("content").innerHTML='<div class="loading">no nodes match these filters</div>';document.getElementById("pager").style.display="none";return;}
const cols=[["rank","#"],["moniker","Node"],["score","Score"],["status","Status"],["protocol","Proto"],["price_hr","P2P/hr"],["price_gb","P2P/GB"],["peers","Peers"],["uptime","Uptime%"],["country","Country"],["asn","ASN"]];
let html='<table><thead><tr>';
cols.forEach(([k,l])=>{const ar=state.sortKey===k?(state.sortDir>0?"\u25b2":"\u25bc"):"";
const hs=["status","protocol","price_hr","price_gb","peers","uptime","country","asn"].includes(k);
html+='<th '+(k!=="rank"?'onclick="sortBy(\''+k+'\')"':'')+' class="'+(hs?'hide-sm':'')+'">'+l+' <span class="ar">'+ar+'</span></th>';});
html+='</tr></thead><tbody>';
rows.forEach((n,i)=>{const rank=start+i+1,rk=rank<=3?"rank top":"rank";
html+='<tr class="node-row" onclick="inspect(\''+n.addr+'\')"><td class="'+rk+'">'+rank+'</td>'+
'<td><div class="mon'+(n.hasName?'':' na')+'">'+esc(n.moniker)+'</div><div class="addr mono">'+n.addr.slice(0,15)+'\u2026'+n.addr.slice(-5)+'</div></td>'+
'<td><span class="score '+sc(n.score)+'">'+n.score+'</span><div class="sbar"><i style="width:'+n.score+'%"></i></div></td>'+
'<td><span class="badge '+(n.status==="active"?"b-act":n.status==="stale"?"b-sta":"b-ina")+'">'+n.status+'</span>'+(n.sla_pass===true?' <span class="badge b-act" data-tip="Passed the official Sentinel SLA test (test.sentinel.co): a real tunnel was opened and measured">SLA \u2713</span>':n.sla_pass===false?' <span class="badge b-ina" data-tip="Failed the official Sentinel SLA test: '+esc(n.sla_err||"error")+'">SLA \u2717</span>':'')+'</td>'+
'<td class="hide-sm">'+(n.protocol?'<span class="proto '+n.protocol+'">'+n.protocol+'</span>':'<span class="na">\u2014</span>')+'</td>'+
'<td class="hide-sm">'+pr(n.price_hr)+'</td><td class="hide-sm">'+pr(n.price_gb)+'</td>'+
'<td class="mono hide-sm">'+(n.peers!=null?n.peers:'<span class="na">\u2014</span>')+'</td>'+
'<td class="mono hide-sm">'+(n.samples?n.uptime.toFixed(0):'<span class="na">\u2014</span>')+'</td>'+
'<td class="flag hide-sm">'+esc(n.country)+(n.city&&n.city!=="\u2014"?" \u00b7 "+esc(n.city):"")+'</td>'+
'<td class="hide-sm">'+asnCell(n.asn)+'</td></tr>';});
html+='</tbody></table>';
document.getElementById("content").innerHTML=html;
const pager=document.getElementById("pager");pager.style.display="flex";
const end=Math.min(start+state.perPage,total);
document.getElementById("pginfo").textContent=(start+1)+"\u2013"+end+" of "+total;
document.getElementById("prev").disabled=state.page===0;document.getElementById("next").disabled=end>=total;
}
// ===== INSPECTOR =====
function inspect(addr){
showInspector();
const n=MAP[addr]||NODES.find(x=>x.moniker&&x.moniker.toLowerCase()===(""+addr).toLowerCase());
document.getElementById("insp-input").value=addr;
if(!n){document.getElementById("insp-body").innerHTML='<div class="insp-empty"><a href="#" onclick="backToExplorer();return false" style="color:var(--good)">‹ back to list</a><br><br>Node not found: '+esc(addr)+'</div>';return;}
renderInspector(n);
}
function showInspector(){
// se sono in un'altra tab (Top Reliable / ASN / Trends), torno a Explorer dove vive l'inspector
document.querySelectorAll("#maintabs .tab").forEach(t=>t.classList.toggle("active",t.dataset.view==="explorer"));
["explorer","asnintel","leaderboard","trends","decent"].forEach(k=>{
const el=document.getElementById("view-"+k); if(el)el.style.display=(k==="explorer")?"block":"none";
});
document.getElementById("explorer-list").style.display="none";
document.getElementById("view-inspector").style.display="block";
}
function countryRank(n){
if(n.country==="\u2014")return null;
const peers=NODES.filter(x=>x.country===n.country).sort((a,b)=>b.score-a.score);
const pos=peers.findIndex(x=>x.addr===n.addr)+1;
return {pos:pos,total:peers.length};
}
function detectProblems(n){
const P=[];
if(n.status==="stale")P.push(["warn","<span class=dot-warn>\u25cf</span>","Node data is STALE \u2014 last seen "+(n.age_h?n.age_h.toFixed(0)+"h":">24h")+" ago. Node likely offline."]);
else if(n.status!=="active")P.push(["bad","<span class=dot-bad>\u25cf</span>","Node is INACTIVE on-chain \u2014 not earning. Check container & heartbeat."]);
if(n.samples>3&&n.uptime<90)P.push(["warn","<span class=dot-warn>\u25cf</span>","Historical uptime "+n.uptime.toFixed(0)+"% \u2014 below 90%. Node flaps offline."]);
if(n.samples>3&&n.stab<80)P.push(["warn","<span class=dot-warn>\u25cf</span>","Low stability ("+n.stab.toFixed(0)+") \u2014 frequent active/inactive transitions."]);
if(!n.api_ok&&n.status!=="stale")P.push(["warn","<span class=dot-warn>\u25cf</span>","Node API not reachable from scanners \u2014 ports/forwarding may be off."]);
if(n.freshness<40&&n.status==="active")P.push(["warn","<span class=dot-warn>\u25cf</span>","Stale heartbeat \u2014 last status update is overdue."]);
const ph=HIST[n.addr]||{};
// inline ASN context: the info comes to the user, not the other way around
const myA=n.asn?asnNum(n.asn):null;
let asnStat="",asnLink="";
if(myA){
const same=NODES.filter(x=>x.status==="active"&&x.asn&&asnNum(x.asn)===myA);
const hasL=same.some(x=>(HIST[x.addr]||{}).l_now!=null);
const led=same.filter(x=>((HIST[x.addr]||{}).l_now||0)>0).length;
asnStat=" Your ASN "+myA+" hosts "+same.length+" active node"+(same.length===1?"":"s")+(hasL?" \u2014 "+Math.round(led/Math.max(1,same.length)*100)+"% of them currently leased":"")+".";
asnLink=' <a href="#" onclick="goAsn(\''+esc(myA)+'\');return false" style="color:var(--good);font-weight:700">Open '+esc(myA)+' in ASN Intelligence \u2192</a>';
}
if(n.status==="active"){
if(ph.l_now!=null){
// on-chain lease truth (collector v3.7+). l_pos exists only after >=1 day of lease samples.
if(ph.l_now===0&&ph.l_pos!=null&&ph.l_pos>=50)
P.push(["warn","\u2191","Lease LOST \u2014 this node was leased "+ph.l_pos+"% of recent days but currently has NO active lease from any plan provider. Nothing to fix node-side: plan rotations usually return within 1\u20133 days.."+asnStat+asnLink]);
else if(ph.l_now===0&&ph.l_pos!=null&&ph.l_pos<10&&n.uptime>=90&&ph.n>30)
P.push(["warn","\u2717","Never leased \u2014 healthy node but plan providers don\u2019t lease it (on-chain). Likely not whitelisted (PlanWizard ASN/region policy)."+asnStat+asnLink]);
else if(ph.l_now===0&&ph.l_pos==null&&ph.p_pos!=null&&ph.p_pos>=50&&ph.n>30)
P.push(["warn","\u2191","No active lease right now (on-chain) \u2014 yet this node had client traffic in "+ph.p_pos+"% of recent days, so its lease most likely just rotated away. Nothing to fix node-side: rotations usually return within 1\u20133 days.."+asnStat+asnLink]);
else if(ph.l_now>0&&(n.peers||0)===0)
P.push(["info","\u2193","Leased \u2713 but no client sessions right now \u2014 normal lull, traffic follows demand."]);
}else if(ph.p_pos!=null&&ph.n>30&&n.uptime>=90&&ph.p_pos<=5)
P.push(["warn","\u2717","Healthy node but NO client traffic \u2014 peers were 0 in "+(100-ph.p_pos)+"% of recent samples. Usually NOT a node fault: the node likely isn\u2019t whitelisted for plans (PlanWizard ASN/region policy), so it never gets leases."+asnStat+asnLink]);
else if(ph.p_pos!=null&&ph.n>30&&n.uptime>=90&&ph.p_pos<=30)
P.push(["info","\u25cf","Sparse client traffic \u2014 peers seen in only "+ph.p_pos+"% of recent samples (avg "+(ph.p_avg!=null?ph.p_avg:"?")+"). Whitelisted but low demand, or borderline placement."]);
else if(n.peers===0)
P.push(["info","\u2139\ufe0f","0 peers right now \u2014 normal for a fresh node, watch over time."]);
}
// --- rules based on network comparison (medians) ---
if(n.status==="active"&&n.price_hr!=null&&MED.price_hr){
if(n.price_hr>MED.price_hr*2.5)P.push(["warn","<span class=dot-warn>\u25cf</span>","Price/hr ("+n.price_hr+") is far above the network median ("+MED.price_hr+") \u2014 fewer clients will pick this node."]);
else if(n.price_hr>0&&n.price_hr<MED.price_hr*0.4)P.push(["info","\u2139\ufe0f","Price/hr ("+n.price_hr+") well below median ("+MED.price_hr+") \u2014 you may be leaving rewards on the table."]);
}
if(n.status==="active"&&n.ul_mbps!=null){
// ABSOLUTE threshold, not relative to median: network median is inflated by datacenters
// (symmetric fiber). A residential node serves clients fine with few Mbps.
// Flag only bandwidth so low it actually harms service.
if(n.ul_mbps>0&&n.ul_mbps<5)P.push(["warn","<span class=dot-warn>\u25cf</span>","Upload "+n.ul_mbps+" Mbps is very low \u2014 may struggle to serve client sessions."]);
}
// Semver-aware version check. Compares NUMERICALLY, not against the most
// common version: right after a release the majority is on the OLD version,
// and the old logic told early adopters of the new one to "update" backwards.
function semverCmp(a,b){
const pa=String(a).replace(/^v/i,"").split(".").map(x=>parseInt(x,10)||0);
const pb=String(b).replace(/^v/i,"").split(".").map(x=>parseInt(x,10)||0);
for(let i=0;i<3;i++){const d=(pa[i]||0)-(pb[i]||0);if(d)return d;}
return 0;
}
if(n.status==="active"&&n.version&&MED.topVersion){
const cmp=semverCmp(n.version,MED.topVersion);
const mine=(MED.versionCounts[n.version]||0),top=(MED.versionCounts[MED.topVersion]||0);
if(cmp<0&&top>mine*3){
P.push(["info","\u2139\ufe0f","Running v"+n.version+" \u2014 older than v"+MED.topVersion+" used by most active nodes. Consider updating."]);
}else if(cmp>0){
P.push(["ok","\u2705","Running v"+n.version+" \u2014 ahead of the network (most nodes are on v"+MED.topVersion+"). Early adopter."]);
}
}
if(asnSaturated(n.asn))P.push(["warn","<span class=dot-warn>\u25cf</span>","On a saturated ASN ("+asnSaturated(n.asn)+") \u2014 over the PlanWizard whitelist limit, may not get leased/earn."+asnStat+asnLink]);
// --- HISTORICAL trend from the per-node timeline (unique to the Scorecard) ---
const h=HIST[n.addr];
if(h&&h.tl&&h.tl.length>=60&&n.status!=="stale"){
const tl=h.tl, third=Math.floor(tl.length/3);
const up=(s)=>{let a=0;for(const c of s)if(c==="1")a++;return a/s.length*100;};
const recent=up(tl.slice(-third)), earlier=up(tl.slice(0,tl.length-third));
if(recent-earlier<=-12)P.push(["warn","<span class=dot-bad>\u25bc</span>","Uptime is DECLINING \u2014 "+earlier.toFixed(0)+"% earlier vs "+recent.toFixed(0)+"% in recent samples. Something changed: check power, network, watchdog."]);
else if(recent-earlier>=12&&n.uptime<97)P.push(["ok","<span class=dot-good>\u25b2</span>","Recovering \u2014 recent uptime "+recent.toFixed(0)+"% vs "+earlier.toFixed(0)+"% earlier. Keep it up."]);
}
// price per GB vs network
if(n.status==="active"&&n.price_gb!=null&&MED.price_gb){
if(n.price_gb>MED.price_gb*2.5)P.push(["warn","<span class=dot-warn>\u25cf</span>","Price/GB ("+n.price_gb+") far above median ("+MED.price_gb+") \u2014 clients comparing prices will skip this node."]);
}
// young node: track record still forming
if(n.status==="active"&&n.samples>0&&n.samples<=10)P.push(["info","\u25cf","Young node ("+n.samples+" samples) \u2014 grade and uptime not meaningful yet, check back in a few days."]);
// active + API ok but no speedtest data
if(n.status==="active"&&n.api_ok&&n.ul_mbps==null&&n.dl_mbps==null)P.push(["info","\u2139\ufe0f","No bandwidth data \u2014 the node hasn\u2019t reported a speedtest yet (runs on its own schedule)."]);
// crowded country: heavy competition for leases
if(n.status==="active"&&n.country){
const cc=NODES.filter(x=>x.status==="active"&&x.country===n.country).length;
if(cc>=200)P.push(["info","\u25cf\ufe0f","Crowded country \u2014 "+cc+" active nodes in "+n.country+". More competition for client sessions."]);
}
// elite recognition
if(n.status==="active"&&n.score>=90){
const gr2=globalRank(n);
if(gr2&&gr2.pos<=20)P.push(["ok","\u2605","Elite node \u2014 grade "+grade(n.score)+", ranked #"+gr2.pos+" of "+gr2.total+" network-wide."]);
}
// --- IP condiviso: piu' nodi sullo stesso host dividono attenzione e affidabilita' percepita ---
if(n.status==="active"&&n.remote){
if(!window._ipdup){
window._ipdup={};
NODES.forEach(x=>{if(x.status==="active"&&x.remote){const h=x.remote.split(":")[0];window._ipdup[h]=(window._ipdup[h]||0)+1;}});
}
const others=(window._ipdup[n.remote.split(":")[0]]||1)-1;
if(others>=1)P.push(["info","\u25cf","Shares its IP/host with "+others+" other active node"+(others===1?"":"s")+" \u2014 multiple nodes on one address split traffic and can look suspicious to plan providers."]);
}
// --- remote = IP grezzo su rete residenziale: alla prima rotazione IP il nodo sparisce ---
if(n.status==="active"&&n.hosting===false&&n.remote&&/^\d+\.\d+\.\d+\.\d+/.test(n.remote))
P.push(["warn","\u25cf","Remote announced as a RAW IP on a residential-class network \u2014 when the ISP rotates the IP, the node becomes unreachable. Use a DDNS hostname instead."]);
// --- prezzo zero: configurazione rotta, il nodo lavora gratis o viene scartato ---
if(n.status==="active"&&(n.price_hr===0||n.price_gb===0))
P.push(["warn","\u00a4","A price is set to ZERO ("+(n.price_hr===0?"hourly":"per-GB")+") \u2014 likely a misconfiguration."]);
// --- token di pagamento incompleti ---
if(n.status==="active"&&typeof n.gb_tokens==="number"&&n.gb_tokens>0&&n.gb_tokens<3)
P.push(["info","\u25cf","Accepts only "+n.gb_tokens+"/3 payment tokens \u2014 plans paying with the missing tokens can\u2019t use this node."]);
// --- moniker assente ---
if(n.status==="active"&&!n.hasName)
P.push(["info","\u25cf\ufe0f","No moniker set \u2014 anonymous nodes are harder to find, trust and get feedback on. Set one in config.toml."]);
if(n.sla_pass===false)P.push(["warn","<span class=dot-bad>\u25cf</span>","FAILED the official Sentinel SLA test ("+esc(n.sla_err||"error")+") \u2014 a real tunnel could not be established or performed. The strongest signal this node is not serving clients right now."]);
if(!P.length)P.push(["ok","\u2705","No problems detected \u2014 node looks healthy."]);
return P;
}
function medRef(key,v){
const m=MED[key];if(!m)return "";
const rel=v>m*1.15?"above":v<m*0.85?"below":"\u2248";
const txt=rel==="\u2248"?"\u2248 median "+m:rel+" median "+m;
return ' <span class="medref">'+txt+'</span>';
}
function ulRef(v){
if(!MED.ulArr||!MED.ulArr.length||!v)return "";
const p=pctRank(MED.ulArr,v);if(p==null)return "";
const lbl=p>=75?"top "+(100-p)+"%":p<=25?"bottom "+p+"%":"median "+MED.ul_mbps.toFixed(0);
return ' <span class="medref">'+lbl+'</span>';
}
// Full checkup: every external check the Scorecard runs, with its pass state.
function passedChecks(n){
const ph=HIST[n.addr]||{};
const out=[];
const add=(ok,name)=>{if(ok)out.push(name);};
add(n.status==="active","On-chain active");
add(n.status!=="stale","Heartbeat fresh");
add(!!n.api_ok,"API reachable");
add(n.uptime>=90,"Uptime \u226590%");
add(n.stab>=80,"Stability");
add(n.samples>10,"Track record");
if(ph.tl&&ph.tl.length>=60){
const third=Math.floor(ph.tl.length/3);
const up=(s)=>{let a=0;for(const c of s)if(c==="1")a++;return a/s.length*100;};
add(up(ph.tl.slice(-third))-up(ph.tl.slice(0,ph.tl.length-third))>-12,"Uptime trend");
}
add(!(asnSaturated(n.asn)),"ASN not saturated");
if(n.price_hr!=null&&MED.price_hr)add(n.price_hr>0&&n.price_hr<=MED.price_hr*2.5,"Price /hr sane");
if(n.price_gb!=null&&MED.price_gb)add(n.price_gb>0&&n.price_gb<=MED.price_gb*2.5,"Price /GB sane");
if(n.ul_mbps!=null)add(n.ul_mbps>=1,"Bandwidth");
if(typeof n.gb_tokens==="number")add(n.gb_tokens>=3,"Payment tokens 3/3");
add(!!n.hasName,"Moniker set");
if(n.sla_pass!=null)add(n.sla_pass===true,"Official SLA test");
if(ph.l_now!=null)add(ph.l_now>0,"Leased (on-chain)");
if(ph.p_pos!=null)add(ph.p_pos>30,"Client traffic");
if(n.remote){if(!window._ipdup){window._ipdup={};NODES.forEach(x=>{if(x.status==="active"&&x.remote){const h=x.remote.split(":")[0];window._ipdup[h]=(window._ipdup[h]||0)+1;}});}
add((window._ipdup[n.remote.split(":")[0]]||1)<=1,"Dedicated IP");}
if(n.hosting===false&&n.remote)add(!/^\d+\.\d+\.\d+\.\d+/.test(n.remote),"DDNS hostname");
return out;
}
// Community support: set the operator wallet below to show a discreet support line.
// Leave empty and the section simply does not exist. No popups, no nags - this tool
// is free infrastructure for the Sentinel community, support is optional and quiet.
// Add one line per coin you want to accept. Leave the array empty to hide the section.
// Example: {coin:"P2P", addr:"sent1..."}, {coin:"XMR", addr:"4..."}
const DONATE_ADDRS=[
{coin:"P2P", addr:"sent1kszl24wkudvwefmdsv64zrxy27qzcfadnr407q"},
{coin:"XMR", addr:"84FkJeqmdKqAUpqzAaWqwSEd9SBW7TAZ66mTRUuQ8aoGXzVYqQGCi4647kPPvsMDbs8txLikzK5iEbZE1jaJRmThUdd7A4N"}
];
function renderSupport(){
const list=DONATE_ADDRS.filter(d=>d.addr&&d.addr.trim());
if(!list.length)return;
document.getElementById("support").innerHTML=
'Independent & community-run \u2014 no ads, no trackers, no backend. '+
'If the Scorecard saved you a bad VPS or a lost lease, you can fuel its servers:'+
'<div class="donrow">'+list.map(d=>
'<span class="addr" data-tip="Click to copy '+esc(d.coin)+' address" onclick="copyAddr(this)" data-a="'+esc(d.addr)+'"><b>'+esc(d.coin)+'</b> \u00b7 '+esc(d.addr)+' \u29c9</span>'
).join("")+'</div>';
}
function copyAddr(el){
if(navigator.clipboard)navigator.clipboard.writeText(el.dataset.a);
const o=el.innerHTML; el.innerHTML="\u2713 address copied \u2014 thank you";
setTimeout(()=>{el.innerHTML=o;},1500);
}
function copyCmd(el){
const t=el.dataset.cmd||el.textContent;
if(navigator.clipboard)navigator.clipboard.writeText(t);
const old=el.textContent; el.textContent="\u2713 copied \u2014 paste it on the node\u2019s machine";
setTimeout(()=>{el.textContent=old;},1400);
}
function renderInspector(n){
const rank=countryRank(n);
const gr=globalRank(n);
const W={uptime:32,stability:15,longevity:25,freshness:13,health:10,tokens:5};
const EXPL={
uptime:"How often the node was active across all collected samples. Weight 32%. The single biggest factor \u2014 measured, not self-reported.",
stability:"Penalizes frequent active\u2194inactive flips. Weight 15%. A node that flaps offline scores low even with decent uptime.",
longevity:"Length of the node's track record (samples collected vs the network's longest). Weight 25%. New nodes start low and climb with time \u2014 trust is earned.",
freshness:"Recency of the on-chain heartbeat at sample time. Weight 13%. Measures whether the node updated its status on schedule.",
health:"Active on-chain (60) + node API reachable by scanners (40). Weight 10%. Reflects whether the node is publicly responsive.",
tokens:"Number of accepted payment denoms configured. Weight 5%. More payment options = reachable to more client apps."
};
const br=Object.entries(n.comps).map(([k,v])=>{const pct=Math.round((v/W[k])*100);
return '<div class="br"><span class="lbl">'+k+'<span class="qm" data-tip="'+esc(EXPL[k]||"")+'">?</span></span><span class="bar"><i style="width:'+pct+'%"></i></span><span class="pct">'+pct+'</span></div>';}).join("");
let tl='<span class="na" style="font-family:Space Mono;font-size:12px">no history yet \u2014 collector needs time</span>';
if(n.tl){tl='<div class="timeline">'+n.tl.split("").map(c=>'<span class="tlc '+(c==="1"?"a":"i")+'" data-tip="'+(c==="1"?"active":"inactive")+'"></span>').join("")+'</div>'+
'<div style="font-size:10.5px;color:var(--dim);font-family:Space Mono;margin-top:6px">'+(n.tl_from?fmtShort(n.tl_from):"")+' \u2192 '+(n.tl_to?fmtShort(n.tl_to):"")+' · '+n.tl.length+' samples</div>';}
const probs=detectProblems(n).map(([cl,ic,t])=>'<div class="problem '+cl+'"><span class="pic">'+ic+'</span><span>'+t+'</span></div>').join("");
const pc=passedChecks(n);
// Local checkup: the Scorecard sees the node from the OUTSIDE. For the inside,
// it hands the operator the exact commands - highlighted by detected problems.
const ptxt=detectProblems(n).map(p=>p[2]).join(" ");
const hotC=/not reachable|INACTIVE|stale/i.test(ptxt); // container/clock issues
const hotP=/DECLINING|flaps|Stability|unstable/i.test(ptxt); // power/heat/sd
const LC=[
["\u25cf","Container alive","no container, no node \u2014 rule it out first","docker ps --format '{{.Names}} {{.Status}}'",hotC],
["\u21bb","Crash loops","silent restarts show up here","docker inspect --format '{{.RestartCount}}' $(docker ps -q | head -1)",hotC||hotP],
["\u2261","Recent errors","the container\u2019s own words, last hour","docker logs $(docker ps -q | head -1) --since 1h 2>&1 | grep -iE \"error|fatal\" | tail -10",hotC],
["\u25cf\ufe0f","Power & heat (Pi)","get_throttled MUST say 0x0 \u2014 anything else is bad power or overheating","vcgencmd measure_temp; vcgencmd get_throttled",hotP],
["\u25a0","Disk space","a full disk kills databases quietly","df -h /",hotP],
["\u25cf","Memory","swapping = stutters = dropped sessions","free -h",false],
["\u23f0","Clock sync","a skewed clock breaks TLS and on-chain timing","timedatectl | grep -i synchron",hotC]
];
const anyHot=LC.some(x=>x[4]);
const localChk='<details class="lchk"'+(anyHot?' open':'')+'><summary>\u25cf Operator\u2019s local checkup \u2014 the parts no external scanner can see'+(anyHot?' <span style="color:var(--warn)">(suggested for the issues above)</span>':'')+'</summary>'+
'<div class="lc-intro">The Scorecard watches this node from the outside. These commands check the <b>inside</b> \u2014 run them on the node\u2019s machine. Click a command to copy it. (Adjust the container name if needed.)</div>'+
LC.map(([ic,name,why,cmd,hot])=>'<div class="lc-item'+(hot?' hot':'')+'"><div class="lc-name" data-tip="'+esc(why)+'">'+ic+' '+name+' <span class="why">\u2014 '+esc(why)+'</span></div><code onclick="copyCmd(this)" data-cmd="'+esc(cmd)+'">'+esc(cmd)+'</code></div>').join("")+
'</details>';
const passedHtml=pc.length?'<div style="margin-top:11px;font-size:10.5px;text-transform:uppercase;letter-spacing:1px;color:var(--muted)">'+pc.length+' checks passed</div><div style="margin-top:6px">'+pc.map(c=>'<span class="pchip">\u2713 '+c+'</span>').join("")+'</div>':"";
document.getElementById("insp-body").innerHTML=
'<span class="back" onclick="backToExplorer()">\u2039 back to list</span>'+
'<div class="insp-grid"><div class="card">'+
'<div class="ihead"><div><div class="imon">'+esc(n.moniker)+'</div><div class="iaddr">'+n.addr+'</div>'+
'<div class="imeta"><span class="badge '+(n.status==="active"?"b-act":n.status==="stale"?"b-sta":"b-ina")+'">'+n.status+'</span>'+
(n.protocol?'<span class="proto '+n.protocol+'">'+n.protocol+'</span>':'')+
'<span class="proto">'+esc(n.country)+(n.city!=="\u2014"?" \u00b7 "+esc(n.city):"")+'</span>'+
(n.version?'<span class="proto">v'+esc(n.version)+'</span>':'')+
(acLevel(n.protocol)?'<span class="acens '+acLevel(n.protocol).cls+'" data-tip="'+acLevel(n.protocol).tip+'">\u26e8 anti-censorship: '+acLevel(n.protocol).label+'</span>':'')+'</div></div>'+
'<div style="text-align:right"><div class="ibig" style="color:'+(n.score>=80?'var(--good)':n.score>=55?'var(--warn)':'var(--bad)')+'">'+n.score+'</div>'+
'<div style="font-size:10.5px;color:var(--muted);text-transform:uppercase;letter-spacing:1px">score</div>'+
'<div style="margin-top:7px;font-family:Space Mono,monospace;font-weight:700;font-size:20px;color:'+(n.score>=80?'var(--good)':n.score>=55?'var(--warn)':'var(--bad)')+'">'+grade(n.score)+'</div>'+
(gr?'<div style="font-size:10px;color:var(--muted);font-family:Space Mono,monospace">#'+gr.pos+' of '+gr.total+'</div>':'')+
'</div></div>'+
(rank?'<div class="rankbox" style="margin:14px 0 4px">Country rank: <b>#'+rank.pos+'</b> of '+rank.total+' in '+esc(n.country)+'</div>':'')+
'<h3 style="margin-top:18px">Uptime timeline</h3>'+tl+
'<h3 style="margin-top:18px">Key metrics</h3><div class="kv">'+
'<span class="k">Historical uptime</span><span class="v">'+(n.samples?n.uptime.toFixed(1)+"%":"\u2014")+'</span>'+
'<span class="k">Stability</span><span class="v">'+(n.samples?n.stab.toFixed(0):"\u2014")+'</span>'+
'<span class="k">History samples</span><span class="v">'+n.samples+'</span>'+
((HIST[n.addr]||{}).l_now!=null?'<span class="k" data-tip="On-chain leases held right now. A node can be rented by several plan providers at once \u2014 the number in parentheses is how many simultaneous leases. More leases = more income streams, but it depends on plan demand, not node quality.">Leased (now) <span class="ttip">?</span></span><span class="v" style="color:'+(((HIST[n.addr]||{}).l_now||0)>0?'var(--good)':'var(--warn)')+'">'+(((HIST[n.addr]||{}).l_now||0)>0?'\u2713 yes ('+HIST[n.addr].l_now+(HIST[n.addr].l_now>1?' simultaneous':'')+')':'no')+'</span>':'')+
((HIST[n.addr]||{}).l_pos!=null?'<span class="k">Lease uptime (7d)</span><span class="v">'+HIST[n.addr].l_pos+'%</span>':'')+
'<span class="k">Peers (now)</span><span class="v" id="peers-live">'+(n.peers!=null?n.peers:"\u2014")+' <span id="peers-badge" style="font-size:10px;color:var(--muted);font-family:Space Mono">\u00b7 collector</span></span>'+
'<span class="k">Price /hr</span><span class="v">'+(n.price_hr!=null?n.price_hr+" P2P"+medRef("price_hr",n.price_hr):"\u2014")+'</span>'+
'<span class="k">Price /GB</span><span class="v">'+(n.price_gb!=null?n.price_gb+" P2P"+medRef("price_gb",n.price_gb):"\u2014")+'</span>'+
'<span class="k" data-tip="Official Sentinel SLA test (test.sentinel.co): a real tunnel is opened through the node and speed is measured. The strongest proof a node actually serves clients.">SLA test <span class="ttip">?</span></span><span class="v">'+(n.sla_pass===true?'<span style="color:var(--good)">\u2713 PASS'+(n.sla_mbps!=null?" \u00b7 "+n.sla_mbps+" Mbps":"")+'</span>':n.sla_pass===false?'<span style="color:var(--bad)">\u2717 FAIL'+(n.sla_err?" \u00b7 "+esc(n.sla_err):"")+'</span>':'\u2014')+'</span>'+
'<span class="k">Upload</span><span class="v">'+(n.ul_mbps!=null?n.ul_mbps+" Mbps"+ulRef(n.ul_mbps):"\u2014")+'</span>'+
'<span class="k">Remote</span><span class="v">'+esc(n.remote||"\u2014")+'</span>'+
'<span class="k">Hosting</span><span class="v">'+(n.asn?esc(asnNum(n.asn))+" \u00b7 "+esc(asnName(n.asn)):"\u2014")+'</span></div>'+
'</div><div class="card">'+
'<h3>Score breakdown</h3>'+br+
'<h3 style="margin-top:20px">Diagnostics</h3>'+probs+passedHtml+localChk+
(detectProblems(n).some(p=>p[0]==="warn"||p[0]==="bad")?'<button class="repbtn" onclick="genReport(\''+n.addr+'\')" data-tip="Build a ready-to-share report of this node\u2019s issues \u2014 for support or your Telegram alert channel">\u25cf Generate issue report</button>':"")+
'<div class="report" id="report"></div>'+
'<div id="nodeadvisor" style="margin-top:22px"><h3>NodeAdvisor \u2014 community feedback</h3><div id="na-body" class="na-loading">loading feedback\u2026</div></div>'+
'</div></div>';
// direct LIVE fetch to node API: updates "Peers (now)" with the real current value
livePeers(n.remote);
loadFeedback(n.addr);
}
function livePeers(remote){
if(!remote){return;}
// remote is like "host:port" - build the https url
const url="https://"+remote.replace(/^https?:\/\//,"")+"/";
const badge=document.getElementById("peers-badge");
const cell=document.getElementById("peers-live");
if(badge)badge.textContent="\u00b7 checking live\u2026";
const ctrl=new AbortController();
const to=setTimeout(()=>ctrl.abort(),6000);
fetch(url+"?t="+Date.now(),{signal:ctrl.signal,cache:"no-store"})
.then(r=>r.ok?r.json():null)
.then(d=>{
clearTimeout(to);
if(!cell)return;
const p=d&&d.result?d.result.peers:null;
if(p!=null){
cell.innerHTML=p+" <span style='font-size:10px;color:var(--good);font-family:Space Mono'>\u00b7 <span class=dot-good>\u25cf</span> LIVE</span>";
}else if(badge){
badge.textContent="\u00b7 collector (live n/d)";
}
})
.catch(()=>{
clearTimeout(to);
// self-signed cert or CORS or node unreachable from browser -> keep collector value
if(badge)badge.textContent="\u00b7 collector (live blocked)";
});
}
// ===== NodeAdvisor =====
// voter hash: STABLE browser fingerprint (survives cache clearing).
// Computed from signals that do not change: user-agent, language, timezone, screen, platform,
// CPU cores, memory. Does NOT use IP (privacy) and does NOT depend on localStorage.
// Stops refresh+clear-cache (same hash); does not stop different browsers or incognito
// with altered settings - a stated limit of any anti-spam without login.
function fnv1a(str){
let h=0x811c9dc5;
for(let i=0;i<str.length;i++){ h^=str.charCodeAt(i); h=Math.imul(h,0x01000193); }
return (h>>>0).toString(36);
}
function voterHash(){
try{
const tz=Intl.DateTimeFormat().resolvedOptions().timeZone||"";
const parts=[
navigator.userAgent||"",
navigator.language||"",
(navigator.languages||[]).join(","),
navigator.platform||"",
(screen.width+"x"+screen.height+"x"+(screen.colorDepth||"")),
(screen.availWidth+"x"+screen.availHeight),
tz,
(new Date().getTimezoneOffset()),
(navigator.hardwareConcurrency||""),
(navigator.deviceMemory||""),
(navigator.maxTouchPoints||0)
].join("|");
// double hash on two slices to reduce collisions
return "fp"+fnv1a(parts)+fnv1a(parts.split("").reverse().join(""));
}catch(e){
// fallback: old localStorage method if fingerprint cannot be computed
let v=null;
try{ v=localStorage.getItem("na_voter"); }catch(_){}
if(!v){ v="v"+Math.random().toString(36).slice(2)+Date.now().toString(36); try{ localStorage.setItem("na_voter",v); }catch(_){} }
return v;
}
}
function votedNodes(){
try{ return JSON.parse(localStorage.getItem("na_voted")||"[]"); }catch(e){ return []; }
}
function markVoted(addr){
try{ const a=votedNodes(); if(!a.includes(addr)){ a.push(addr); localStorage.setItem("na_voted",JSON.stringify(a)); } }catch(e){}
}
function sbHeaders(){
return {"apikey":SB_KEY,"Authorization":"Bearer "+SB_KEY,"Content-Type":"application/json"};
}
function loadFeedback(addr){
const body=document.getElementById("na-body");
if(!body)return;
const url=SB_URL+"/rest/v1/node_feedback?node_addr=eq."+encodeURIComponent(addr)+"&hidden=eq.false&select=tags,comment,created_at&order=created_at.desc&limit=100";
fetch(url,{headers:sbHeaders()})
.then(r=>r.ok?r.json():Promise.reject(r.status))
.then(rows=>renderFeedback(addr,rows))
.catch(err=>{
body.innerHTML='<div class="na-err">Community feedback not available right now.</div>';
});
}
function renderFeedback(addr,rows){
const body=document.getElementById("na-body");
if(!body)return;
rows=Array.isArray(rows)?rows:[];
// aggregated tag count
const counts={};
rows.forEach(r=>(r.tags||[]).forEach(t=>{counts[t]=(counts[t]||0)+1;}));
const sorted=Object.entries(counts).sort((a,b)=>b[1]-a[1]);
let summary="";
if(rows.length===0){
summary='<div class="na-empty">No community feedback yet. Be the first to rate this node.</div>';
}else{
summary='<div class="na-count">'+rows.length+' feedback'+(rows.length>1?"s":"")+'</div><div class="na-tags-sum">'+
sorted.map(([id,c])=>{const t=NA_TAGMAP[id];const lbl=t?t.label:id;const cls=t?(t.pos?"pos":"neg"):"";
return '<span class="na-tagchip '+cls+'">'+esc(lbl)+' <b>'+c+'</b></span>';}).join("")+'</div>';
}
// recent comments (only those with text)
const comments=rows.filter(r=>r.comment&&r.comment.trim()).slice(0,8);
let clist="";
if(comments.length){
clist='<div class="na-comments"><div class="na-sub">Recent comments</div>'+
comments.map(r=>'<div class="na-comment"><span class="na-ctext">'+esc(r.comment)+'</span><span class="na-cdate">'+fmtShort(r.created_at)+'</span></div>').join("")+'</div>';
}
// form or "already voted"
let form="";
if(votedNodes().includes(addr)){
form='<div class="na-voted">\u2713 You\u2019ve already given feedback on this node.</div>';
}else{
form='<div class="na-form">'+
'<div class="na-sub">Your experience with this node</div>'+
'<div class="na-checks">'+
NA_TAGS.map(t=>'<label class="na-chk '+(t.pos?"pos":"neg")+'"><input type="checkbox" value="'+t.id+'"> '+esc(t.label)+'</label>').join("")+
'</div>'+
'<textarea id="na-comment" maxlength="500" placeholder="Optional comment (max 500 chars)\u2026"></textarea>'+
'<button class="na-submit" onclick="submitFeedback(\''+addr+'\')">Send feedback</button>'+
'<div class="na-note">One vote per node. Be fair \u2014 other operators rely on this.</div>'+
'<div class="na-privacy">To prevent double-voting we use an anonymous browser fingerprint \u2014 no IP, no cookies, no cross-site tracking. It only counts one vote per device on this page.</div>'+
'</div>';
}
body.innerHTML=summary+clist+form;
}
function submitFeedback(addr){
const body=document.getElementById("na-body");
if(!body)return;
const checks=[...body.querySelectorAll(".na-checks input:checked")].map(c=>c.value);
const comment=(document.getElementById("na-comment")||{}).value||"";
if(checks.length===0 && !comment.trim()){
alert("Select at least one tag or write a comment.");
return;
}
const btn=body.querySelector(".na-submit");
if(btn){btn.disabled=true;btn.textContent="Sending\u2026";}
const payload={node_addr:addr,tags:checks,comment:comment.trim()||null,voter_hash:voterHash()};
fetch(SB_URL+"/rest/v1/node_feedback",{
method:"POST",
headers:Object.assign(sbHeaders(),{"Prefer":"return=minimal"}),
body:JSON.stringify(payload)
})
.then(r=>{
if(r.ok){ markVoted(addr); loadFeedback(addr); }
else if(r.status===409){ // unique constraint: this fingerprint already voted this node
markVoted(addr);
alert("You\u2019ve already voted on this node from this device.");
loadFeedback(addr);
}
else return Promise.reject(r.status);
})