node-red-contrib-knx-ultimate 4.1.35 → 4.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3826 +0,0 @@
1
- <!doctype html>
2
- <html lang="en">
3
-
4
- <head>
5
- <meta charset="utf-8">
6
- <meta name="viewport" content="width=device-width, initial-scale=1">
7
- <title>KNX AI Web</title>
8
- <style>
9
- @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&family=Manrope:wght@400;500;600;700;800&display=swap');
10
-
11
- :root {
12
- color-scheme: light;
13
- --bg: #f4f1ff;
14
- --panel: #ffffff;
15
- --text: #2b2446;
16
- --muted: #655f80;
17
- --line: rgba(98, 87, 142, 0.24);
18
- --accent: #7a68d8;
19
- --accent-soft: #ece8ff;
20
- --surface-soft: #f8f5ff;
21
- --shadow-sm: none;
22
- --shadow-md: none;
23
- --ok-bg: #edf9ef;
24
- --ok-border: #5bbf73;
25
- --user-bg: #eef3ff;
26
- --user-border: #8b7de0;
27
- --err-bg: #ffe9ea;
28
- --err-border: #d95b63;
29
- --warn-bg: #fff3df;
30
- --warn-border: #d99a34;
31
- --graph-edge-out: #7a68d8;
32
- --graph-edge-in: #46b86d;
33
- --knx-ai: #ad9eff;
34
- --knx-ai-soft: #f0ecff;
35
- --knx-device: #7dd484;
36
- --knx-device-soft: #edf9ef;
37
- --hue-lavender: #c0c7e9;
38
- --hue-lavender-soft: #f2f4fc;
39
- --event-write: #7a68d8;
40
- --event-response: #46b86d;
41
- --event-read: #d99a34;
42
- --event-repeat: #c34747;
43
- --event-other: #9a93b8;
44
- --pie-4: #8b7de0;
45
- --pie-5: #5aa5c9;
46
- --pie-6: #d95b63;
47
- --pie-7: #7d7699;
48
- }
49
-
50
- * {
51
- box-sizing: border-box;
52
- }
53
-
54
- body {
55
- margin: 0;
56
- font-family: "Manrope", "Avenir Next", "Segoe UI", sans-serif;
57
- color: var(--text);
58
- background: var(--bg);
59
- min-height: 100vh;
60
- padding: 14px;
61
- }
62
-
63
- #app {
64
- max-width: 1380px;
65
- margin: 0 auto;
66
- border: 1px solid var(--line);
67
- border-radius: 16px;
68
- background: #ffffff;
69
- overflow: hidden;
70
- box-shadow: var(--shadow-md);
71
- }
72
-
73
- #toolbar {
74
- padding: 12px;
75
- border-bottom: 1px solid var(--line);
76
- display: flex;
77
- align-items: center;
78
- gap: 9px;
79
- flex-wrap: wrap;
80
- background: var(--knx-ai-soft);
81
- }
82
-
83
- #toolbar select {
84
- min-width: 240px;
85
- flex: 1 1 240px;
86
- }
87
-
88
- #toolbar input[type="text"],
89
- #toolbar select,
90
- #chat-input {
91
- border: 1px solid var(--line);
92
- border-radius: 10px;
93
- padding: 8px 10px;
94
- font-size: 14px;
95
- background: #fff;
96
- color: #2d2851;
97
- transition: border-color .16s ease, background-color .16s ease;
98
- }
99
-
100
- #toolbar button,
101
- #chat-send {
102
- border: 1px solid var(--line);
103
- border-radius: 10px;
104
- background: #fff;
105
- color: var(--text);
106
- padding: 8px 10px;
107
- font-size: 13px;
108
- font-weight: 700;
109
- cursor: pointer;
110
- transition: border-color .14s ease, background-color .14s ease;
111
- }
112
-
113
- #theme-switch {
114
- display: inline-flex;
115
- align-items: center;
116
- gap: 6px;
117
- padding: 2px 0;
118
- }
119
-
120
- .knx-ai-theme-btn {
121
- min-width: 92px;
122
- font-size: 12px;
123
- padding: 6px 10px;
124
- }
125
-
126
- .knx-ai-theme-btn.active {
127
- border-color: var(--accent);
128
- background: var(--accent-soft);
129
- color: #322a60;
130
- }
131
-
132
- #toolbar button:hover,
133
- #chat-send:hover {
134
- border-color: #9385e7;
135
- background: #f3efff;
136
- }
137
-
138
- #chat-send {
139
- border-color: var(--accent);
140
- color: #fff;
141
- background: var(--accent);
142
- }
143
-
144
- #chat-send:hover {
145
- border-color: #6655c4;
146
- background: #6b59ca;
147
- }
148
-
149
- #toolbar input[type="text"]:focus,
150
- #toolbar select:focus,
151
- #chat-input:focus {
152
- outline: none;
153
- border-color: #8f82df;
154
- box-shadow: none;
155
- }
156
-
157
- #toolbar .spacer {
158
- margin-left: auto;
159
- }
160
-
161
- #status {
162
- color: var(--muted);
163
- font-size: 12px;
164
- font-weight: 600;
165
- letter-spacing: .01em;
166
- }
167
-
168
- #content {
169
- padding: 12px;
170
- display: grid;
171
- grid-template-columns: 1.1fr 1fr;
172
- gap: 12px;
173
- }
174
-
175
- @media (max-width: 900px) {
176
- #content {
177
- grid-template-columns: 1fr;
178
- }
179
- }
180
-
181
- .panel {
182
- border: 1px solid var(--line);
183
- border-radius: 13px;
184
- background: #ffffff;
185
- overflow: hidden;
186
- box-shadow: var(--shadow-sm);
187
- }
188
-
189
- .panel>h3 {
190
- margin: 0;
191
- padding: 11px 12px;
192
- border-bottom: 1px solid var(--line);
193
- font-size: 16px;
194
- font-weight: 800;
195
- letter-spacing: .01em;
196
- background: var(--knx-ai-soft);
197
- }
198
-
199
- .panel-wide {
200
- grid-column: 1 / -1;
201
- }
202
-
203
- #summary {
204
- margin: 0;
205
- padding: 12px;
206
- min-height: 220px;
207
- max-height: 360px;
208
- overflow: auto;
209
- white-space: pre-wrap;
210
- word-break: break-word;
211
- font-family: "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace;
212
- font-size: 12px;
213
- line-height: 1.35;
214
- background: #ffffff;
215
- }
216
-
217
- #anomalies {
218
- padding: 12px;
219
- min-height: 220px;
220
- max-height: 360px;
221
- overflow: auto;
222
- font-size: 13px;
223
- background: #ffffff;
224
- }
225
-
226
- .anomaly {
227
- margin-bottom: 8px;
228
- padding: 9px;
229
- border-radius: 9px;
230
- border-left: 3px solid var(--warn-border);
231
- background: var(--warn-bg);
232
- box-shadow: none;
233
- }
234
-
235
- .anomaly-meta {
236
- color: var(--muted);
237
- font-size: 11px;
238
- margin-top: 4px;
239
- margin-bottom: 6px;
240
- }
241
-
242
- .anomaly pre {
243
- margin: 0;
244
- white-space: pre-wrap;
245
- word-break: break-word;
246
- font-family: "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace;
247
- font-size: 12px;
248
- }
249
-
250
- #bus-connection {
251
- padding: 12px;
252
- background: #ffffff;
253
- }
254
-
255
- .bus-conn-empty {
256
- min-height: 96px;
257
- display: flex;
258
- align-items: center;
259
- color: var(--muted);
260
- font-size: 13px;
261
- font-weight: 600;
262
- }
263
-
264
- .bus-conn-header {
265
- display: flex;
266
- align-items: center;
267
- justify-content: space-between;
268
- gap: 10px;
269
- flex-wrap: wrap;
270
- margin-bottom: 10px;
271
- }
272
-
273
- .bus-conn-title {
274
- font-size: 13px;
275
- font-weight: 800;
276
- color: #38315d;
277
- }
278
-
279
- .bus-conn-badges {
280
- display: flex;
281
- flex-wrap: wrap;
282
- gap: 6px;
283
- }
284
-
285
- .bus-conn-pill {
286
- display: inline-flex;
287
- align-items: center;
288
- gap: 5px;
289
- padding: 4px 8px;
290
- border-radius: 999px;
291
- border: 1px solid var(--line);
292
- background: #f7f4ff;
293
- color: #463e6d;
294
- font-size: 11px;
295
- font-weight: 800;
296
- letter-spacing: .01em;
297
- }
298
-
299
- .bus-conn-pill-connected {
300
- background: var(--ok-bg);
301
- border-color: var(--ok-border);
302
- color: #2e7d46;
303
- }
304
-
305
- .bus-conn-pill-disconnected {
306
- background: var(--err-bg);
307
- border-color: var(--err-border);
308
- color: #b13d46;
309
- }
310
-
311
- .bus-conn-pill-muted {
312
- background: #f7f4ff;
313
- border-color: #d7cffa;
314
- color: #655f80;
315
- }
316
-
317
- .bus-conn-track {
318
- position: relative;
319
- height: 18px;
320
- border-radius: 999px;
321
- overflow: hidden;
322
- border: 1px solid #ddd7f2;
323
- background: linear-gradient(180deg, #f6f3ff 0%, #efebfb 100%);
324
- }
325
-
326
- .bus-conn-track::after {
327
- content: "";
328
- position: absolute;
329
- inset: 0;
330
- border-radius: inherit;
331
- box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8);
332
- pointer-events: none;
333
- }
334
-
335
- .bus-conn-segment {
336
- position: absolute;
337
- top: 0;
338
- bottom: 0;
339
- min-width: 2px;
340
- }
341
-
342
- .bus-conn-segment-connected {
343
- background: linear-gradient(90deg, #4ad06d 0%, #38c45f 100%);
344
- }
345
-
346
- .bus-conn-segment-disconnected {
347
- background: linear-gradient(90deg, #eb6a74 0%, #d94b55 100%);
348
- }
349
-
350
- .bus-conn-scale {
351
- display: grid;
352
- grid-template-columns: 1fr 1fr 1fr;
353
- gap: 8px;
354
- margin-top: 8px;
355
- color: var(--muted);
356
- font-size: 12px;
357
- font-weight: 700;
358
- }
359
-
360
- .bus-conn-scale span:nth-child(2) {
361
- text-align: center;
362
- }
363
-
364
- .bus-conn-scale span:nth-child(3) {
365
- text-align: right;
366
- }
367
-
368
- .bus-conn-note {
369
- margin-top: 9px;
370
- color: #6a6386;
371
- font-size: 12px;
372
- line-height: 1.35;
373
- }
374
-
375
- #chat-wrap {
376
- margin: 10px;
377
- border: 1px solid var(--line);
378
- border-radius: 12px;
379
- overflow: hidden;
380
- box-shadow: none;
381
- }
382
-
383
- #ask-panel {
384
- margin: 0 12px 12px;
385
- border-color: #b7d8be;
386
- background: var(--knx-device-soft);
387
- }
388
-
389
- #ask-panel>h3 {
390
- background: #e3f3e6;
391
- border-bottom-color: #b7d8be;
392
- }
393
-
394
- #ask-panel #chat-wrap {
395
- border-color: #b7d8be;
396
- background: #eff9f1;
397
- }
398
-
399
- #ask-panel #chat-log {
400
- background: #f7fdf8;
401
- }
402
-
403
- #ask-panel .chat-presets {
404
- background: #edf8ef;
405
- border-top-color: #cde2d2;
406
- }
407
-
408
- #ask-panel .chat-input-row {
409
- background: #eef9f0;
410
- border-top-color: #cde2d2;
411
- }
412
-
413
- #insights-panel {
414
- margin: 0 12px 12px;
415
- }
416
-
417
- #insights-grid {
418
- display: grid;
419
- grid-template-columns: 1.4fr 1fr;
420
- gap: 12px;
421
- padding: 12px;
422
- }
423
-
424
- @media (max-width: 1100px) {
425
- #insights-grid {
426
- grid-template-columns: 1fr;
427
- }
428
- }
429
-
430
- .insight-card {
431
- border: 1px solid var(--line);
432
- border-radius: 13px;
433
- background: #ffffff;
434
- overflow: hidden;
435
- min-height: 260px;
436
- box-shadow: var(--shadow-sm);
437
- }
438
-
439
- .insight-wide {
440
- grid-column: 1 / -1;
441
- min-height: 0;
442
- }
443
-
444
- .insight-title {
445
- margin: 0;
446
- padding: 9px 12px;
447
- border-bottom: 1px solid var(--line);
448
- background: var(--knx-ai-soft);
449
- font-size: 15px;
450
- font-weight: 800;
451
- color: #31285f;
452
- }
453
-
454
- .insight-meta {
455
- padding: 8px 12px;
456
- border-bottom: 1px solid var(--line);
457
- color: var(--muted);
458
- font-size: 12px;
459
- background: #f5f2ff;
460
- }
461
-
462
- .flow-controls {
463
- display: flex;
464
- align-items: center;
465
- gap: 8px;
466
- flex-wrap: wrap;
467
- padding: 9px 12px;
468
- border-bottom: 1px solid var(--line);
469
- background: #f7f4ff;
470
- font-size: 12px;
471
- color: #564d81;
472
- }
473
-
474
- .flow-controls label {
475
- display: inline-flex;
476
- align-items: center;
477
- gap: 5px;
478
- }
479
-
480
- .flow-controls input[type="number"],
481
- .flow-controls input[type="text"] {
482
- border: 1px solid #c9c0ef;
483
- border-radius: 8px;
484
- padding: 4px 7px;
485
- font-size: 12px;
486
- color: #342f58;
487
- background: #fff;
488
- }
489
-
490
- .flow-controls input[type="number"] {
491
- width: 66px;
492
- }
493
-
494
- .flow-controls input[type="text"] {
495
- min-width: 130px;
496
- }
497
-
498
- .flow-controls button {
499
- border: 1px solid #c7bfea;
500
- border-radius: 8px;
501
- background: #ffffff;
502
- color: #3b3365;
503
- font-size: 12px;
504
- font-weight: 700;
505
- padding: 4px 9px;
506
- cursor: pointer;
507
- transition: border-color .14s ease, background-color .14s ease;
508
- }
509
-
510
- .flow-controls button:hover {
511
- background: #f2eeff;
512
- border-color: #998bdc;
513
- }
514
-
515
- .flow-legend {
516
- display: flex;
517
- flex-wrap: wrap;
518
- gap: 10px;
519
- align-items: center;
520
- padding: 8px 12px 9px;
521
- border-top: 1px solid var(--line);
522
- border-bottom: 1px solid var(--line);
523
- background: #f7f4ff;
524
- color: #4a416f;
525
- font-size: 11px;
526
- font-weight: 700;
527
- }
528
-
529
- .flow-legend-item {
530
- display: inline-flex;
531
- align-items: center;
532
- gap: 5px;
533
- white-space: nowrap;
534
- }
535
-
536
- .flow-legend-help {
537
- display: flex;
538
- flex-wrap: wrap;
539
- gap: 9px 14px;
540
- align-items: center;
541
- padding: 7px 12px 8px;
542
- border-bottom: 1px solid var(--line);
543
- background: #fbf9ff;
544
- color: #4f4874;
545
- font-size: 11px;
546
- font-weight: 600;
547
- line-height: 1.25;
548
- }
549
-
550
- .flow-legend-help-item {
551
- display: inline-flex;
552
- align-items: center;
553
- gap: 6px;
554
- }
555
-
556
- .flow-legend-help-note {
557
- color: #6b648b;
558
- font-weight: 500;
559
- }
560
-
561
- .flow-legend-bubble {
562
- width: 10px;
563
- height: 10px;
564
- border-radius: 50%;
565
- border: 1.8px solid #7a68d8;
566
- background: #f0ecff;
567
- display: inline-block;
568
- flex: 0 0 10px;
569
- }
570
-
571
- .flow-legend-bubble-node {
572
- border-color: #4caf66;
573
- background: #edf9ef;
574
- }
575
-
576
- .flow-legend-bubble-disabled {
577
- border-color: #c5bddf;
578
- background: #f1edf8;
579
- }
580
-
581
- .flow-legend-bubble-ext {
582
- border-color: #d99a34;
583
- background: #fff8ea;
584
- }
585
-
586
- .flow-legend-ext {
587
- font-size: 9px;
588
- color: #9a6b24;
589
- border: 1px solid #e4c68d;
590
- border-radius: 6px;
591
- padding: 0 4px;
592
- font-weight: 800;
593
- letter-spacing: .05em;
594
- background: #fff8ea;
595
- line-height: 15px;
596
- height: 15px;
597
- display: inline-flex;
598
- align-items: center;
599
- }
600
-
601
- .flow-legend-line {
602
- width: 19px;
603
- height: 0;
604
- border-top: 3px solid #a29bbd;
605
- border-radius: 2px;
606
- }
607
-
608
- .flow-legend-write {
609
- border-top-color: #7a68d8;
610
- }
611
-
612
- .flow-legend-response {
613
- border-top-color: #46b86d;
614
- }
615
-
616
- .flow-legend-read {
617
- border-top-color: #d99a34;
618
- }
619
-
620
- .flow-legend-repeat {
621
- border-top-color: #c62828;
622
- }
623
-
624
- .flow-legend-idle {
625
- border-top-color: #8c97ad;
626
- opacity: 0.42;
627
- }
628
-
629
- .flow-ga-list-wrap {
630
- padding: 8px 12px 10px;
631
- border-bottom: 1px solid var(--line);
632
- background: #f8f6ff;
633
- }
634
-
635
- #flow-ga-select {
636
- width: 100%;
637
- min-height: 90px;
638
- max-height: 120px;
639
- border: 1px solid #c9c0ef;
640
- border-radius: 9px;
641
- font-size: 12px;
642
- color: #3a3561;
643
- background: #fff;
644
- padding: 4px;
645
- }
646
-
647
- #flow-ga-select option.flow-ga-stale {
648
- color: #8c87a8;
649
- }
650
-
651
- #flow-ga-select option.flow-ga-external {
652
- color: #9a6b24;
653
- }
654
-
655
- #flow-wrap {
656
- position: relative;
657
- min-height: 420px;
658
- padding: 12px;
659
- background: #ffffff;
660
- }
661
-
662
- #flow-svg {
663
- width: 100%;
664
- height: 420px;
665
- display: block;
666
- }
667
-
668
- .viz-empty {
669
- position: absolute;
670
- inset: 0;
671
- display: flex;
672
- align-items: center;
673
- justify-content: center;
674
- pointer-events: none;
675
- background: rgba(255, 255, 255, 0.88);
676
- }
677
-
678
- .flow-edge {
679
- fill: none;
680
- stroke-width: 1.6;
681
- stroke-linecap: round;
682
- vector-effect: non-scaling-stroke;
683
- opacity: 0.92;
684
- }
685
-
686
- .flow-edge-out {
687
- /* stroke/marker are applied inline based on telegram type */
688
- }
689
-
690
- .flow-edge-hot {
691
- opacity: 1;
692
- filter: none;
693
- }
694
-
695
- .flow-edge-in {
696
- /* stroke/marker are applied inline based on telegram type */
697
- }
698
-
699
- .flow-edge-active {
700
- stroke-dasharray: 10 7;
701
- animation: graph-flow 1.3s linear infinite;
702
- }
703
-
704
- .flow-edge-idle {
705
- opacity: 0.36;
706
- stroke-dasharray: none;
707
- animation: none;
708
- }
709
-
710
- .flow-edge-external-link {
711
- opacity: 0.82;
712
- }
713
-
714
- .flow-edge-external-link.flow-edge-idle {
715
- opacity: 0.30;
716
- }
717
-
718
- .flow-node circle {
719
- fill: #f0ecff;
720
- stroke: #7a68d8;
721
- stroke-width: 2;
722
- }
723
-
724
- .flow-node-kind-node:not(.flow-node-disabled):not(.flow-node-anomaly) circle {
725
- fill: #edf9ef;
726
- stroke: #4caf66;
727
- stroke-width: 2;
728
- }
729
-
730
- .flow-node-kind-node:not(.flow-node-disabled) .flow-node-label {
731
- fill: #2f7f48;
732
- }
733
-
734
- .flow-node-anomaly circle {
735
- fill: #fff1f1;
736
- stroke: #e53935;
737
- stroke-width: 2.2;
738
- }
739
-
740
- .flow-node-disabled circle {
741
- fill: #f1edf8;
742
- stroke: #c5bddf;
743
- stroke-width: 1.8;
744
- }
745
-
746
- .flow-node-disabled .flow-node-label,
747
- .flow-node-disabled .flow-node-subtitle,
748
- .flow-node-disabled .flow-node-payload {
749
- fill: #9a93b8;
750
- }
751
-
752
- .flow-node-unmapped:not(.flow-node-disabled) circle {
753
- fill: #fff8ea;
754
- stroke: #c98a1a;
755
- stroke-dasharray: 4 2;
756
- }
757
-
758
- .flow-node-unmapped .flow-node-subtitle,
759
- .flow-node-unmapped .flow-node-payload {
760
- fill: #8f641d;
761
- }
762
-
763
- .flow-node-label {
764
- font-size: 12px;
765
- fill: #342f58;
766
- text-anchor: middle;
767
- dominant-baseline: middle;
768
- font-weight: 700;
769
- }
770
-
771
- .flow-node-subtitle {
772
- font-size: 10px;
773
- fill: #665f86;
774
- text-anchor: middle;
775
- font-weight: 500;
776
- }
777
-
778
- .flow-node-badge {
779
- font-size: 10px;
780
- fill: #b3261e;
781
- text-anchor: middle;
782
- font-weight: 700;
783
- }
784
-
785
- .flow-node-payload {
786
- font-size: 9px;
787
- fill: #645d84;
788
- text-anchor: middle;
789
- font-weight: 500;
790
- font-family: "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace;
791
- }
792
-
793
- .flow-node-ext-tag {
794
- font-size: 8px;
795
- fill: #9a6b24;
796
- text-anchor: middle;
797
- font-weight: 800;
798
- letter-spacing: .06em;
799
- }
800
-
801
- .flow-node-disabled .flow-node-ext-tag {
802
- fill: #a39cbf;
803
- }
804
-
805
- .pie-wrap {
806
- display: flex;
807
- align-items: center;
808
- gap: 12px;
809
- padding: 12px;
810
- min-height: 210px;
811
- }
812
-
813
- .pie-svg {
814
- width: 160px;
815
- height: 160px;
816
- flex: 0 0 160px;
817
- }
818
-
819
- .pie-legend {
820
- display: flex;
821
- flex-direction: column;
822
- gap: 6px;
823
- min-width: 0;
824
- font-size: 12px;
825
- }
826
-
827
- .pie-legend-item {
828
- display: flex;
829
- align-items: center;
830
- gap: 7px;
831
- color: #423b66;
832
- min-width: 0;
833
- }
834
-
835
- .pie-dot {
836
- width: 10px;
837
- height: 10px;
838
- border-radius: 50%;
839
- flex: 0 0 10px;
840
- }
841
-
842
- .pie-label {
843
- overflow: hidden;
844
- text-overflow: ellipsis;
845
- white-space: nowrap;
846
- }
847
-
848
- .stack-wrap {
849
- padding: 12px;
850
- display: flex;
851
- flex-direction: column;
852
- gap: 7px;
853
- max-height: 260px;
854
- overflow: auto;
855
- }
856
-
857
- .stack-row {
858
- display: grid;
859
- grid-template-columns: 150px 1fr 54px;
860
- gap: 8px;
861
- align-items: center;
862
- font-size: 12px;
863
- padding: 4px 6px;
864
- border-radius: 8px;
865
- background: #f8f6ff;
866
- }
867
-
868
- .stack-label {
869
- overflow: hidden;
870
- text-overflow: ellipsis;
871
- white-space: nowrap;
872
- color: #433c66;
873
- }
874
-
875
- .stack-track {
876
- height: 11px;
877
- border-radius: 6px;
878
- border: 1px solid #d7cffa;
879
- background: #f0ecff;
880
- position: relative;
881
- overflow: hidden;
882
- }
883
-
884
- .stack-total {
885
- height: 100%;
886
- display: flex;
887
- border-radius: 6px;
888
- overflow: hidden;
889
- min-width: 1px;
890
- }
891
-
892
- .stack-seg {
893
- height: 100%;
894
- }
895
-
896
- .stack-value {
897
- text-align: right;
898
- color: #665f86;
899
- font-variant-numeric: tabular-nums;
900
- }
901
-
902
- @keyframes graph-flow {
903
- from {
904
- stroke-dashoffset: 0;
905
- }
906
-
907
- to {
908
- stroke-dashoffset: -34;
909
- }
910
- }
911
-
912
- #chat-log {
913
- padding: 12px;
914
- min-height: 200px;
915
- max-height: 340px;
916
- overflow: auto;
917
- background: #fff;
918
- }
919
-
920
- .chat-msg {
921
- margin-bottom: 8px;
922
- padding: 9px 10px;
923
- border-radius: 10px;
924
- font-size: 14px;
925
- line-height: 1.4;
926
- box-shadow: none;
927
- }
928
-
929
- .chat-user {
930
- border-left: 3px solid var(--user-border);
931
- background: var(--user-bg);
932
- }
933
-
934
- .chat-assistant {
935
- border-left: 3px solid var(--ok-border);
936
- background: var(--ok-bg);
937
- }
938
-
939
- .chat-error {
940
- border-left: 3px solid var(--err-border);
941
- background: var(--err-bg);
942
- }
943
-
944
- .chat-pending {
945
- border-left: 3px solid #8b84ad;
946
- background: #f2eff8;
947
- color: #4f496d;
948
- }
949
-
950
- .chat-msg p {
951
- margin: 0 0 6px;
952
- }
953
-
954
- .chat-msg p:last-child {
955
- margin-bottom: 0;
956
- }
957
-
958
- .chat-msg pre {
959
- margin: 6px 0;
960
- padding: 6px;
961
- border-radius: 4px;
962
- background: rgba(0, 0, 0, 0.05);
963
- overflow: auto;
964
- }
965
-
966
- .chat-msg code {
967
- font-family: "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace;
968
- font-size: 12px;
969
- }
970
-
971
- .chat-msg table {
972
- width: 100%;
973
- border-collapse: collapse;
974
- min-width: 520px;
975
- }
976
-
977
- .chat-msg th,
978
- .chat-msg td {
979
- border: 1px solid rgba(0, 0, 0, 0.12);
980
- padding: 4px 6px;
981
- }
982
-
983
- .chat-msg th {
984
- background: rgba(0, 0, 0, 0.03);
985
- }
986
-
987
- .chat-svg-wrap {
988
- margin: 8px 0;
989
- padding: 8px;
990
- border: 1px solid rgba(122, 104, 216, 0.3);
991
- border-radius: 9px;
992
- background: #fff;
993
- }
994
-
995
- .chat-svg-wrap svg {
996
- display: block;
997
- width: 100%;
998
- height: auto;
999
- max-height: 380px;
1000
- }
1001
-
1002
- .chat-input-row {
1003
- display: flex;
1004
- gap: 8px;
1005
- border-top: 1px solid var(--line);
1006
- background: #f8f6ff;
1007
- padding: 10px;
1008
- }
1009
-
1010
- .chat-presets {
1011
- display: flex;
1012
- flex-wrap: wrap;
1013
- gap: 6px;
1014
- padding: 10px;
1015
- border-top: 1px solid var(--line);
1016
- background: #f3f6fc;
1017
- }
1018
-
1019
- .chat-preset-btn {
1020
- border: 1px solid #c7bfea;
1021
- border-radius: 16px;
1022
- background: #ffffff;
1023
- color: #3e3664;
1024
- font-size: 12px;
1025
- font-weight: 700;
1026
- padding: 5px 11px;
1027
- cursor: pointer;
1028
- transition: border-color .14s ease, background-color .14s ease;
1029
- }
1030
-
1031
- .chat-preset-btn:hover {
1032
- background: #f2eeff;
1033
- border-color: #998bdc;
1034
- }
1035
-
1036
- #chat-input {
1037
- flex: 1 1 auto;
1038
- }
1039
-
1040
- .empty {
1041
- color: #7a7498;
1042
- font-style: italic;
1043
- }
1044
-
1045
- .analyzing-state {
1046
- display: flex;
1047
- align-items: flex-start;
1048
- gap: 10px;
1049
- border: 1px solid #d7cffa;
1050
- border-radius: 10px;
1051
- padding: 10px;
1052
- background: #f8f6ff;
1053
- color: #4a426f;
1054
- }
1055
-
1056
- .analyzing-state-small {
1057
- margin-top: 6px;
1058
- padding: 8px;
1059
- border-radius: 8px;
1060
- border: 1px solid #d7cffa;
1061
- background: #f8f6ff;
1062
- }
1063
-
1064
- .analyzing-icon {
1065
- width: 16px;
1066
- height: 16px;
1067
- border: 2px solid #b9addf;
1068
- border-top-color: #6f5ac9;
1069
- border-radius: 50%;
1070
- animation: knx-spin 0.9s linear infinite;
1071
- margin-top: 1px;
1072
- flex: 0 0 16px;
1073
- }
1074
-
1075
- .analyzing-title {
1076
- font-size: 12px;
1077
- font-weight: 800;
1078
- color: #3f3764;
1079
- margin-bottom: 2px;
1080
- }
1081
-
1082
- .analyzing-sub {
1083
- font-size: 11px;
1084
- color: #665f86;
1085
- line-height: 1.35;
1086
- }
1087
-
1088
- .analyzing-ga-list {
1089
- margin-top: 4px;
1090
- font-size: 11px;
1091
- color: #5d567b;
1092
- line-height: 1.35;
1093
- word-break: break-word;
1094
- }
1095
-
1096
- .analyzing-what {
1097
- margin-top: 6px;
1098
- font-size: 11px;
1099
- line-height: 1.35;
1100
- color: #5a5477;
1101
- }
1102
-
1103
- #summary,
1104
- #anomalies,
1105
- .stack-wrap,
1106
- #chat-log {
1107
- scrollbar-width: thin;
1108
- scrollbar-color: #b3abd6 #efeaf8;
1109
- }
1110
-
1111
- #summary::-webkit-scrollbar,
1112
- #anomalies::-webkit-scrollbar,
1113
- .stack-wrap::-webkit-scrollbar,
1114
- #chat-log::-webkit-scrollbar {
1115
- width: 10px;
1116
- height: 10px;
1117
- }
1118
-
1119
- #summary::-webkit-scrollbar-thumb,
1120
- #anomalies::-webkit-scrollbar-thumb,
1121
- .stack-wrap::-webkit-scrollbar-thumb,
1122
- #chat-log::-webkit-scrollbar-thumb {
1123
- background: #b3abd6;
1124
- border-radius: 12px;
1125
- border: 2px solid #efeaf8;
1126
- }
1127
-
1128
- #summary::-webkit-scrollbar-track,
1129
- #anomalies::-webkit-scrollbar-track,
1130
- .stack-wrap::-webkit-scrollbar-track,
1131
- #chat-log::-webkit-scrollbar-track {
1132
- background: #efeaf8;
1133
- border-radius: 12px;
1134
- }
1135
-
1136
- @keyframes knx-spin {
1137
- from {
1138
- transform: rotate(0deg);
1139
- }
1140
-
1141
- to {
1142
- transform: rotate(360deg);
1143
- }
1144
- }
1145
- </style>
1146
- <link id="knx-ai-theme-link" rel="stylesheet" href="">
1147
- </head>
1148
-
1149
- <body>
1150
- <div id="app">
1151
- <div id="toolbar">
1152
- <div id="theme-switch" title="Theme">
1153
- <button type="button" class="knx-ai-theme-btn" data-theme="mix">Node Mix</button>
1154
- <button type="button" class="knx-ai-theme-btn" data-theme="green">KNX Green</button>
1155
- <button type="button" class="knx-ai-theme-btn" data-theme="lavender">Hue Lavender</button>
1156
- </div>
1157
- <select id="node-select"></select>
1158
- <button type="button" id="refresh-nodes">Refresh Node List</button>
1159
- <label style="display:flex;align-items:center;gap:5px;font-size:13px;">
1160
- <input type="checkbox" id="auto-refresh">
1161
- <span>Auto</span>
1162
- </label>
1163
- <span class="spacer"></span>
1164
- <span id="status">Ready</span>
1165
- </div>
1166
- <div class="panel" id="insights-panel">
1167
- <h3>Traffic Dashboard</h3>
1168
- <div id="insights-grid">
1169
- <div class="insight-card insight-wide">
1170
- <div class="insight-title">Flow Map (Top Links)</div>
1171
- <div id="flow-meta" class="insight-meta">Nodes: 0 | Links: 0</div>
1172
- <div class="flow-controls">
1173
- <label>Max GA
1174
- <input type="number" id="flow-max-ga" min="4" max="60" step="1" value="14">
1175
- </label>
1176
- <label>Find
1177
- <input type="text" id="flow-ga-search" placeholder="Search GA/name">
1178
- </label>
1179
- <button type="button" id="flow-clear-ga">Clear</button>
1180
- <button type="button" id="flow-reset-layout">Reset Layout</button>
1181
- </div>
1182
- <div class="flow-legend" id="flow-color-legend">
1183
- <span class="flow-legend-item"><span
1184
- class="flow-legend-line flow-legend-write"></span>Write</span>
1185
- <span class="flow-legend-item"><span
1186
- class="flow-legend-line flow-legend-read"></span>Read</span>
1187
- <span class="flow-legend-item"><span
1188
- class="flow-legend-line flow-legend-response"></span>Response</span>
1189
- <span class="flow-legend-item"><span
1190
- class="flow-legend-line flow-legend-repeat"></span>Repeat</span>
1191
- <span class="flow-legend-item"><span class="flow-legend-line flow-legend-idle"></span>Idle
1192
- memory</span>
1193
- </div>
1194
- <div class="flow-legend-help" id="flow-legend-help">
1195
- <span class="flow-legend-help-item">
1196
- <span class="flow-legend-line flow-legend-write"></span>
1197
- Active arrow
1198
- <span class="flow-legend-help-note">telegram currently flowing</span>
1199
- </span>
1200
- <span class="flow-legend-help-item">
1201
- <span class="flow-legend-line flow-legend-idle"></span>
1202
- Idle arrow
1203
- <span class="flow-legend-help-note">last known direction, no recent traffic</span>
1204
- </span>
1205
- <span class="flow-legend-help-item">
1206
- <span class="flow-legend-bubble flow-legend-bubble-node"></span>
1207
- Active bubble
1208
- <span class="flow-legend-help-note">group address active in the current window</span>
1209
- </span>
1210
- <span class="flow-legend-help-item">
1211
- <span class="flow-legend-bubble flow-legend-bubble-disabled"></span>
1212
- Disabled bubble
1213
- <span class="flow-legend-help-note">no recent telegrams, kept visible for context</span>
1214
- </span>
1215
- <span class="flow-legend-help-item">
1216
- <span class="flow-legend-bubble flow-legend-bubble-ext"></span>
1217
- <span class="flow-legend-ext">EXT</span>
1218
- <span class="flow-legend-help-note">external GA detected on bus, not mapped in Node-RED
1219
- flow</span>
1220
- </span>
1221
- </div>
1222
- <div class="flow-ga-list-wrap">
1223
- <select id="flow-ga-select" multiple size="6"></select>
1224
- </div>
1225
- <div id="flow-wrap">
1226
- <svg id="flow-svg" viewBox="0 0 1000 420" preserveAspectRatio="xMidYMid meet"></svg>
1227
- <div id="flow-empty" class="viz-empty empty">No patterns/anomalies to display.</div>
1228
- </div>
1229
- </div>
1230
- <div class="insight-card">
1231
- <div class="insight-title">Events Mix (Pie)</div>
1232
- <div class="pie-wrap">
1233
- <svg id="event-pie-svg" class="pie-svg" viewBox="0 0 180 180"
1234
- preserveAspectRatio="xMidYMid meet"></svg>
1235
- <div id="event-pie-legend" class="pie-legend"></div>
1236
- </div>
1237
- </div>
1238
- <div class="insight-card">
1239
- <div class="insight-title">Anomalies by Type (Pie)</div>
1240
- <div class="pie-wrap">
1241
- <svg id="anomaly-pie-svg" class="pie-svg" viewBox="0 0 180 180"
1242
- preserveAspectRatio="xMidYMid meet"></svg>
1243
- <div id="anomaly-pie-legend" class="pie-legend"></div>
1244
- </div>
1245
- </div>
1246
- <div class="insight-card insight-wide">
1247
- <div class="insight-title">Top Links by Event (Stacked)</div>
1248
- <div id="edge-stack-wrap" class="stack-wrap"></div>
1249
- </div>
1250
- </div>
1251
- </div>
1252
- <div id="content">
1253
- <div class="panel">
1254
- <h3>Summary</h3>
1255
- <pre id="summary"></pre>
1256
- </div>
1257
- <div class="panel">
1258
- <h3>Anomalies</h3>
1259
- <div id="anomalies"></div>
1260
- </div>
1261
- <div class="panel panel-wide">
1262
- <h3>Bus Connection Persistence</h3>
1263
- <div id="bus-connection"></div>
1264
- </div>
1265
- </div>
1266
- <div class="panel" id="ask-panel">
1267
- <h3>Ask</h3>
1268
- <div id="chat-wrap">
1269
- <div id="chat-log"></div>
1270
- <div class="chat-presets">
1271
- <button type="button" class="chat-preset-btn"
1272
- data-question="Generate an SVG bar chart of Top Group Addresses with counts and title.">Graph
1273
- Top GA</button>
1274
- <button type="button" class="chat-preset-btn"
1275
- data-question="Generate an SVG pie chart of the distribution of KNX events (write/read/response/repeat).">Graph
1276
- Events</button>
1277
- <button type="button" class="chat-preset-btn"
1278
- data-question="Generate an SVG line chart of KNX traffic over time using available summary data.">Graph
1279
- Trend</button>
1280
- </div>
1281
- <div class="chat-input-row">
1282
- <input type="text" id="chat-input" placeholder="Ask a question about KNX traffic...">
1283
- <button type="button" id="chat-send">Send</button>
1284
- </div>
1285
- </div>
1286
- </div>
1287
- </div>
1288
-
1289
- <script type="text/javascript">
1290
- (function () {
1291
- // Local storage keys for UI preferences (selected node, auto-refresh, flow filters).
1292
- const storageKey = 'knxUltimateAI:selectedNodeId';
1293
- const autoKey = 'knxUltimateAI:autoRefresh';
1294
- const themeKey = 'knxUltimateAI:theme';
1295
- let queryNodeId = (() => {
1296
- try {
1297
- return new URLSearchParams(window.location.search).get('nodeId') || '';
1298
- } catch (error) {
1299
- return '';
1300
- }
1301
- })();
1302
- // Build API URLs relative to this page URL.
1303
- const apiUrl = (tail) => new URL(tail, window.location.href).toString();
1304
-
1305
- // Cache all frequently accessed DOM references once.
1306
- const $nodeSelect = document.getElementById('node-select');
1307
- const $refreshNodes = document.getElementById('refresh-nodes');
1308
- const $auto = document.getElementById('auto-refresh');
1309
- const $status = document.getElementById('status');
1310
- const $themeLink = document.getElementById('knx-ai-theme-link');
1311
- const $themeBtns = Array.from(document.querySelectorAll('.knx-ai-theme-btn'));
1312
- const $summary = document.getElementById('summary');
1313
- const $anomalies = document.getElementById('anomalies');
1314
- const $busConnection = document.getElementById('bus-connection');
1315
- const $flowSvg = document.getElementById('flow-svg');
1316
- const $flowWrap = document.getElementById('flow-wrap');
1317
- const $flowEmpty = document.getElementById('flow-empty');
1318
- const $flowMeta = document.getElementById('flow-meta');
1319
- const $flowMaxGa = document.getElementById('flow-max-ga');
1320
- const $flowGaSearch = document.getElementById('flow-ga-search');
1321
- const $flowGaSelect = document.getElementById('flow-ga-select');
1322
- const $flowClearGa = document.getElementById('flow-clear-ga');
1323
- const $flowResetLayout = document.getElementById('flow-reset-layout');
1324
- const $eventPieSvg = document.getElementById('event-pie-svg');
1325
- const $eventPieLegend = document.getElementById('event-pie-legend');
1326
- const $anomalyPieSvg = document.getElementById('anomaly-pie-svg');
1327
- const $anomalyPieLegend = document.getElementById('anomaly-pie-legend');
1328
- const $edgeStackWrap = document.getElementById('edge-stack-wrap');
1329
- const $chatLog = document.getElementById('chat-log');
1330
- const $chatInput = document.getElementById('chat-input');
1331
- const $chatSend = document.getElementById('chat-send');
1332
- const $chatPresetBtns = Array.from(document.querySelectorAll('.chat-preset-btn'));
1333
-
1334
- // Runtime caches used to preserve UI stability between refreshes.
1335
- let nodesCache = [];
1336
- let pendingChatEl = null;
1337
- let stateInterval = null;
1338
- let nodesInterval = null;
1339
- let stateRequestInFlight = false;
1340
- let lastDashboardRawData = null;
1341
- let resizeHandle = null;
1342
- const flowFilterKey = 'knxUltimateAI:flowMapFilters';
1343
- let flowEdgeCache = new Map();
1344
- let flowNodeCache = new Map();
1345
-
1346
- // Normalize persisted filter payloads to a safe, bounded shape.
1347
- const parseFlowFilters = (raw) => {
1348
- const defaults = { maxGa: 14, selectedGa: [], gaOrder: [], layoutOrder: [], edgeOrder: [] };
1349
- if (!raw || typeof raw !== 'object') return defaults;
1350
- const maxGaNum = Number(raw.maxGa);
1351
- const maxGa = Number.isFinite(maxGaNum) ? Math.max(4, Math.min(60, Math.round(maxGaNum))) : defaults.maxGa;
1352
- const selectedGa = Array.isArray(raw.selectedGa)
1353
- ? Array.from(new Set(raw.selectedGa.map(x => String(x || '').trim()).filter(Boolean))).slice(0, 200)
1354
- : [];
1355
- const gaOrder = Array.isArray(raw.gaOrder)
1356
- ? Array.from(new Set(raw.gaOrder.map(x => String(x || '').trim()).filter(Boolean))).slice(0, 600)
1357
- : [];
1358
- const layoutOrder = Array.isArray(raw.layoutOrder)
1359
- ? Array.from(new Set(raw.layoutOrder.map(x => String(x || '').trim()).filter(Boolean))).slice(0, 600)
1360
- : [];
1361
- const edgeOrder = Array.isArray(raw.edgeOrder)
1362
- ? Array.from(new Set(raw.edgeOrder.map(x => String(x || '').trim()).filter(Boolean))).slice(0, 4000)
1363
- : [];
1364
- return { maxGa, selectedGa, gaOrder, layoutOrder, edgeOrder };
1365
- };
1366
-
1367
- // Read flow filter settings from local storage.
1368
- const loadFlowFilters = () => {
1369
- try {
1370
- if (!window.localStorage) return parseFlowFilters(null);
1371
- const raw = window.localStorage.getItem(flowFilterKey);
1372
- if (!raw) return parseFlowFilters(null);
1373
- return parseFlowFilters(JSON.parse(raw));
1374
- } catch (error) {
1375
- return parseFlowFilters(null);
1376
- }
1377
- };
1378
-
1379
- // Persist flow filter settings after normalization.
1380
- const saveFlowFilters = (filters) => {
1381
- try {
1382
- if (!window.localStorage) return;
1383
- window.localStorage.setItem(flowFilterKey, JSON.stringify(parseFlowFilters(filters || {})));
1384
- } catch (error) { }
1385
- };
1386
-
1387
- let flowFilters = loadFlowFilters();
1388
-
1389
- // Restore the last selected KNX AI node.
1390
- const loadStoredNode = () => {
1391
- try {
1392
- return window.localStorage ? window.localStorage.getItem(storageKey) : '';
1393
- } catch (error) {
1394
- return '';
1395
- }
1396
- };
1397
-
1398
- // Persist selected KNX AI node for next page load.
1399
- const storeNode = (value) => {
1400
- try {
1401
- if (window.localStorage) window.localStorage.setItem(storageKey, value || '');
1402
- } catch (error) { }
1403
- };
1404
-
1405
- // Restore auto-refresh preference.
1406
- const loadAuto = () => {
1407
- try {
1408
- if (!window.localStorage) return true;
1409
- const raw = window.localStorage.getItem(autoKey);
1410
- if (raw === null || raw === undefined || raw === '') return true;
1411
- return raw === 'true';
1412
- } catch (error) {
1413
- return true;
1414
- }
1415
- };
1416
-
1417
- // Persist auto-refresh preference.
1418
- const storeAuto = (value) => {
1419
- try {
1420
- if (window.localStorage) window.localStorage.setItem(autoKey, value ? 'true' : 'false');
1421
- } catch (error) { }
1422
- };
1423
-
1424
- const THEMES = ['mix', 'green', 'lavender'];
1425
- const normalizeTheme = (value) => {
1426
- const t = String(value || '').trim().toLowerCase();
1427
- return THEMES.includes(t) ? t : 'mix';
1428
- };
1429
- const loadTheme = () => {
1430
- try {
1431
- if (!window.localStorage) return 'mix';
1432
- return normalizeTheme(window.localStorage.getItem(themeKey));
1433
- } catch (error) {
1434
- return 'mix';
1435
- }
1436
- };
1437
- const storeTheme = (value) => {
1438
- try {
1439
- if (window.localStorage) window.localStorage.setItem(themeKey, normalizeTheme(value));
1440
- } catch (error) { }
1441
- };
1442
-
1443
- let currentTheme = loadTheme();
1444
- const markThemeButtons = (theme) => {
1445
- if (!$themeBtns.length) return;
1446
- const t = normalizeTheme(theme);
1447
- $themeBtns.forEach((btn) => {
1448
- btn.classList.toggle('active', String(btn.dataset.theme || '') === t);
1449
- });
1450
- };
1451
- const applyTheme = (theme, options) => {
1452
- const opts = options && typeof options === 'object' ? options : {};
1453
- const rerender = opts.rerender !== false;
1454
- const t = normalizeTheme(theme);
1455
- currentTheme = t;
1456
- storeTheme(t);
1457
- markThemeButtons(t);
1458
- document.documentElement.setAttribute('data-theme', t);
1459
- if (!$themeLink) return;
1460
- const href = apiUrl('theme/' + t + '.css');
1461
- const prevTheme = String($themeLink.dataset.theme || '');
1462
- if (prevTheme === t && $themeLink.getAttribute('href')) return;
1463
- $themeLink.dataset.theme = t;
1464
- $themeLink.onload = () => {
1465
- refreshVisualPalette();
1466
- if (rerender && lastDashboardRawData) renderDashboard(lastDashboardRawData);
1467
- };
1468
- $themeLink.onerror = () => {
1469
- setStatus('Theme load failed: ' + t);
1470
- refreshVisualPalette();
1471
- if (rerender && lastDashboardRawData) renderDashboard(lastDashboardRawData);
1472
- };
1473
- $themeLink.href = href + '?v=1';
1474
- };
1475
-
1476
- const setStatus = (text) => {
1477
- $status.textContent = text || '';
1478
- };
1479
-
1480
- const setEnabled = (enabled) => {
1481
- $refreshNodes.disabled = !enabled;
1482
- $nodeSelect.disabled = !enabled;
1483
- $chatInput.disabled = !enabled;
1484
- $chatSend.disabled = !enabled;
1485
- };
1486
-
1487
- // Escape user/LLM text before markdown rendering to avoid HTML injection.
1488
- const escapeHtml = (value) => {
1489
- const s = String(value || '');
1490
- return s
1491
- .replace(/&/g, '&amp;')
1492
- .replace(/</g, '&lt;')
1493
- .replace(/>/g, '&gt;')
1494
- .replace(/"/g, '&quot;')
1495
- .replace(/'/g, '&#39;');
1496
- };
1497
-
1498
- // Convert any answer payload into a displayable string.
1499
- const normalizeChatText = (value) => {
1500
- if (value === undefined || value === null) return '';
1501
- if (typeof value === 'string') return value;
1502
- try {
1503
- return JSON.stringify(value, null, 2);
1504
- } catch (error) {
1505
- return String(value);
1506
- }
1507
- };
1508
-
1509
- const isTableSeparatorLine = (line) => {
1510
- const s = String(line || '').trim();
1511
- if (!s.includes('-')) return false;
1512
- return /^\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(s);
1513
- };
1514
-
1515
- const parsePipeRow = (line) => {
1516
- let s = String(line || '').trim();
1517
- if (s.startsWith('|')) s = s.slice(1);
1518
- if (s.endsWith('|')) s = s.slice(0, -1);
1519
- return s.split('|').map(c => String(c || '').trim());
1520
- };
1521
-
1522
- const parseAlignments = (sepLine) => {
1523
- const cols = parsePipeRow(sepLine);
1524
- return cols.map((c) => {
1525
- const cell = String(c || '').trim();
1526
- const left = cell.startsWith(':');
1527
- const right = cell.endsWith(':');
1528
- if (left && right) return 'center';
1529
- if (right) return 'right';
1530
- return 'left';
1531
- });
1532
- };
1533
-
1534
- // Lightweight markdown renderer (headings, lists, tables, code, links).
1535
- const basicMarkdownToHtml = (markdown) => {
1536
- const lines = String(markdown || '').split(/\r?\n/);
1537
- const sanitizeHref = (href) => {
1538
- const h = String(href || '').trim();
1539
- if (/^javascript:/i.test(h)) return '#';
1540
- return h;
1541
- };
1542
- const renderInline = (text) => {
1543
- let out = String(text || '');
1544
- out = out.replace(/`([^`]+)`/g, '<code>$1</code>');
1545
- out = out.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
1546
- out = out.replace(/\*([^*]+)\*/g, '<em>$1</em>');
1547
- out = out.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (m, label, href) => {
1548
- return '<a href="' + sanitizeHref(href) + '" target="_blank" rel="noopener noreferrer">' + label + '</a>';
1549
- });
1550
- return out;
1551
- };
1552
-
1553
- let html = '';
1554
- let inCode = false;
1555
-
1556
- for (let i = 0; i < lines.length; i++) {
1557
- const line = lines[i];
1558
- if (/^```/.test(line.trim())) {
1559
- inCode = !inCode;
1560
- html += inCode ? '<pre><code>' : '</code></pre>';
1561
- continue;
1562
- }
1563
- if (inCode) {
1564
- html += line + '\n';
1565
- continue;
1566
- }
1567
-
1568
- if (/^###\s+/.test(line)) { html += '<h3>' + line.replace(/^###\s+/, '') + '</h3>'; continue; }
1569
- if (/^##\s+/.test(line)) { html += '<h2>' + line.replace(/^##\s+/, '') + '</h2>'; continue; }
1570
- if (/^#\s+/.test(line)) { html += '<h1>' + line.replace(/^#\s+/, '') + '</h1>'; continue; }
1571
-
1572
- // Parse GitHub-style pipe tables.
1573
- if (line.includes('|') && i + 1 < lines.length && isTableSeparatorLine(lines[i + 1])) {
1574
- const headerCells = parsePipeRow(line);
1575
- const aligns = parseAlignments(lines[i + 1]);
1576
- const rows = [];
1577
- i += 2;
1578
- while (i < lines.length) {
1579
- const rowLine = lines[i];
1580
- if (!rowLine || rowLine.trim() === '') break;
1581
- if (!rowLine.includes('|')) break;
1582
- if (/^```/.test(rowLine.trim())) break;
1583
- rows.push(parsePipeRow(rowLine));
1584
- i++;
1585
- }
1586
- i -= 1;
1587
-
1588
- const colCount = Math.max(headerCells.length, aligns.length, ...(rows.map(r => r.length)));
1589
- html += '<div style="overflow-x:auto;margin:6px 0;"><table><thead><tr>';
1590
- for (let c = 0; c < colCount; c++) {
1591
- const a = aligns[c] || 'left';
1592
- html += '<th style="text-align:' + a + ';">' + renderInline(headerCells[c] || '') + '</th>';
1593
- }
1594
- html += '</tr></thead><tbody>';
1595
- for (const row of rows) {
1596
- html += '<tr>';
1597
- for (let c = 0; c < colCount; c++) {
1598
- const a = aligns[c] || 'left';
1599
- html += '<td style="text-align:' + a + ';">' + renderInline(row[c] || '') + '</td>';
1600
- }
1601
- html += '</tr>';
1602
- }
1603
- html += '</tbody></table></div>';
1604
- continue;
1605
- }
1606
-
1607
- // Parse one-level ordered/unordered lists.
1608
- const isUnordered = /^\s*[-*]\s+/.test(line);
1609
- const isOrdered = /^\s*\d+\.\s+/.test(line);
1610
- if (isUnordered || isOrdered) {
1611
- const listTag = isOrdered ? 'ol' : 'ul';
1612
- const itemRe = isOrdered ? /^\s*\d+\.\s+/ : /^\s*[-*]\s+/;
1613
- html += '<' + listTag + '>';
1614
- while (i < lines.length && itemRe.test(lines[i])) {
1615
- const item = lines[i].replace(itemRe, '');
1616
- html += '<li>' + renderInline(item) + '</li>';
1617
- i++;
1618
- }
1619
- i -= 1;
1620
- html += '</' + listTag + '>';
1621
- continue;
1622
- }
1623
-
1624
- if (line.trim() === '') { html += '<br>'; continue; }
1625
- html += '<p>' + renderInline(line) + '</p>';
1626
- }
1627
-
1628
- if (inCode) html += '</code></pre>';
1629
- return html;
1630
- };
1631
-
1632
- // Escape first, then render markdown syntax.
1633
- const renderMarkdownToHtml = (markdown) => {
1634
- const safeMd = escapeHtml(markdown || '');
1635
- return basicMarkdownToHtml(safeMd);
1636
- };
1637
-
1638
- const decodeBasicHtmlEntities = (value) => {
1639
- return String(value || '')
1640
- .replace(/&lt;/gi, '<')
1641
- .replace(/&gt;/gi, '>')
1642
- .replace(/&quot;/gi, '"')
1643
- .replace(/&#39;/gi, "'")
1644
- .replace(/&amp;/gi, '&');
1645
- };
1646
-
1647
- const extractInlineSvgCandidates = (value) => {
1648
- const out = [];
1649
- const src = String(value || '');
1650
- if (!src) return out;
1651
- const re = /<svg[\s\S]*?<\/svg>/gi;
1652
- let m;
1653
- while ((m = re.exec(src)) !== null) {
1654
- if (m && m[0]) out.push(String(m[0]));
1655
- }
1656
- return out;
1657
- };
1658
-
1659
- const SVG_ALLOWED_TAGS = new Set([
1660
- 'svg', 'g', 'path', 'line', 'polyline', 'polygon', 'circle', 'ellipse', 'rect',
1661
- 'text', 'tspan', 'title', 'desc', 'defs', 'marker', 'lineargradient', 'radialgradient',
1662
- 'stop', 'clippath'
1663
- ]);
1664
- const SVG_ALLOWED_ATTRS = new Set([
1665
- 'xmlns', 'xmlns:xlink', 'viewbox', 'preserveaspectratio', 'width', 'height', 'role', 'aria-label',
1666
- 'id', 'class', 'x', 'y', 'x1', 'y1', 'x2', 'y2', 'cx', 'cy', 'r', 'rx', 'ry',
1667
- 'd', 'points', 'transform', 'fill', 'fill-opacity', 'stroke', 'stroke-width', 'stroke-opacity',
1668
- 'stroke-dasharray', 'stroke-linecap', 'stroke-linejoin', 'opacity', 'font-size', 'font-family',
1669
- 'font-weight', 'text-anchor', 'dominant-baseline', 'offset', 'stop-color', 'stop-opacity',
1670
- 'gradientunits', 'gradienttransform', 'fx', 'fy', 'marker-start', 'marker-mid', 'marker-end',
1671
- 'markerwidth', 'markerheight', 'refx', 'refy', 'orient', 'markerunits', 'href', 'xlink:href',
1672
- 'clip-path', 'clippathunits'
1673
- ]);
1674
-
1675
- // Strict SVG sanitizer for AI-generated charts embedded in chat responses.
1676
- const sanitizeSvgMarkup = (svgMarkup) => {
1677
- try {
1678
- const parser = new DOMParser();
1679
- const doc = parser.parseFromString(String(svgMarkup || ''), 'image/svg+xml');
1680
- if (!doc || !doc.documentElement) return '';
1681
- if (doc.getElementsByTagName('parsererror').length) return '';
1682
-
1683
- const root = doc.documentElement;
1684
- if (!root || String(root.tagName || '').toLowerCase() !== 'svg') return '';
1685
-
1686
- const sanitizeNode = (node) => {
1687
- const tag = String(node.tagName || '').toLowerCase();
1688
- if (!SVG_ALLOWED_TAGS.has(tag)) {
1689
- node.remove();
1690
- return;
1691
- }
1692
- Array.from(node.attributes || []).forEach((attr) => {
1693
- const name = String(attr.name || '').toLowerCase();
1694
- const value = String(attr.value || '');
1695
- if (name.startsWith('on')) {
1696
- node.removeAttribute(attr.name);
1697
- return;
1698
- }
1699
- if (!SVG_ALLOWED_ATTRS.has(name)) {
1700
- node.removeAttribute(attr.name);
1701
- return;
1702
- }
1703
- // Disallow external links/resources in SVG attributes.
1704
- if ((name === 'href' || name === 'xlink:href') && value && !String(value).trim().startsWith('#')) {
1705
- node.removeAttribute(attr.name);
1706
- }
1707
- });
1708
-
1709
- Array.from(node.childNodes || []).forEach((child) => {
1710
- if (child.nodeType === 1) sanitizeNode(child);
1711
- else if (child.nodeType !== 3) child.remove();
1712
- });
1713
- };
1714
-
1715
- sanitizeNode(root);
1716
- if (!root.getAttribute('viewBox') && !root.getAttribute('viewbox')) {
1717
- root.setAttribute('viewBox', '0 0 920 360');
1718
- }
1719
- root.setAttribute('role', root.getAttribute('role') || 'img');
1720
-
1721
- const out = new XMLSerializer().serializeToString(root);
1722
- if (!out || out.length > 120000) return '';
1723
- return out;
1724
- } catch (error) {
1725
- return '';
1726
- }
1727
- };
1728
-
1729
- // Render assistant output with optional fenced SVG blocks (```svg ... ```).
1730
- const renderAssistantHtml = (text) => {
1731
- const raw = normalizeChatText(text);
1732
- const trimmed = raw.trim();
1733
- if (!trimmed) return renderMarkdownToHtml('(empty answer)');
1734
-
1735
- const fenceRe = /```(?:svg|xml|html)?\s*([\s\S]*?)```/gi;
1736
- let html = '';
1737
- let cursor = 0;
1738
- let hasSvg = false;
1739
- let match;
1740
-
1741
- while ((match = fenceRe.exec(raw)) !== null) {
1742
- const before = raw.slice(cursor, match.index);
1743
- if (before.trim()) html += renderMarkdownToHtml(before);
1744
- const blockRaw = String(match[1] || '');
1745
- const blockDecoded = decodeBasicHtmlEntities(blockRaw);
1746
- const svgCandidates = extractInlineSvgCandidates(blockDecoded);
1747
- if (svgCandidates.length) {
1748
- let rendered = 0;
1749
- svgCandidates.forEach((candidate) => {
1750
- const safeSvg = sanitizeSvgMarkup(candidate);
1751
- if (safeSvg) {
1752
- rendered++;
1753
- hasSvg = true;
1754
- html += '<div class="chat-svg-wrap">' + safeSvg + '</div>';
1755
- }
1756
- });
1757
- if (!rendered) html += '<pre><code>' + escapeHtml(blockRaw) + '</code></pre>';
1758
- } else {
1759
- const safeSvg = sanitizeSvgMarkup(blockDecoded);
1760
- if (safeSvg) {
1761
- hasSvg = true;
1762
- html += '<div class="chat-svg-wrap">' + safeSvg + '</div>';
1763
- } else {
1764
- html += '<pre><code>' + escapeHtml(blockRaw) + '</code></pre>';
1765
- }
1766
- }
1767
- cursor = match.index + match[0].length;
1768
- }
1769
-
1770
- const tail = raw.slice(cursor);
1771
- if (tail.trim()) html += renderMarkdownToHtml(tail);
1772
-
1773
- // Fallback: if no fenced block exists, allow one inline <svg>...</svg> block.
1774
- if (!hasSvg) {
1775
- const decodedRaw = decodeBasicHtmlEntities(raw);
1776
- const inlineCandidates = [
1777
- ...extractInlineSvgCandidates(raw),
1778
- ...extractInlineSvgCandidates(decodedRaw)
1779
- ];
1780
- const uniqueCandidates = Array.from(new Set(inlineCandidates.map(x => String(x || '')))).filter(Boolean);
1781
- const safeSvgs = [];
1782
- uniqueCandidates.forEach((candidate) => {
1783
- const safe = sanitizeSvgMarkup(candidate);
1784
- if (safe) safeSvgs.push(safe);
1785
- });
1786
- if (safeSvgs.length) {
1787
- const withoutInline = raw
1788
- .replace(/<svg[\s\S]*?<\/svg>/gi, '')
1789
- .replace(/&lt;svg[\s\S]*?&lt;\/svg&gt;/gi, '')
1790
- .trim();
1791
- html = '';
1792
- if (withoutInline) html += renderMarkdownToHtml(withoutInline);
1793
- safeSvgs.forEach((safeSvg) => {
1794
- html += '<div class="chat-svg-wrap">' + safeSvg + '</div>';
1795
- });
1796
- }
1797
- }
1798
-
1799
- return html || renderMarkdownToHtml(trimmed);
1800
- };
1801
-
1802
- const appendChat = (kind, text) => {
1803
- const cls = kind === 'user' ? 'chat-user' : (kind === 'assistant' ? 'chat-assistant' : 'chat-error');
1804
- const $msg = document.createElement('div');
1805
- $msg.className = 'chat-msg ' + cls;
1806
- if (kind === 'assistant') {
1807
- $msg.innerHTML = renderAssistantHtml(text);
1808
- } else {
1809
- $msg.textContent = normalizeChatText(text);
1810
- }
1811
- $chatLog.appendChild($msg);
1812
- $chatLog.scrollTop = $chatLog.scrollHeight;
1813
- };
1814
-
1815
- const showChatPending = () => {
1816
- if (pendingChatEl) return;
1817
- pendingChatEl = document.createElement('div');
1818
- pendingChatEl.className = 'chat-msg chat-pending';
1819
- pendingChatEl.textContent = 'Thinking...';
1820
- $chatLog.appendChild(pendingChatEl);
1821
- $chatLog.scrollTop = $chatLog.scrollHeight;
1822
- };
1823
-
1824
- const hideChatPending = () => {
1825
- if (!pendingChatEl) return;
1826
- pendingChatEl.remove();
1827
- pendingChatEl = null;
1828
- };
1829
-
1830
- // Fetch JSON from backend and normalize HTTP/application errors.
1831
- const requestJson = async (url, options) => {
1832
- const response = await fetch(url, Object.assign({ credentials: 'same-origin' }, options || {}));
1833
- const text = await response.text();
1834
- let json = {};
1835
- try {
1836
- json = text ? JSON.parse(text) : {};
1837
- } catch (error) {
1838
- json = { error: text || ('HTTP ' + response.status) };
1839
- }
1840
- if (!response.ok) {
1841
- throw new Error((json && json.error) ? json.error : ('HTTP ' + response.status));
1842
- }
1843
- return json;
1844
- };
1845
-
1846
- // Produce the textual snapshot shown in the "Summary" panel.
1847
- const formatSummaryText = (data) => {
1848
- const nodeInfo = data && data.node ? data.node : {};
1849
- const s = data && data.summary ? data.summary : null;
1850
- if (!s) return 'No data available.';
1851
-
1852
- const lines = [];
1853
- const headerBits = [];
1854
- if (nodeInfo.name) headerBits.push(nodeInfo.name);
1855
- if (nodeInfo.gatewayName) headerBits.push('Gateway: ' + nodeInfo.gatewayName);
1856
- if (s.meta && s.meta.generatedAt) headerBits.push('Updated: ' + s.meta.generatedAt);
1857
- if (headerBits.length) lines.push(headerBits.join(' | '));
1858
- lines.push('');
1859
-
1860
- const c = s.counters || {};
1861
- const win = (s.meta && s.meta.analysisWindowSec) ? s.meta.analysisWindowSec : '';
1862
- lines.push('Analysis window: ' + win + 's');
1863
- lines.push('Telegrams: ' + (c.telegrams || 0) + ' | Rate: ' + (c.overallRatePerSec || 0) + '/s | Echoed: ' + (c.echoed || 0) + ' | Repeat: ' + (c.repeated || 0) + ' | Unknown DPT: ' + (c.unknownDpt || 0));
1864
- if (s.busConnection && typeof s.busConnection === 'object') {
1865
- const bus = s.busConnection;
1866
- lines.push('Bus connection: ' + String(bus.currentState || 'unknown') + ' | Connected: ' + Number(bus.connectedPct || 0) + '% | Disconnected: ' + Number(bus.disconnectedPct || 0) + '% over ' + Number(bus.windowSec || 0) + 's');
1867
- }
1868
-
1869
- if (Array.isArray(s.topGAs) && s.topGAs.length) {
1870
- lines.push('');
1871
- lines.push('Top Group Address:');
1872
- s.topGAs.slice(0, 20).forEach((x, idx) => {
1873
- lines.push((idx + 1) + '. ' + x.ga + ' (' + x.count + ')');
1874
- });
1875
- }
1876
-
1877
- if (s.byEvent && Object.keys(s.byEvent).length) {
1878
- lines.push('');
1879
- lines.push('Events:');
1880
- Object.keys(s.byEvent).sort().forEach((k) => {
1881
- lines.push('- ' + k + ': ' + s.byEvent[k]);
1882
- });
1883
- }
1884
-
1885
- if (Array.isArray(s.patterns) && s.patterns.length) {
1886
- lines.push('');
1887
- lines.push('Patterns:');
1888
- s.patterns.slice(0, 15).forEach((p) => {
1889
- lines.push('- ' + p.from + ' -> ' + p.to + ' (' + p.count + ' times within ' + p.withinMs + 'ms)');
1890
- });
1891
- }
1892
-
1893
- return lines.join('\n');
1894
- };
1895
-
1896
- const clamp01 = (value) => {
1897
- const n = Number(value);
1898
- if (!Number.isFinite(n)) return 0;
1899
- if (n <= 0) return 0;
1900
- if (n >= 1) return 1;
1901
- return n;
1902
- };
1903
-
1904
- const formatClockLabel = (value) => {
1905
- const ts = Number(value);
1906
- if (!Number.isFinite(ts) || ts <= 0) return '--:--';
1907
- const date = new Date(ts);
1908
- if (Number.isNaN(date.getTime())) return '--:--';
1909
- return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
1910
- };
1911
-
1912
- const formatDurationCompact = (seconds) => {
1913
- const totalSec = Math.max(0, Math.round(Number(seconds) || 0));
1914
- const days = Math.floor(totalSec / 86400);
1915
- const hours = Math.floor((totalSec % 86400) / 3600);
1916
- const mins = Math.floor((totalSec % 3600) / 60);
1917
- const secs = totalSec % 60;
1918
- const parts = [];
1919
- if (days > 0) parts.push(days + 'd');
1920
- if (hours > 0) parts.push(hours + 'h');
1921
- if (mins > 0) parts.push(mins + 'm');
1922
- if (secs > 0 || parts.length === 0) parts.push(secs + 's');
1923
- return parts.slice(0, 2).join(' ');
1924
- };
1925
-
1926
- const renderBusConnection = (data) => {
1927
- $busConnection.innerHTML = '';
1928
- const summary = data && data.summary ? data.summary : {};
1929
- const bus = summary && summary.busConnection && typeof summary.busConnection === 'object'
1930
- ? summary.busConnection
1931
- : null;
1932
-
1933
- if (!bus || !Array.isArray(bus.segments) || bus.segments.length === 0) {
1934
- const empty = document.createElement('div');
1935
- empty.className = 'bus-conn-empty';
1936
- empty.textContent = 'Waiting for connection persistence data...';
1937
- $busConnection.appendChild(empty);
1938
- return;
1939
- }
1940
-
1941
- const windowStartMs = new Date(String(bus.windowStartAt || '')).getTime();
1942
- const windowEndMs = new Date(String(bus.windowEndAt || '')).getTime();
1943
- const windowMs = Math.max(1, windowEndMs - windowStartMs);
1944
- const midMs = windowStartMs + Math.round(windowMs / 2);
1945
-
1946
- const header = document.createElement('div');
1947
- header.className = 'bus-conn-header';
1948
-
1949
- const title = document.createElement('div');
1950
- title.className = 'bus-conn-title';
1951
- title.textContent = 'Last ' + formatDurationCompact(bus.windowSec || 0) + ' of KNX bus availability';
1952
- header.appendChild(title);
1953
-
1954
- const badges = document.createElement('div');
1955
- badges.className = 'bus-conn-badges';
1956
-
1957
- const makePill = (label, value, cls) => {
1958
- const pill = document.createElement('span');
1959
- pill.className = 'bus-conn-pill ' + cls;
1960
- pill.textContent = label + ': ' + value;
1961
- return pill;
1962
- };
1963
-
1964
- badges.appendChild(makePill('Current', String(bus.currentState || 'unknown'), bus.currentState === 'connected' ? 'bus-conn-pill-connected' : 'bus-conn-pill-disconnected'));
1965
- badges.appendChild(makePill('Connected', formatDurationCompact(bus.connectedSec || 0), 'bus-conn-pill-connected'));
1966
- badges.appendChild(makePill('Disconnected', formatDurationCompact(bus.disconnectedSec || 0), 'bus-conn-pill-disconnected'));
1967
- badges.appendChild(makePill('Coverage', Number(bus.knownCoveragePct || 0) + '%', 'bus-conn-pill-muted'));
1968
- header.appendChild(badges);
1969
- $busConnection.appendChild(header);
1970
-
1971
- const track = document.createElement('div');
1972
- track.className = 'bus-conn-track';
1973
-
1974
- bus.segments.forEach((segment) => {
1975
- const startRatio = clamp01(segment && segment.ratioStart);
1976
- const widthRatio = clamp01(segment && segment.ratioWidth);
1977
- if (widthRatio <= 0) return;
1978
- const bar = document.createElement('div');
1979
- bar.className = 'bus-conn-segment ' + (segment && segment.state === 'connected'
1980
- ? 'bus-conn-segment-connected'
1981
- : 'bus-conn-segment-disconnected');
1982
- bar.style.left = (startRatio * 100).toFixed(3) + '%';
1983
- bar.style.width = Math.max(widthRatio * 100, 0.4).toFixed(3) + '%';
1984
- bar.title = (segment.state === 'connected' ? 'Connected' : 'Disconnected')
1985
- + ' | ' + formatClockLabel(new Date(String(segment.startedAt || '')).getTime())
1986
- + ' -> ' + formatClockLabel(new Date(String(segment.endedAt || '')).getTime())
1987
- + ' | ' + formatDurationCompact(segment.durationSec || 0);
1988
- track.appendChild(bar);
1989
- });
1990
-
1991
- $busConnection.appendChild(track);
1992
-
1993
- const scale = document.createElement('div');
1994
- scale.className = 'bus-conn-scale';
1995
- const left = document.createElement('span');
1996
- left.textContent = formatClockLabel(windowStartMs);
1997
- const mid = document.createElement('span');
1998
- mid.textContent = formatClockLabel(midMs);
1999
- const right = document.createElement('span');
2000
- right.textContent = 'Now';
2001
- scale.appendChild(left);
2002
- scale.appendChild(mid);
2003
- scale.appendChild(right);
2004
- $busConnection.appendChild(scale);
2005
-
2006
- const note = document.createElement('div');
2007
- note.className = 'bus-conn-note';
2008
- note.textContent = 'Green shows time connected to the KNX bus. Red shows downtime inside the selected history window.';
2009
- $busConnection.appendChild(note);
2010
- };
2011
-
2012
- const buildAnalysisActivityContext = (rawData, dashboardData) => {
2013
- const summary = rawData && rawData.summary ? rawData.summary : {};
2014
- const telemetryWindowSec = Number(
2015
- summary && summary.meta && summary.meta.analysisWindowSec
2016
- ? summary.meta.analysisWindowSec
2017
- : (dashboardData && dashboardData.telemetryWindowSec ? dashboardData.telemetryWindowSec : 0)
2018
- ) || 0;
2019
-
2020
- const gaCandidates = [];
2021
- const seen = new Set();
2022
- const addGa = (ga) => {
2023
- const key = String(ga || '').trim();
2024
- if (!key || seen.has(key)) return;
2025
- seen.add(key);
2026
- gaCandidates.push(key);
2027
- };
2028
-
2029
- const dashboardNodes = (dashboardData && Array.isArray(dashboardData.nodes)) ? dashboardData.nodes : [];
2030
- dashboardNodes
2031
- .filter((n) => n && n.kind !== 'node')
2032
- .slice()
2033
- .sort((a, b) => Number(b && b.lastSeenAtMs ? b.lastSeenAtMs : 0) - Number(a && a.lastSeenAtMs ? a.lastSeenAtMs : 0))
2034
- .forEach((n) => addGa(n.displayId || n.id));
2035
-
2036
- const topGAs = Array.isArray(summary.topGAs) ? summary.topGAs : [];
2037
- topGAs.forEach((x) => addGa(x && x.ga));
2038
-
2039
- const flowKnownGAs = Array.isArray(summary.flowKnownGAs) ? summary.flowKnownGAs : [];
2040
- flowKnownGAs.forEach((ga) => addGa(ga));
2041
-
2042
- const monitored = flowKnownGAs.length || dashboardNodes.filter((n) => n && n.inFlow !== false && n.kind !== 'node').length || gaCandidates.length;
2043
- return {
2044
- monitoringText: 'Analyzing BUS traffic for anomalies...',
2045
- analysisFocus: [
2046
- 'traffic bursts / telegram storms',
2047
- 'repeat or echo telegram patterns',
2048
- 'unknown or inconsistent DPT payloads',
2049
- 'abnormal read/response behavior',
2050
- 'unusual GA transition patterns'
2051
- ],
2052
- gaPreview: gaCandidates.slice(0, 8),
2053
- monitoredCount: Number(monitored || 0),
2054
- telemetryWindowSec
2055
- };
2056
- };
2057
-
2058
- const createAnalyzingStateEl = (context, compact) => {
2059
- const activity = context || {};
2060
- const root = document.createElement('div');
2061
- root.className = compact ? 'analyzing-state analyzing-state-small' : 'analyzing-state';
2062
-
2063
- const icon = document.createElement('span');
2064
- icon.className = 'analyzing-icon';
2065
- root.appendChild(icon);
2066
-
2067
- const body = document.createElement('div');
2068
- const title = document.createElement('div');
2069
- title.className = 'analyzing-title';
2070
- title.textContent = 'Analyzing...';
2071
- body.appendChild(title);
2072
-
2073
- const sub = document.createElement('div');
2074
- sub.className = 'analyzing-sub';
2075
- const windowText = activity.telemetryWindowSec > 0 ? ('Window: ' + activity.telemetryWindowSec + 's') : 'Window: live';
2076
- const monitoredText = activity.monitoredCount > 0 ? ('Monitored GA: ' + activity.monitoredCount) : 'Monitored GA: n/a';
2077
- sub.textContent = (activity.monitoringText || 'Analyzing BUS traffic for anomalies...') + ' ' + windowText + ' | ' + monitoredText;
2078
- body.appendChild(sub);
2079
-
2080
- const focusItems = Array.isArray(activity.analysisFocus) ? activity.analysisFocus.filter(Boolean).slice(0, 5) : [];
2081
- if (focusItems.length) {
2082
- const details = document.createElement('div');
2083
- details.className = 'analyzing-what';
2084
- details.textContent = 'Checking for: ' + focusItems.join(' | ');
2085
- body.appendChild(details);
2086
- }
2087
-
2088
- if (Array.isArray(activity.gaPreview) && activity.gaPreview.length) {
2089
- const ga = document.createElement('div');
2090
- ga.className = 'analyzing-ga-list';
2091
- ga.textContent = 'GA in analysis: ' + activity.gaPreview.join(', ');
2092
- body.appendChild(ga);
2093
- }
2094
-
2095
- root.appendChild(body);
2096
- return root;
2097
- };
2098
-
2099
- // Render anomaly timeline (latest items first, bounded length).
2100
- const renderAnomalies = (items, activityContext) => {
2101
- $anomalies.innerHTML = '';
2102
- if (!items || !items.length) {
2103
- const $empty = document.createElement('div');
2104
- $empty.className = 'empty';
2105
- $empty.textContent = 'No anomalies detected right now.';
2106
- $anomalies.appendChild($empty);
2107
- $anomalies.appendChild(createAnalyzingStateEl(activityContext, false));
2108
- return;
2109
- }
2110
-
2111
- items.slice().reverse().slice(0, 30).forEach((entry) => {
2112
- const payload = entry && entry.payload ? entry.payload : {};
2113
- const $row = document.createElement('div');
2114
- $row.className = 'anomaly';
2115
-
2116
- const $title = document.createElement('div');
2117
- $title.textContent = (payload.type || 'anomaly') + (payload.ga ? (' | ' + payload.ga) : '');
2118
- $row.appendChild($title);
2119
-
2120
- const $meta = document.createElement('div');
2121
- $meta.className = 'anomaly-meta';
2122
- $meta.textContent = entry.at || '';
2123
- $row.appendChild($meta);
2124
-
2125
- const $pre = document.createElement('pre');
2126
- $pre.textContent = JSON.stringify(payload, null, 2);
2127
- $row.appendChild($pre);
2128
-
2129
- $anomalies.appendChild($row);
2130
- });
2131
- };
2132
-
2133
- // Tiny SVG helper to keep rendering code concise.
2134
- const createSvgEl = (name, attrs) => {
2135
- const el = document.createElementNS('http://www.w3.org/2000/svg', name);
2136
- Object.keys(attrs || {}).forEach((k) => {
2137
- if (attrs[k] === undefined || attrs[k] === null) return;
2138
- el.setAttribute(k, String(attrs[k]));
2139
- });
2140
- return el;
2141
- };
2142
-
2143
- // Compact GA label used inside the node bubble.
2144
- const shortNodeLabel = (value) => {
2145
- const s = String(value || '');
2146
- if (s.length <= 14) return s;
2147
- return s.slice(0, 12) + '..';
2148
- };
2149
-
2150
- // Compact subtitle shown under the node bubble.
2151
- const shortNodeSubtitle = (value) => {
2152
- let s = String(value || '').trim();
2153
- if (!s) return '';
2154
- if (s.includes('->')) {
2155
- const parts = s.split('->').map(p => String(p || '').trim()).filter(Boolean);
2156
- if (parts.length) s = parts[parts.length - 1];
2157
- }
2158
- if (s.includes(')')) {
2159
- const tail = s.split(')').pop().trim();
2160
- if (tail) s = tail;
2161
- }
2162
- if (s.length <= 26) return s;
2163
- return s.slice(0, 24) + '..';
2164
- };
2165
-
2166
- const stripPayloadDecimals = (value) => {
2167
- if (value === undefined || value === null) return value;
2168
- if (typeof value === 'number') {
2169
- if (!Number.isFinite(value)) return value;
2170
- return Math.trunc(value);
2171
- }
2172
- if (Array.isArray(value)) return value.map(v => stripPayloadDecimals(v));
2173
- if (typeof value === 'object') {
2174
- const out = {};
2175
- Object.keys(value).forEach((k) => {
2176
- out[k] = stripPayloadDecimals(value[k]);
2177
- });
2178
- return out;
2179
- }
2180
- if (typeof value === 'string') {
2181
- const s = String(value).trim();
2182
- if (!s) return '';
2183
- if (/^[+-]?\d+(?:\.\d+)?$/.test(s)) {
2184
- const n = Number(s);
2185
- if (Number.isFinite(n)) return String(Math.trunc(n));
2186
- }
2187
- if ((s.startsWith('{') && s.endsWith('}')) || (s.startsWith('[') && s.endsWith(']'))) {
2188
- try {
2189
- const parsed = JSON.parse(s);
2190
- return JSON.stringify(stripPayloadDecimals(parsed));
2191
- } catch (error) {
2192
- return s;
2193
- }
2194
- }
2195
- return s;
2196
- }
2197
- return value;
2198
- };
2199
-
2200
- const shortNodePayload = (value) => {
2201
- let s = stripPayloadDecimals(value);
2202
- if (s === undefined || s === null) return '';
2203
- s = String(s).trim();
2204
- if (!s) return '';
2205
- s = s.replace(/\s+/g, ' ');
2206
- if (s.length <= 30) return s;
2207
- return s.slice(0, 28) + '..';
2208
- };
2209
-
2210
- const toTrimmedText = (value) => {
2211
- if (value === undefined || value === null) return '';
2212
- return String(value).trim();
2213
- };
2214
-
2215
- let EVENT_COLORS = {};
2216
- let PIE_COLORS = [];
2217
- const readCssVarColor = (name, fallback) => {
2218
- try {
2219
- const styles = window.getComputedStyle(document.documentElement);
2220
- const v = String(styles.getPropertyValue(name) || '').trim();
2221
- return v || fallback;
2222
- } catch (error) {
2223
- return fallback;
2224
- }
2225
- };
2226
- const refreshVisualPalette = () => {
2227
- EVENT_COLORS = {
2228
- write: readCssVarColor('--event-write', '#7a68d8'),
2229
- response: readCssVarColor('--event-response', '#46b86d'),
2230
- read: readCssVarColor('--event-read', '#d99a34'),
2231
- repeat: readCssVarColor('--event-repeat', '#c34747'),
2232
- other: readCssVarColor('--event-other', '#9a93b8')
2233
- };
2234
- PIE_COLORS = [
2235
- EVENT_COLORS.write,
2236
- EVENT_COLORS.response,
2237
- EVENT_COLORS.read,
2238
- readCssVarColor('--pie-4', '#8b7de0'),
2239
- readCssVarColor('--pie-5', '#5aa5c9'),
2240
- readCssVarColor('--pie-6', '#d95b63'),
2241
- readCssVarColor('--pie-7', '#7d7699'),
2242
- EVENT_COLORS.repeat
2243
- ];
2244
- };
2245
- refreshVisualPalette();
2246
-
2247
- // Normalize heterogeneous event names into a small color palette.
2248
- const normalizeEvent = (eventName) => {
2249
- const e = String(eventName || '').toLowerCase();
2250
- if (e.includes('repeat')) return 'repeat';
2251
- if (e.includes('write')) return 'write';
2252
- if (e.includes('response')) return 'response';
2253
- if (e.includes('read')) return 'read';
2254
- return 'other';
2255
- };
2256
-
2257
- const classifyEdgeTelegramType = (edge) => {
2258
- const byEvent = (edge && edge.edgeByEvent && typeof edge.edgeByEvent === 'object') ? edge.edgeByEvent : {};
2259
- const keys = Object.keys(byEvent);
2260
- if (!keys.length) return 'other';
2261
-
2262
- const counts = { write: 0, response: 0, read: 0, repeat: 0, other: 0 };
2263
- keys.forEach((name) => {
2264
- const c = Number(byEvent[name] || 0);
2265
- if (!Number.isFinite(c) || c <= 0) return;
2266
- const s = String(name || '');
2267
- const kind = normalizeEvent(s);
2268
- if (kind === 'write') counts.write += c;
2269
- else if (kind === 'response') counts.response += c;
2270
- else if (kind === 'read') counts.read += c;
2271
- else if (kind === 'repeat') counts.repeat += c;
2272
- else counts.other += c;
2273
- });
2274
-
2275
- const priority = ['repeat', 'write', 'response', 'read', 'other'];
2276
- let best = 'other';
2277
- let bestVal = -1;
2278
- priority.forEach((k) => {
2279
- const v = Number(counts[k] || 0);
2280
- if (v > bestVal) {
2281
- best = k;
2282
- bestVal = v;
2283
- }
2284
- });
2285
- return best;
2286
- };
2287
-
2288
- const edgeMarkerIdForType = (eventType) => {
2289
- const t = String(eventType || '').trim();
2290
- return EVENT_COLORS[t] ? ('flow-arrow-' + t) : 'flow-arrow-other';
2291
- };
2292
-
2293
- const pickColor = (idx) => PIE_COLORS[idx % PIE_COLORS.length];
2294
-
2295
- // Convert backend state payload into chart-ready structures.
2296
- const buildDashboardData = (data) => {
2297
- const summary = (data && data.summary) ? data.summary : {};
2298
- const patternTransitions = Array.isArray(summary.patternTransitions) ? summary.patternTransitions : [];
2299
- const patterns = Array.isArray(summary.patterns) ? summary.patterns : [];
2300
- const anomalyLifecycle = Array.isArray(summary.anomalyLifecycle) ? summary.anomalyLifecycle : [];
2301
- const anomalies = (data && Array.isArray(data.anomalies)) ? data.anomalies : [];
2302
- const byEvent = (summary && typeof summary.byEvent === 'object' && summary.byEvent) ? summary.byEvent : {};
2303
- const gaLabels = (summary && summary.gaLabels && typeof summary.gaLabels === 'object') ? summary.gaLabels : {};
2304
- const gaLastSeenAt = (summary && summary.gaLastSeenAt && typeof summary.gaLastSeenAt === 'object') ? summary.gaLastSeenAt : {};
2305
- const gaLastPayload = (summary && summary.gaLastPayload && typeof summary.gaLastPayload === 'object') ? summary.gaLastPayload : {};
2306
- const flowKnownGAs = Array.isArray(summary.flowKnownGAs) ? summary.flowKnownGAs : [];
2307
- const flowKnownGASet = new Set(flowKnownGAs.map(x => String(x || '').trim()).filter(Boolean));
2308
- const telemetryWindowSec = Number(summary && summary.graph && summary.graph.windowSec ? summary.graph.windowSec : 0);
2309
- const busLabel = String((summary && summary.meta && summary.meta.nodeName) || (data && data.node && data.node.name) || 'KNX AI').trim();
2310
-
2311
- // Build candidate transition links from patternTransitions (preferred) or fallback patterns.
2312
- const edgeList = [];
2313
- if (patternTransitions.length) {
2314
- patternTransitions.forEach((e) => {
2315
- const from = String(e && e.from ? e.from : '').trim();
2316
- const to = String(e && e.to ? e.to : '').trim();
2317
- if (!from || !to || from === to) return;
2318
- const fromInFlow = flowKnownGASet.size ? flowKnownGASet.has(from) : true;
2319
- const toInFlow = flowKnownGASet.size ? flowKnownGASet.has(to) : true;
2320
- // Keep only flow<->flow and flow<->external links.
2321
- if (flowKnownGASet.size && !fromInFlow && !toInFlow) return;
2322
- const weight = Number(e && e.currentWindowCount !== undefined ? e.currentWindowCount : (e && e.totalCount !== undefined ? e.totalCount : 1));
2323
- const edgeByEvent = (e && e.edgeByEvent && typeof e.edgeByEvent === 'object') ? e.edgeByEvent : {};
2324
- edgeList.push({
2325
- key: from + '->' + to,
2326
- from,
2327
- to,
2328
- fromInFlow,
2329
- toInFlow,
2330
- linkType: (e && e.linkType) ? String(e.linkType) : (fromInFlow && toInFlow ? 'flow-flow' : (fromInFlow ? 'flow-external' : 'external-flow')),
2331
- weight: Number.isFinite(weight) ? weight : 0,
2332
- currentWindowCount: Number.isFinite(Number(e && e.currentWindowCount)) ? Number(e.currentWindowCount) : 0,
2333
- delta: Number(e && e.delta ? e.delta : 0),
2334
- edgeByEvent,
2335
- lastAtMs: (e && e.lastAt) ? (new Date(e.lastAt).getTime() || 0) : 0
2336
- });
2337
- });
2338
- } else {
2339
- patterns.forEach((p) => {
2340
- const from = String(p && p.from ? p.from : '').trim();
2341
- const to = String(p && p.to ? p.to : '').trim();
2342
- if (!from || !to || from === to) return;
2343
- const fromInFlow = flowKnownGASet.size ? flowKnownGASet.has(from) : true;
2344
- const toInFlow = flowKnownGASet.size ? flowKnownGASet.has(to) : true;
2345
- if (flowKnownGASet.size && !fromInFlow && !toInFlow) return;
2346
- const c = Number(p && p.count ? p.count : 1);
2347
- edgeList.push({
2348
- key: from + '->' + to,
2349
- from,
2350
- to,
2351
- fromInFlow,
2352
- toInFlow,
2353
- linkType: fromInFlow && toInFlow ? 'flow-flow' : (fromInFlow ? 'flow-external' : 'external-flow'),
2354
- weight: Number.isFinite(c) ? c : 1,
2355
- currentWindowCount: 0,
2356
- delta: 0,
2357
- edgeByEvent: {},
2358
- lastAtMs: 0
2359
- });
2360
- });
2361
- }
2362
-
2363
- // Merge duplicate edges and aggregate weights/deltas/event buckets.
2364
- const mergedEdges = new Map();
2365
- edgeList.forEach((e) => {
2366
- if (!mergedEdges.has(e.key)) mergedEdges.set(e.key, Object.assign({}, e));
2367
- else {
2368
- const cur = mergedEdges.get(e.key);
2369
- cur.weight += e.weight;
2370
- cur.currentWindowCount = Number(cur.currentWindowCount || 0) + Number(e.currentWindowCount || 0);
2371
- cur.delta += e.delta;
2372
- cur.lastAtMs = Math.max(Number(cur.lastAtMs || 0), Number(e.lastAtMs || 0));
2373
- cur.fromInFlow = (cur.fromInFlow !== false) && (e.fromInFlow !== false);
2374
- cur.toInFlow = (cur.toInFlow !== false) && (e.toInFlow !== false);
2375
- cur.linkType = cur.fromInFlow && cur.toInFlow ? 'flow-flow' : (cur.fromInFlow ? 'flow-external' : 'external-flow');
2376
- Object.keys(e.edgeByEvent || {}).forEach((ev) => {
2377
- cur.edgeByEvent[ev] = (cur.edgeByEvent[ev] || 0) + (e.edgeByEvent[ev] || 0);
2378
- });
2379
- mergedEdges.set(e.key, cur);
2380
- }
2381
- });
2382
-
2383
- let edges = Array.from(mergedEdges.values())
2384
- .filter(e => e.weight > 0)
2385
- .sort((a, b) => {
2386
- if (b.weight !== a.weight) return b.weight - a.weight;
2387
- return Math.abs(b.delta) - Math.abs(a.delta);
2388
- })
2389
- .slice(0, 14);
2390
-
2391
- // Count anomalies per GA so nodes with issues are prioritized.
2392
- const anomalyByGA = {};
2393
- if (anomalyLifecycle.length) {
2394
- anomalyLifecycle.forEach((a) => {
2395
- const ga = String(a && a.ga ? a.ga : 'BUS').trim() || 'BUS';
2396
- anomalyByGA[ga] = (anomalyByGA[ga] || 0) + Math.max(1, Number(a && a.count ? a.count : 1));
2397
- });
2398
- } else {
2399
- anomalies.forEach((a) => {
2400
- const ga = String(a && a.payload && a.payload.ga ? a.payload.ga : 'BUS').trim() || 'BUS';
2401
- anomalyByGA[ga] = (anomalyByGA[ga] || 0) + 1;
2402
- });
2403
- }
2404
-
2405
- const flowMapTopology = (summary && summary.flowMapTopology && typeof summary.flowMapTopology === 'object')
2406
- ? summary.flowMapTopology
2407
- : null;
2408
- const hasFlowTopologyPayload = !!(flowMapTopology
2409
- && Array.isArray(flowMapTopology.nodes)
2410
- && Array.isArray(flowMapTopology.edges));
2411
- if (hasFlowTopologyPayload) {
2412
- const nodes = flowMapTopology.nodes.map((n) => {
2413
- const id = String(n && n.id ? n.id : '').trim();
2414
- const kind = String(n && n.kind ? n.kind : (id.startsWith('N:') ? 'node' : 'ga')).trim() || 'ga';
2415
- const displayId = String(n && n.displayId ? n.displayId : id).trim() || id;
2416
- const subtitle = String(n && n.subtitle ? n.subtitle : (kind === 'ga' ? (gaLabels[id] || '') : '')).trim();
2417
- const payload = toTrimmedText(n ? n.payload : '');
2418
- const lastSeenAtMs = (() => {
2419
- const direct = Number(n && n.lastSeenAtMs ? n.lastSeenAtMs : 0);
2420
- if (Number.isFinite(direct) && direct > 0) return direct;
2421
- const ts = new Date(String(n && n.lastSeenAt ? n.lastSeenAt : '')).getTime();
2422
- return Number.isFinite(ts) ? ts : 0;
2423
- })();
2424
- const inFlow = kind === 'node'
2425
- ? true
2426
- : ((n && n.inFlow !== undefined) ? !!n.inFlow : (flowKnownGASet.size ? flowKnownGASet.has(id) : false));
2427
- return {
2428
- id,
2429
- displayId,
2430
- kind,
2431
- score: Number(n && n.score ? n.score : 0),
2432
- anomalyCount: Number(n && n.anomalyCount ? n.anomalyCount : (anomalyByGA[id] || 0)),
2433
- subtitle,
2434
- payload,
2435
- lastSeenAtMs,
2436
- inFlow
2437
- };
2438
- }).filter(n => !!n.id);
2439
-
2440
- const nodeInFlow = new Map(nodes.map(n => [String(n.id || ''), n.inFlow !== false]));
2441
- const edges = flowMapTopology.edges.map((e) => {
2442
- const from = String(e && e.from ? e.from : '').trim();
2443
- const to = String(e && e.to ? e.to : '').trim();
2444
- if (!from || !to || from === to) return null;
2445
- const currentWindowCount = Number(e && e.currentWindowCount ? e.currentWindowCount : 0);
2446
- const previousWindowCount = Number(e && e.previousWindowCount ? e.previousWindowCount : 0);
2447
- const totalCount = Number(e && e.totalCount ? e.totalCount : 0);
2448
- const delta = Number(e && e.delta !== undefined ? e.delta : (currentWindowCount - previousWindowCount));
2449
- const weight = Number(e && e.weight !== undefined ? e.weight : (currentWindowCount > 0 ? currentWindowCount : totalCount));
2450
- const lastAtMs = (() => {
2451
- const direct = Number(e && e.lastAtMs ? e.lastAtMs : 0);
2452
- if (Number.isFinite(direct) && direct > 0) return direct;
2453
- const ts = new Date(String(e && e.lastAt ? e.lastAt : '')).getTime();
2454
- return Number.isFinite(ts) ? ts : 0;
2455
- })();
2456
- const edgeByEvent = (e && e.edgeByEvent && typeof e.edgeByEvent === 'object') ? e.edgeByEvent : {};
2457
- const delayMs = Math.max(0, Number(e && e.delayMs ? e.delayMs : 0));
2458
- const keepVisible = !!(e && e.keepVisible === true);
2459
- const viaGa = String(e && e.viaGa ? e.viaGa : '').trim();
2460
- const fromInFlow = (e && e.fromInFlow !== undefined)
2461
- ? !!e.fromInFlow
2462
- : (nodeInFlow.has(from) ? (nodeInFlow.get(from) !== false) : from.startsWith('N:'));
2463
- const toInFlow = (e && e.toInFlow !== undefined)
2464
- ? !!e.toInFlow
2465
- : (nodeInFlow.has(to) ? (nodeInFlow.get(to) !== false) : to.startsWith('N:'));
2466
- return {
2467
- key: String(e && e.key ? e.key : (from + '->' + to)).trim(),
2468
- from,
2469
- to,
2470
- weight: Number.isFinite(weight) ? weight : 0,
2471
- currentWindowCount: Number.isFinite(currentWindowCount) ? currentWindowCount : 0,
2472
- delta: Number.isFinite(delta) ? delta : 0,
2473
- edgeByEvent,
2474
- delayMs,
2475
- keepVisible,
2476
- viaGa,
2477
- lastAtMs,
2478
- totalCount: Number.isFinite(totalCount) ? totalCount : 0,
2479
- previousWindowCount: Number.isFinite(previousWindowCount) ? previousWindowCount : 0,
2480
- fromInFlow,
2481
- toInFlow,
2482
- linkType: String(e && e.linkType ? e.linkType : (fromInFlow && toInFlow ? 'flow-flow' : (fromInFlow ? 'flow-external' : 'external-flow')))
2483
- };
2484
- }).filter(Boolean).slice(0, 800);
2485
-
2486
- const repeatedCount = Number(summary && summary.counters && summary.counters.repeated ? summary.counters.repeated : 0);
2487
- const eventEntriesRaw = Object.keys(byEvent).map((k) => ({ label: k, value: Number(byEvent[k] || 0) }))
2488
- .filter(x => x.value > 0)
2489
- .sort((a, b) => b.value - a.value);
2490
- const maxEventSlices = 6;
2491
- const selectedEventLabels = new Set();
2492
- const eventEntries = [];
2493
- const repeatEntries = eventEntriesRaw.filter(x => /repeat/i.test(String(x.label || '')));
2494
- const normalEntries = eventEntriesRaw.filter(x => !/repeat/i.test(String(x.label || '')));
2495
- repeatEntries.forEach((entry) => {
2496
- if (eventEntries.length >= maxEventSlices) return;
2497
- if (selectedEventLabels.has(entry.label)) return;
2498
- eventEntries.push(entry);
2499
- selectedEventLabels.add(entry.label);
2500
- });
2501
- normalEntries.forEach((entry) => {
2502
- if (eventEntries.length >= maxEventSlices) return;
2503
- if (selectedEventLabels.has(entry.label)) return;
2504
- eventEntries.push(entry);
2505
- selectedEventLabels.add(entry.label);
2506
- });
2507
- const eventRest = eventEntriesRaw
2508
- .filter(x => !selectedEventLabels.has(x.label))
2509
- .reduce((acc, x) => acc + x.value, 0);
2510
- if (eventRest > 0) eventEntries.push({ label: 'Other', value: eventRest });
2511
- if (repeatedCount > 0) eventEntries.push({ label: 'Repeat flag', value: repeatedCount });
2512
-
2513
- const anomalyTypeCount = {};
2514
- if (anomalyLifecycle.length) {
2515
- anomalyLifecycle.forEach((a) => {
2516
- const t = String(a && a.type ? a.type : 'anomaly');
2517
- anomalyTypeCount[t] = (anomalyTypeCount[t] || 0) + Math.max(1, Number(a && a.count ? a.count : 1));
2518
- });
2519
- } else {
2520
- anomalies.forEach((a) => {
2521
- const t = String(a && a.payload && a.payload.type ? a.payload.type : 'anomaly');
2522
- anomalyTypeCount[t] = (anomalyTypeCount[t] || 0) + 1;
2523
- });
2524
- }
2525
- const anomalyEntries = Object.keys(anomalyTypeCount)
2526
- .map((k) => ({ label: k, value: Number(anomalyTypeCount[k] || 0) }))
2527
- .filter(x => x.value > 0)
2528
- .sort((a, b) => b.value - a.value)
2529
- .slice(0, 6);
2530
-
2531
- return {
2532
- nodes,
2533
- edges,
2534
- eventEntries,
2535
- anomalyEntries,
2536
- telemetryWindowSec: Number(flowMapTopology.windowSec || telemetryWindowSec || 0)
2537
- };
2538
- }
2539
-
2540
- // Rank nodes by traffic + anomaly pressure; this controls visibility priority.
2541
- const nodeScore = {};
2542
- edges.forEach((e) => {
2543
- nodeScore[e.from] = (nodeScore[e.from] || 0) + e.weight;
2544
- nodeScore[e.to] = (nodeScore[e.to] || 0) + e.weight;
2545
- });
2546
- Object.keys(anomalyByGA).forEach((ga) => {
2547
- nodeScore[ga] = (nodeScore[ga] || 0) + (anomalyByGA[ga] * 2);
2548
- });
2549
-
2550
- const nodeIds = Object.keys(nodeScore)
2551
- .sort((a, b) => (nodeScore[b] || 0) - (nodeScore[a] || 0))
2552
- .slice(0, 18);
2553
- const nodeSet = new Set(nodeIds);
2554
- edges = edges.filter(e => nodeSet.has(e.from) && nodeSet.has(e.to));
2555
-
2556
- const nodes = nodeIds.map((id) => ({
2557
- id,
2558
- displayId: id,
2559
- kind: 'ga',
2560
- score: Number(nodeScore[id] || 0),
2561
- anomalyCount: Number(anomalyByGA[id] || 0),
2562
- subtitle: id === 'BUS' ? busLabel : (String(gaLabels[id] || '').trim()),
2563
- payload: toTrimmedText(gaLastPayload[id]),
2564
- lastSeenAtMs: (() => {
2565
- const ts = new Date(String(gaLastSeenAt[id] || '')).getTime();
2566
- return Number.isFinite(ts) ? ts : 0;
2567
- })(),
2568
- inFlow: id === 'BUS' ? true : (flowKnownGASet.size ? flowKnownGASet.has(id) : true)
2569
- }));
2570
-
2571
- // Pie chart composition: keep repeat-related slices visible when present.
2572
- const repeatedCount = Number(summary && summary.counters && summary.counters.repeated ? summary.counters.repeated : 0);
2573
- const eventEntriesRaw = Object.keys(byEvent).map((k) => ({ label: k, value: Number(byEvent[k] || 0) }))
2574
- .filter(x => x.value > 0)
2575
- .sort((a, b) => b.value - a.value);
2576
- const maxEventSlices = 6;
2577
- const selectedEventLabels = new Set();
2578
- const eventEntries = [];
2579
- const repeatEntries = eventEntriesRaw.filter(x => /repeat/i.test(String(x.label || '')));
2580
- const normalEntries = eventEntriesRaw.filter(x => !/repeat/i.test(String(x.label || '')));
2581
-
2582
- repeatEntries.forEach((entry) => {
2583
- if (eventEntries.length >= maxEventSlices) return;
2584
- if (selectedEventLabels.has(entry.label)) return;
2585
- eventEntries.push(entry);
2586
- selectedEventLabels.add(entry.label);
2587
- });
2588
- normalEntries.forEach((entry) => {
2589
- if (eventEntries.length >= maxEventSlices) return;
2590
- if (selectedEventLabels.has(entry.label)) return;
2591
- eventEntries.push(entry);
2592
- selectedEventLabels.add(entry.label);
2593
- });
2594
-
2595
- const eventRest = eventEntriesRaw
2596
- .filter(x => !selectedEventLabels.has(x.label))
2597
- .reduce((acc, x) => acc + x.value, 0);
2598
- if (eventRest > 0) eventEntries.push({ label: 'Other', value: eventRest });
2599
- if (repeatedCount > 0) eventEntries.push({ label: 'Repeat flag', value: repeatedCount });
2600
-
2601
- const anomalyTypeCount = {};
2602
- if (anomalyLifecycle.length) {
2603
- anomalyLifecycle.forEach((a) => {
2604
- const t = String(a && a.type ? a.type : 'anomaly');
2605
- anomalyTypeCount[t] = (anomalyTypeCount[t] || 0) + Math.max(1, Number(a && a.count ? a.count : 1));
2606
- });
2607
- } else {
2608
- anomalies.forEach((a) => {
2609
- const t = String(a && a.payload && a.payload.type ? a.payload.type : 'anomaly');
2610
- anomalyTypeCount[t] = (anomalyTypeCount[t] || 0) + 1;
2611
- });
2612
- }
2613
- const anomalyEntries = Object.keys(anomalyTypeCount)
2614
- .map((k) => ({ label: k, value: Number(anomalyTypeCount[k] || 0) }))
2615
- .filter(x => x.value > 0)
2616
- .sort((a, b) => b.value - a.value)
2617
- .slice(0, 6);
2618
-
2619
- return {
2620
- nodes,
2621
- edges,
2622
- eventEntries,
2623
- anomalyEntries,
2624
- telemetryWindowSec
2625
- };
2626
- };
2627
-
2628
- // Read current multi-select values from the GA selector.
2629
- const collectSelectedFlowGAs = () => {
2630
- if (!$flowGaSelect) return [];
2631
- return Array.from($flowGaSelect.options)
2632
- .filter(opt => opt.selected)
2633
- .map(opt => String(opt.value || '').trim())
2634
- .filter(Boolean);
2635
- };
2636
-
2637
- // Client-side filter for the GA selector list.
2638
- const applyFlowSearchVisibility = () => {
2639
- if (!$flowGaSelect) return;
2640
- const q = String(($flowGaSearch && $flowGaSearch.value) ? $flowGaSearch.value : '').trim().toLowerCase();
2641
- Array.from($flowGaSelect.options).forEach((opt) => {
2642
- if (!q) {
2643
- opt.hidden = false;
2644
- return;
2645
- }
2646
- const txt = (String(opt.text || '') + ' ' + String(opt.value || '')).toLowerCase();
2647
- opt.hidden = !txt.includes(q);
2648
- });
2649
- };
2650
-
2651
- // Keep GA selector options stable across refreshes and preserve old subtitles when possible.
2652
- const updateFlowGaOptions = (nodes) => {
2653
- if (!$flowGaSelect) return;
2654
- const selected = new Set(flowFilters.selectedGa || []);
2655
- const incomingNodes = Array.isArray(nodes) ? nodes.slice() : [];
2656
- const incomingById = new Map();
2657
- incomingNodes.forEach((n) => {
2658
- const id = String(n && n.id ? n.id : '').trim();
2659
- if (!id) return;
2660
- incomingById.set(id, n);
2661
- const cached = flowNodeCache.get(id) || {};
2662
- const next = {
2663
- id,
2664
- displayId: String(n && n.displayId ? n.displayId : (cached.displayId || id)).trim() || id,
2665
- kind: String(n && n.kind ? n.kind : (cached.kind || (id.startsWith('N:') ? 'node' : 'ga'))).trim() || 'ga',
2666
- subtitle: String(n && n.subtitle ? n.subtitle : (cached.subtitle || '')).trim(),
2667
- payload: toTrimmedText((n && n.payload !== undefined && n.payload !== null) ? n.payload : cached.payload),
2668
- inFlow: (n && n.inFlow !== undefined) ? !!n.inFlow : (cached.inFlow !== undefined ? !!cached.inFlow : true),
2669
- lastSeenAtMs: Math.max(Number(cached.lastSeenAtMs || 0), Number(n && n.lastSeenAtMs ? n.lastSeenAtMs : 0))
2670
- };
2671
- flowNodeCache.set(id, next);
2672
- });
2673
-
2674
- // gaOrder controls selector ordering; we append only new IDs at the end.
2675
- const nextOrder = Array.isArray(flowFilters.gaOrder) ? flowFilters.gaOrder.slice() : [];
2676
- const seenOrder = new Set(nextOrder);
2677
- incomingById.forEach((_, id) => {
2678
- if (seenOrder.has(id)) return;
2679
- nextOrder.push(id);
2680
- seenOrder.add(id);
2681
- });
2682
- (flowFilters.selectedGa || []).forEach((id) => {
2683
- const ga = String(id || '').trim();
2684
- if (!ga || seenOrder.has(ga)) return;
2685
- nextOrder.push(ga);
2686
- seenOrder.add(ga);
2687
- });
2688
- // Keep a dedicated layout order so node positions stay stable across refreshes.
2689
- const nextLayoutOrder = (Array.isArray(flowFilters.layoutOrder) && flowFilters.layoutOrder.length > 0)
2690
- ? flowFilters.layoutOrder.slice()
2691
- : (Array.isArray(flowFilters.gaOrder) ? flowFilters.gaOrder.slice() : []);
2692
- const seenLayout = new Set(nextLayoutOrder);
2693
- incomingById.forEach((_, id) => {
2694
- if (seenLayout.has(id)) return;
2695
- nextLayoutOrder.push(id);
2696
- seenLayout.add(id);
2697
- });
2698
-
2699
- let orderChanged = false;
2700
- if (!Array.isArray(flowFilters.gaOrder) || flowFilters.gaOrder.length !== nextOrder.length) {
2701
- orderChanged = true;
2702
- } else {
2703
- for (let i = 0; i < nextOrder.length; i++) {
2704
- if (flowFilters.gaOrder[i] !== nextOrder[i]) { orderChanged = true; break; }
2705
- }
2706
- }
2707
- let layoutChanged = false;
2708
- if (!Array.isArray(flowFilters.layoutOrder) || flowFilters.layoutOrder.length !== nextLayoutOrder.length) {
2709
- layoutChanged = true;
2710
- } else {
2711
- for (let i = 0; i < nextLayoutOrder.length; i++) {
2712
- if (flowFilters.layoutOrder[i] !== nextLayoutOrder[i]) { layoutChanged = true; break; }
2713
- }
2714
- }
2715
- if (orderChanged) {
2716
- flowFilters.gaOrder = nextOrder.slice(0, 600);
2717
- }
2718
- if (layoutChanged) flowFilters.layoutOrder = nextLayoutOrder.slice(0, 600);
2719
- if (orderChanged || layoutChanged) saveFlowFilters(flowFilters);
2720
-
2721
- // Preserve subtitles and scroll position to avoid UX jumps while refreshing.
2722
- const prevMetaById = new Map();
2723
- Array.from($flowGaSelect.options).forEach((opt) => {
2724
- prevMetaById.set(String(opt.value || ''), {
2725
- subtitle: String(opt.dataset.subtitle || ''),
2726
- displayId: String(opt.dataset.displayId || ''),
2727
- kind: String(opt.dataset.kind || '')
2728
- });
2729
- });
2730
- const prevScrollTop = $flowGaSelect.scrollTop || 0;
2731
- $flowGaSelect.innerHTML = '';
2732
-
2733
- (flowFilters.gaOrder || []).forEach((idRaw) => {
2734
- const id = String(idRaw || '').trim();
2735
- if (!id) return;
2736
- const n = incomingById.get(id);
2737
- const cached = flowNodeCache.get(id) || {};
2738
- const prevMeta = prevMetaById.get(id) || {};
2739
- const displayId = n
2740
- ? String(n && n.displayId ? n.displayId : id).trim()
2741
- : String(cached.displayId || prevMeta.displayId || id).trim();
2742
- const kind = n
2743
- ? String(n && n.kind ? n.kind : (id.startsWith('N:') ? 'node' : 'ga')).trim()
2744
- : String(cached.kind || prevMeta.kind || (id.startsWith('N:') ? 'node' : 'ga')).trim();
2745
- const subtitle = n
2746
- ? String(n && n.subtitle ? n.subtitle : '').trim()
2747
- : String(cached.subtitle || prevMeta.subtitle || '').trim();
2748
- const inFlow = n
2749
- ? ((n && n.inFlow !== undefined) ? !!n.inFlow : true)
2750
- : (cached.inFlow !== undefined ? !!cached.inFlow : true);
2751
- const option = document.createElement('option');
2752
- option.value = id;
2753
- const extTag = (!inFlow && kind !== 'node') ? ' [ext]' : '';
2754
- option.textContent = subtitle ? `${displayId}${extTag} | ${subtitle}` : `${displayId}${extTag}`;
2755
- option.selected = selected.has(id);
2756
- option.dataset.subtitle = subtitle;
2757
- option.dataset.displayId = displayId;
2758
- option.dataset.kind = kind || 'ga';
2759
- option.dataset.inFlow = inFlow ? '1' : '0';
2760
- const cls = [];
2761
- if (!n) cls.push('flow-ga-stale');
2762
- if (!inFlow && kind !== 'node') cls.push('flow-ga-external');
2763
- if (cls.length) option.className = cls.join(' ');
2764
- $flowGaSelect.appendChild(option);
2765
- });
2766
- applyFlowSearchVisibility();
2767
- $flowGaSelect.scrollTop = prevScrollTop;
2768
- };
2769
-
2770
- // Apply persistent filter/layout constraints to the raw dashboard graph.
2771
- const applyFlowFilters = (dashboard) => {
2772
- const graph = dashboard || { nodes: [], edges: [] };
2773
- const nodesAll = Array.isArray(graph.nodes) ? graph.nodes.slice() : [];
2774
- const edgesAll = Array.isArray(graph.edges) ? graph.edges.slice() : [];
2775
- const maxGa = Math.max(4, Math.min(60, Number(flowFilters.maxGa || 14)));
2776
- const now = Date.now();
2777
- const nodeIdleTimeoutMs = Math.max(10000, Math.min(300000, ((Number(graph.telemetryWindowSec || 0) || 10) * 2000)));
2778
- // Active nodes seen in this refresh window.
2779
- const activeById = new Map();
2780
- nodesAll.forEach((n) => {
2781
- const id = String(n && n.id ? n.id : '').trim();
2782
- if (!id) return;
2783
- activeById.set(id, n);
2784
- const cached = flowNodeCache.get(id) || {};
2785
- flowNodeCache.set(id, {
2786
- id,
2787
- displayId: String(n && n.displayId ? n.displayId : (cached.displayId || id)).trim() || id,
2788
- kind: String(n && n.kind ? n.kind : (cached.kind || (id.startsWith('N:') ? 'node' : 'ga'))).trim() || 'ga',
2789
- subtitle: String(n && n.subtitle ? n.subtitle : (cached.subtitle || '')).trim(),
2790
- payload: toTrimmedText((n && n.payload !== undefined && n.payload !== null) ? n.payload : cached.payload),
2791
- inFlow: (n && n.inFlow !== undefined) ? !!n.inFlow : (cached.inFlow !== undefined ? !!cached.inFlow : true),
2792
- lastSeenAtMs: Math.max(Number(cached.lastSeenAtMs || 0), Number(n && n.lastSeenAtMs ? n.lastSeenAtMs : 0))
2793
- });
2794
- });
2795
-
2796
- const optionSubtitleById = new Map();
2797
- const optionDisplayById = new Map();
2798
- const optionKindById = new Map();
2799
- const optionInFlowById = new Map();
2800
- if ($flowGaSelect) {
2801
- Array.from($flowGaSelect.options || []).forEach((opt) => {
2802
- const id = String(opt && opt.value ? opt.value : '').trim();
2803
- if (!id) return;
2804
- optionSubtitleById.set(id, String((opt.dataset && opt.dataset.subtitle) ? opt.dataset.subtitle : '').trim());
2805
- optionDisplayById.set(id, String((opt.dataset && opt.dataset.displayId) ? opt.dataset.displayId : '').trim());
2806
- optionKindById.set(id, String((opt.dataset && opt.dataset.kind) ? opt.dataset.kind : '').trim());
2807
- optionInFlowById.set(id, String((opt.dataset && opt.dataset.inFlow) ? opt.dataset.inFlow : '1') !== '0');
2808
- });
2809
- }
2810
-
2811
- // Known catalog: includes historical IDs so bubbles remain fixed and can become "disabled".
2812
- const knownIds = [];
2813
- const seenKnown = new Set();
2814
- const pushKnown = (idRaw) => {
2815
- const id = String(idRaw || '').trim();
2816
- if (!id || seenKnown.has(id)) return;
2817
- seenKnown.add(id);
2818
- knownIds.push(id);
2819
- };
2820
- // Build a stable catalog: once a node is seen, keep it renderable as disabled.
2821
- (flowFilters.layoutOrder || []).forEach(pushKnown);
2822
- (flowFilters.gaOrder || []).forEach(pushKnown);
2823
- nodesAll.forEach((n) => pushKnown(n && n.id));
2824
- (flowFilters.selectedGa || []).forEach(pushKnown);
2825
-
2826
- const knownNodeById = new Map();
2827
- knownIds.forEach((id) => {
2828
- const active = activeById.get(id);
2829
- const cached = flowNodeCache.get(id) || {};
2830
- const subtitleFallback = String(optionSubtitleById.get(id) || cached.subtitle || '').trim();
2831
- const displayFallback = String(optionDisplayById.get(id) || cached.displayId || id).trim() || id;
2832
- const kindFallback = String(optionKindById.get(id) || cached.kind || (id.startsWith('N:') ? 'node' : 'ga')).trim() || 'ga';
2833
- const inFlowFallback = cached.inFlow !== undefined ? !!cached.inFlow : (optionInFlowById.has(id) ? !!optionInFlowById.get(id) : true);
2834
- if (active) {
2835
- const lastSeenAtMs = Math.max(Number(active.lastSeenAtMs || 0), Number(cached.lastSeenAtMs || 0));
2836
- const isRecentOrUnknown = lastSeenAtMs <= 0 || (now - lastSeenAtMs) <= nodeIdleTimeoutMs;
2837
- const merged = Object.assign({}, cached, active, {
2838
- id,
2839
- displayId: String(active.displayId || displayFallback).trim() || id,
2840
- kind: String(active.kind || kindFallback).trim() || 'ga',
2841
- subtitle: String(active.subtitle || subtitleFallback).trim(),
2842
- payload: toTrimmedText((active.payload !== undefined && active.payload !== null) ? active.payload : cached.payload),
2843
- inFlow: (active.inFlow !== undefined) ? !!active.inFlow : inFlowFallback,
2844
- lastSeenAtMs,
2845
- disabled: !isRecentOrUnknown
2846
- });
2847
- flowNodeCache.set(id, merged);
2848
- knownNodeById.set(id, merged);
2849
- } else {
2850
- const lastSeenAtMs = Number(cached.lastSeenAtMs || 0);
2851
- const isRecent = lastSeenAtMs > 0 && (now - lastSeenAtMs) <= nodeIdleTimeoutMs;
2852
- const staleNode = {
2853
- id,
2854
- displayId: displayFallback,
2855
- kind: kindFallback,
2856
- score: 0,
2857
- anomalyCount: 0,
2858
- subtitle: subtitleFallback,
2859
- payload: toTrimmedText(cached.payload),
2860
- inFlow: inFlowFallback,
2861
- lastSeenAtMs,
2862
- disabled: !isRecent
2863
- };
2864
- flowNodeCache.set(id, Object.assign({}, cached, staleNode));
2865
- knownNodeById.set(id, staleNode);
2866
- }
2867
- });
2868
-
2869
- // Keep cache bounded while preserving nodes currently visible/known.
2870
- if (flowNodeCache.size > 1200) {
2871
- const minKeepTs = now - Math.max(300000, nodeIdleTimeoutMs * 3);
2872
- const keepEntries = Array.from(flowNodeCache.values())
2873
- .filter((n) => {
2874
- const id = String(n && n.id ? n.id : '');
2875
- if (!id) return false;
2876
- if (knownNodeById.has(id)) return true;
2877
- return Number(n && n.lastSeenAtMs ? n.lastSeenAtMs : 0) >= minKeepTs;
2878
- })
2879
- .slice(-1200);
2880
- flowNodeCache = new Map(keepEntries.map((n) => [String(n.id || ''), n]).filter(([k]) => !!k));
2881
- }
2882
-
2883
- // Explicit user selection overrides "max GA" truncation.
2884
- const selectedSet = new Set(
2885
- (flowFilters.selectedGa || [])
2886
- .map(id => String(id || '').trim())
2887
- .filter(id => knownNodeById.has(id))
2888
- );
2889
-
2890
- let nodesFiltered = [];
2891
- if (selectedSet.size > 0) {
2892
- nodesFiltered = Array.from(selectedSet.values())
2893
- .map((id) => knownNodeById.get(id))
2894
- .filter(Boolean);
2895
- // When user selects specific nodes, keep directly connected peers visible too
2896
- // so directional links (for example KNX source -> GA) remain renderable.
2897
- const selectedIds = new Set(nodesFiltered.map((n) => String(n && n.id ? n.id : '').trim()).filter(Boolean));
2898
- const extraIds = new Set();
2899
- edgesAll.forEach((e) => {
2900
- const from = String(e && e.from ? e.from : '').trim();
2901
- const to = String(e && e.to ? e.to : '').trim();
2902
- if (!from || !to) return;
2903
- if (selectedIds.has(from) && knownNodeById.has(to)) extraIds.add(to);
2904
- if (selectedIds.has(to) && knownNodeById.has(from)) extraIds.add(from);
2905
- });
2906
- Array.from(extraIds.values()).forEach((id) => {
2907
- if (selectedIds.has(id)) return;
2908
- const n = knownNodeById.get(id);
2909
- if (!n) return;
2910
- nodesFiltered.push(n);
2911
- selectedIds.add(id);
2912
- });
2913
- } else {
2914
- const orderedIds = (flowFilters.layoutOrder && flowFilters.layoutOrder.length)
2915
- ? flowFilters.layoutOrder.map(id => String(id || '').trim())
2916
- : knownIds.slice();
2917
- nodesFiltered = orderedIds
2918
- .map((id) => knownNodeById.get(id))
2919
- .filter(Boolean)
2920
- .slice(0, maxGa);
2921
-
2922
- if (!nodesFiltered.length) {
2923
- nodesFiltered = nodesAll
2924
- .slice()
2925
- .sort((a, b) => Number(b.score || 0) - Number(a.score || 0))
2926
- .slice(0, maxGa)
2927
- .map((n) => {
2928
- const id = String(n && n.id ? n.id : '').trim();
2929
- const cached = flowNodeCache.get(id) || {};
2930
- const lastSeenAtMs = Math.max(Number(n && n.lastSeenAtMs ? n.lastSeenAtMs : 0), Number(cached.lastSeenAtMs || 0));
2931
- const isRecentOrUnknown = lastSeenAtMs <= 0 || (now - lastSeenAtMs) <= nodeIdleTimeoutMs;
2932
- return Object.assign({}, cached, n, { lastSeenAtMs, disabled: !isRecentOrUnknown });
2933
- });
2934
- }
2935
- }
2936
-
2937
- // Always include endpoints of keepVisible edges (for synthesized response arrows),
2938
- // even when Max GA truncation would normally hide them.
2939
- const mustHaveIds = new Set();
2940
- edgesAll.forEach((e) => {
2941
- if (!(e && e.keepVisible === true)) return;
2942
- const from = String(e && e.from ? e.from : '').trim();
2943
- const to = String(e && e.to ? e.to : '').trim();
2944
- if (from && knownNodeById.has(from)) mustHaveIds.add(from);
2945
- if (to && knownNodeById.has(to)) mustHaveIds.add(to);
2946
- });
2947
- if (mustHaveIds.size) {
2948
- const present = new Set(nodesFiltered.map(n => String(n && n.id ? n.id : '').trim()).filter(Boolean));
2949
- Array.from(mustHaveIds.values()).forEach((id) => {
2950
- if (!id || present.has(id)) return;
2951
- const node = knownNodeById.get(id);
2952
- if (!node) return;
2953
- nodesFiltered.push(node);
2954
- present.add(id);
2955
- });
2956
- }
2957
-
2958
- const nodeSet = new Set(nodesFiltered.map(n => n.id));
2959
- // Parse "from->to" edge keys persisted in edgeOrder.
2960
- const parseEdgeKey = (keyRaw) => {
2961
- const key = String(keyRaw || '').trim();
2962
- if (!key) return null;
2963
- const sep = key.indexOf('->');
2964
- if (sep === -1) return null;
2965
- const from = key.slice(0, sep).trim();
2966
- const to = key.slice(sep + 2).trim();
2967
- if (!from || !to) return null;
2968
- return { key, from, to };
2969
- };
2970
-
2971
- // edgeOrder is append-only: existing links keep a stable render sequence.
2972
- const nextEdgeOrder = Array.isArray(flowFilters.edgeOrder) ? flowFilters.edgeOrder.slice() : [];
2973
- const seenEdgeOrder = new Set(nextEdgeOrder);
2974
- edgesAll.forEach((e) => {
2975
- const key = String(e && e.key ? e.key : '').trim();
2976
- if (!key) return;
2977
- if (seenEdgeOrder.has(key)) return;
2978
- nextEdgeOrder.push(key);
2979
- seenEdgeOrder.add(key);
2980
- });
2981
- if (nextEdgeOrder.length > 4000) {
2982
- flowFilters.edgeOrder = nextEdgeOrder.slice(nextEdgeOrder.length - 4000);
2983
- saveFlowFilters(flowFilters);
2984
- } else {
2985
- let edgeOrderChanged = false;
2986
- if (!Array.isArray(flowFilters.edgeOrder) || flowFilters.edgeOrder.length !== nextEdgeOrder.length) {
2987
- edgeOrderChanged = true;
2988
- } else {
2989
- for (let i = 0; i < nextEdgeOrder.length; i++) {
2990
- if (flowFilters.edgeOrder[i] !== nextEdgeOrder[i]) { edgeOrderChanged = true; break; }
2991
- }
2992
- }
2993
- if (edgeOrderChanged) {
2994
- flowFilters.edgeOrder = nextEdgeOrder.slice();
2995
- saveFlowFilters(flowFilters);
2996
- }
2997
- }
2998
-
2999
- // Active links in current payload; edge cache keeps stale links available in idle mode.
3000
- const activeEdgeByKey = new Map();
3001
- edgesAll.forEach((e) => {
3002
- const key = String(e && e.key ? e.key : '').trim();
3003
- if (!key) return;
3004
- // Cache the last known edge payload; if edge traffic stops, keep the link visible in idle mode.
3005
- flowEdgeCache.set(key, Object.assign({}, e, { disabled: false }));
3006
- activeEdgeByKey.set(key, Object.assign({}, e, { disabled: false }));
3007
- });
3008
-
3009
- const hasAnyMappedFlowNode = Array.from(knownNodeById.values()).some((n) => n && n.inFlow !== false);
3010
- const edgeKeysToRender = (flowFilters.edgeOrder || []).filter((keyRaw) => {
3011
- const parsed = parseEdgeKey(keyRaw);
3012
- if (!parsed) return false;
3013
- if (!nodeSet.has(parsed.from) || !nodeSet.has(parsed.to)) return false;
3014
- const edgeSnapshot = activeEdgeByKey.get(parsed.key) || flowEdgeCache.get(parsed.key);
3015
- if (edgeSnapshot && edgeSnapshot.keepVisible === true) return true;
3016
- const fromNode = knownNodeById.get(parsed.from);
3017
- const toNode = knownNodeById.get(parsed.to);
3018
- const fromInFlow = fromNode ? (fromNode.inFlow !== false) : false;
3019
- const toInFlow = toNode ? (toNode.inFlow !== false) : false;
3020
- // Graph must represent flow<->flow and flow<->external only.
3021
- // If no flow-mapped nodes are available, keep external bus links visible.
3022
- if (!hasAnyMappedFlowNode) return true;
3023
- return fromInFlow || toInFlow;
3024
- });
3025
-
3026
- const edgesFiltered = edgeKeysToRender.map((keyRaw) => {
3027
- const key = String(keyRaw || '').trim();
3028
- const active = activeEdgeByKey.get(key);
3029
- if (active) return active;
3030
- const cached = flowEdgeCache.get(key);
3031
- // Cached-only edges must render as idle baseline, never as active traffic.
3032
- if (cached) {
3033
- const fromNode = knownNodeById.get(String(cached.from || ''));
3034
- const toNode = knownNodeById.get(String(cached.to || ''));
3035
- const fromInFlow = fromNode ? (fromNode.inFlow !== false) : false;
3036
- const toInFlow = toNode ? (toNode.inFlow !== false) : false;
3037
- return Object.assign({}, cached, {
3038
- fromInFlow,
3039
- toInFlow,
3040
- linkType: fromInFlow && toInFlow ? 'flow-flow' : (fromInFlow ? 'flow-external' : 'external-flow'),
3041
- disabled: true,
3042
- delta: 0,
3043
- currentWindowCount: 0,
3044
- lastAtMs: 0
3045
- });
3046
- }
3047
- const parsed = parseEdgeKey(key);
3048
- if (!parsed) return null;
3049
- const fromNode = knownNodeById.get(parsed.from);
3050
- const toNode = knownNodeById.get(parsed.to);
3051
- const fromInFlow = fromNode ? (fromNode.inFlow !== false) : false;
3052
- const toInFlow = toNode ? (toNode.inFlow !== false) : false;
3053
- return {
3054
- key: parsed.key,
3055
- from: parsed.from,
3056
- to: parsed.to,
3057
- fromInFlow,
3058
- toInFlow,
3059
- linkType: fromInFlow && toInFlow ? 'flow-flow' : (fromInFlow ? 'flow-external' : 'external-flow'),
3060
- weight: 1,
3061
- currentWindowCount: 0,
3062
- delta: 0,
3063
- edgeByEvent: {},
3064
- keepVisible: false,
3065
- viaGa: '',
3066
- delayMs: 0,
3067
- lastAtMs: 0,
3068
- disabled: true,
3069
- };
3070
- }).filter(Boolean);
3071
-
3072
- return Object.assign({}, graph, {
3073
- nodes: nodesFiltered,
3074
- edges: edgesFiltered,
3075
- selectedCount: selectedSet.size,
3076
- maxGa,
3077
- nodeIdleTimeoutMs
3078
- });
3079
- };
3080
-
3081
- const polarPoint = (cx, cy, r, angleRad) => ({
3082
- x: cx + (r * Math.cos(angleRad)),
3083
- y: cy + (r * Math.sin(angleRad))
3084
- });
3085
-
3086
- // Build the path of one pie slice between two angles.
3087
- const pieSlicePath = (cx, cy, r, a0, a1) => {
3088
- const p0 = polarPoint(cx, cy, r, a0);
3089
- const p1 = polarPoint(cx, cy, r, a1);
3090
- const large = (a1 - a0) > Math.PI ? 1 : 0;
3091
- return `M ${cx} ${cy} L ${p0.x} ${p0.y} A ${r} ${r} 0 ${large} 1 ${p1.x} ${p1.y} Z`;
3092
- };
3093
-
3094
- // Draw donut-style pie + legend for event/anomaly distributions.
3095
- const renderPie = (svgEl, legendEl, entries, titlePrefix, options) => {
3096
- svgEl.innerHTML = '';
3097
- legendEl.innerHTML = '';
3098
- const opts = options || {};
3099
- const rows = Array.isArray(entries) ? entries.filter(e => e && Number(e.value || 0) > 0) : [];
3100
- const total = rows.reduce((acc, x) => acc + Number(x.value || 0), 0);
3101
- if (!rows.length || total <= 0) {
3102
- const t = createSvgEl('text', { x: 90, y: 92, fill: '#837ba6', 'text-anchor': 'middle', 'font-size': 12 });
3103
- t.textContent = 'No data';
3104
- svgEl.appendChild(t);
3105
- const empty = document.createElement('div');
3106
- empty.className = 'empty';
3107
- empty.textContent = 'No data available.';
3108
- legendEl.appendChild(empty);
3109
- if (opts.showAnalyzing && opts.activityContext) {
3110
- legendEl.appendChild(createAnalyzingStateEl(opts.activityContext, true));
3111
- }
3112
- return;
3113
- }
3114
-
3115
- let start = -Math.PI / 2;
3116
- rows.forEach((row, idx) => {
3117
- const value = Number(row.value || 0);
3118
- const slice = (value / total) * Math.PI * 2;
3119
- const end = start + slice;
3120
- const color = /repeat flag/i.test(String(row.label || '')) ? '#c62828' : pickColor(idx);
3121
- const path = createSvgEl('path', {
3122
- d: pieSlicePath(90, 90, 72, start, end),
3123
- fill: color,
3124
- stroke: '#fff',
3125
- 'stroke-width': 1
3126
- });
3127
- path.appendChild(createSvgEl('title', {}));
3128
- path.lastChild.textContent = `${titlePrefix} ${row.label}: ${value}`;
3129
- svgEl.appendChild(path);
3130
- start = end;
3131
-
3132
- const pct = total > 0 ? ((value * 100) / total) : 0;
3133
- const legendItem = document.createElement('div');
3134
- legendItem.className = 'pie-legend-item';
3135
- const dot = document.createElement('span');
3136
- dot.className = 'pie-dot';
3137
- dot.style.backgroundColor = color;
3138
- const label = document.createElement('span');
3139
- label.className = 'pie-label';
3140
- label.textContent = `${row.label} (${value}, ${pct.toFixed(0)}%)`;
3141
- legendItem.appendChild(dot);
3142
- legendItem.appendChild(label);
3143
- legendEl.appendChild(legendItem);
3144
- });
3145
-
3146
- const hole = createSvgEl('circle', { cx: 90, cy: 90, r: 35, fill: '#fff' });
3147
- svgEl.appendChild(hole);
3148
- const centerLabel = createSvgEl('text', { x: 90, y: 86, fill: '#4a426f', 'text-anchor': 'middle', 'font-size': 12, 'font-weight': 700 });
3149
- centerLabel.textContent = String(total);
3150
- svgEl.appendChild(centerLabel);
3151
- const centerSub = createSvgEl('text', { x: 90, y: 101, fill: '#7a739a', 'text-anchor': 'middle', 'font-size': 10 });
3152
- centerSub.textContent = 'total';
3153
- svgEl.appendChild(centerSub);
3154
- };
3155
-
3156
- // Draw "Top links" stacked bars by edge and event type mix.
3157
- const renderEdgeStack = (edges) => {
3158
- $edgeStackWrap.innerHTML = '';
3159
- const rows = Array.isArray(edges) ? edges.slice(0, 10) : [];
3160
- if (!rows.length) {
3161
- const empty = document.createElement('div');
3162
- empty.className = 'empty';
3163
- empty.textContent = 'No links to display.';
3164
- $edgeStackWrap.appendChild(empty);
3165
- return;
3166
- }
3167
-
3168
- const maxTotal = Math.max(1, ...rows.map(r => Number(r.weight || 0)));
3169
- rows.forEach((row) => {
3170
- const total = Math.max(0, Number(row.weight || 0));
3171
- const eventRaw = row.edgeByEvent && Object.keys(row.edgeByEvent).length
3172
- ? row.edgeByEvent
3173
- : { traffic: total };
3174
- const events = Object.keys(eventRaw)
3175
- .map((name) => ({ name, value: Number(eventRaw[name] || 0), key: normalizeEvent(name) }))
3176
- .filter(x => x.value > 0)
3177
- .sort((a, b) => b.value - a.value);
3178
- if (!events.length) events.push({ name: 'traffic', value: total, key: 'other' });
3179
-
3180
- const rowEl = document.createElement('div');
3181
- rowEl.className = 'stack-row';
3182
- const label = document.createElement('div');
3183
- label.className = 'stack-label';
3184
- label.textContent = `${shortNodeLabel(row.from)} -> ${shortNodeLabel(row.to)}`;
3185
- label.title = `${row.from} -> ${row.to}`;
3186
-
3187
- const track = document.createElement('div');
3188
- track.className = 'stack-track';
3189
- const totalBar = document.createElement('div');
3190
- totalBar.className = 'stack-total';
3191
- totalBar.style.width = ((total / maxTotal) * 100).toFixed(1) + '%';
3192
- events.forEach((ev) => {
3193
- const seg = document.createElement('div');
3194
- seg.className = 'stack-seg';
3195
- seg.style.width = ((ev.value / total) * 100).toFixed(2) + '%';
3196
- seg.style.backgroundColor = EVENT_COLORS[ev.key] || EVENT_COLORS.other;
3197
- seg.title = `${ev.name}: ${ev.value}`;
3198
- totalBar.appendChild(seg);
3199
- });
3200
- track.appendChild(totalBar);
3201
-
3202
- const value = document.createElement('div');
3203
- value.className = 'stack-value';
3204
- value.textContent = String(total);
3205
-
3206
- rowEl.appendChild(label);
3207
- rowEl.appendChild(track);
3208
- rowEl.appendChild(value);
3209
- $edgeStackWrap.appendChild(rowEl);
3210
- });
3211
- };
3212
-
3213
- // Render the flow map as a fixed-grid graph with directional, traffic-aware links.
3214
- const renderFlowMap = (graph) => {
3215
- const nodes = graph.nodes || [];
3216
- const edges = graph.edges || [];
3217
- $flowSvg.innerHTML = '';
3218
- if (!nodes.length) {
3219
- $flowEmpty.style.display = 'flex';
3220
- $flowMeta.textContent = 'Nodes: 0 | Links: 0';
3221
- return;
3222
- }
3223
- $flowEmpty.style.display = 'none';
3224
-
3225
- const width = Math.max(560, Math.floor(($flowWrap && $flowWrap.clientWidth) ? $flowWrap.clientWidth - 20 : 900));
3226
- const baseHeight = 420;
3227
- const nodeRadius = 20;
3228
-
3229
- const anomalyNodeCount = nodes.filter(n => Number(n.anomalyCount || 0) > 0).length;
3230
- const winInfo = graph.telemetryWindowSec ? (' | Window: ' + graph.telemetryWindowSec + 's') : '';
3231
- const filterInfo = graph.selectedCount > 0
3232
- ? (` | Selected GA: ${graph.selectedCount}`)
3233
- : (` | Max GA: ${graph.maxGa}`);
3234
- const idleInfo = graph.nodeIdleTimeoutMs ? (` | Node idle: ${Math.round(Number(graph.nodeIdleTimeoutMs || 0) / 1000)}s`) : '';
3235
- $flowMeta.textContent = `Nodes: ${nodes.length} | Links: ${edges.length} | Anomalous nodes: ${anomalyNodeCount}${filterInfo}${winInfo}${idleInfo}`;
3236
-
3237
- const defs = createSvgEl('defs');
3238
- const addArrowMarker = (id, color) => {
3239
- const marker = createSvgEl('marker', {
3240
- id,
3241
- markerWidth: 9,
3242
- markerHeight: 7,
3243
- refX: 8,
3244
- refY: 3.5,
3245
- orient: 'auto',
3246
- markerUnits: 'strokeWidth'
3247
- });
3248
- marker.appendChild(createSvgEl('path', { d: 'M0,0 L9,3.5 L0,7 z', fill: color }));
3249
- defs.appendChild(marker);
3250
- };
3251
- Object.keys(EVENT_COLORS).forEach((eventType) => {
3252
- addArrowMarker(edgeMarkerIdForType(eventType), EVENT_COLORS[eventType] || EVENT_COLORS.other);
3253
- });
3254
- $flowSvg.appendChild(defs);
3255
-
3256
- // Deterministic slots: IDs keep the same grid position once learned.
3257
- const slotById = new Map();
3258
- let nextSlot = 0;
3259
- (flowFilters.layoutOrder || []).forEach((idRaw) => {
3260
- const id = String(idRaw || '').trim();
3261
- if (!id || slotById.has(id)) return;
3262
- slotById.set(id, nextSlot);
3263
- nextSlot++;
3264
- });
3265
- nodes.forEach((n) => {
3266
- const id = String(n && n.id ? n.id : '').trim();
3267
- if (!id || slotById.has(id)) return;
3268
- slotById.set(id, nextSlot);
3269
- nextSlot++;
3270
- });
3271
-
3272
- // Render by stable slot order, then lexical fallback.
3273
- const sortedNodes = nodes.slice().sort((a, b) => {
3274
- const sa = Number(slotById.get(String(a && a.id ? a.id : '')) || 0);
3275
- const sb = Number(slotById.get(String(b && b.id ? b.id : '')) || 0);
3276
- if (sa !== sb) return sa - sb;
3277
- return String(a.id || '').localeCompare(String(b.id || ''));
3278
- });
3279
-
3280
- // Compute a compact grid shape based on current capacity.
3281
- const layoutSeed = Math.max(4, Number(flowFilters.maxGa || graph.maxGa || sortedNodes.length || 4));
3282
- const cols = Math.max(2, Math.min(8, Math.ceil(Math.sqrt(layoutSeed * 1.45))));
3283
- let maxSlot = 0;
3284
- sortedNodes.forEach((n) => {
3285
- const slot = Number(slotById.get(String(n && n.id ? n.id : '')) || 0);
3286
- if (slot > maxSlot) maxSlot = slot;
3287
- });
3288
- const rows = Math.max(1, Math.floor(maxSlot / cols) + 1);
3289
- const padX = 62;
3290
- const padY = 32;
3291
- // Increase vertical spacing dynamically to avoid crowded rows.
3292
- const minRowStep = 112;
3293
- const extraBottomForLabels = 56;
3294
- const requiredHeight = (padY * 2) + ((rows - 1) * minRowStep) + extraBottomForLabels;
3295
- const height = Math.max(baseHeight, requiredHeight);
3296
- $flowSvg.setAttribute('viewBox', '0 0 ' + width + ' ' + height);
3297
- $flowSvg.style.height = `${height}px`;
3298
- if ($flowWrap) $flowWrap.style.minHeight = `${height + 24}px`;
3299
- const usableW = Math.max(40, width - (padX * 2));
3300
- const usableH = Math.max(40, height - (padY * 2));
3301
- const stepX = cols > 1 ? (usableW / (cols - 1)) : 0;
3302
- const stepY = rows > 1 ? (usableH / (rows - 1)) : 0;
3303
- const positions = new Map();
3304
- sortedNodes.forEach((n) => {
3305
- const slot = Number(slotById.get(String(n && n.id ? n.id : '')) || 0);
3306
- const row = Math.floor(slot / cols);
3307
- const col = slot % cols;
3308
- const x = padX + (stepX * col);
3309
- const y = padY + (stepY * row);
3310
- positions.set(n.id, { x, y, row, col });
3311
- });
3312
- const obstacleNodes = sortedNodes
3313
- .map((n) => {
3314
- const id = String(n && n.id ? n.id : '').trim();
3315
- const p = positions.get(id);
3316
- if (!id || !p) return null;
3317
- return { id, x: Number(p.x || 0), y: Number(p.y || 0) };
3318
- })
3319
- .filter(Boolean);
3320
- const pointToSegmentDistance = (px, py, x1, y1, x2, y2) => {
3321
- const dx = x2 - x1;
3322
- const dy = y2 - y1;
3323
- const len2 = (dx * dx) + (dy * dy);
3324
- if (len2 <= 0.000001) return Math.hypot(px - x1, py - y1);
3325
- let t = (((px - x1) * dx) + ((py - y1) * dy)) / len2;
3326
- t = Math.max(0, Math.min(1, t));
3327
- const qx = x1 + (t * dx);
3328
- const qy = y1 + (t * dy);
3329
- return Math.hypot(px - qx, py - qy);
3330
- };
3331
- const quadraticPointAt = (t, x0, y0, cx, cy, x1, y1) => {
3332
- const mt = 1 - t;
3333
- const qx = (mt * mt * x0) + (2 * mt * t * cx) + (t * t * x1);
3334
- const qy = (mt * mt * y0) + (2 * mt * t * cy) + (t * t * y1);
3335
- return { x: qx, y: qy };
3336
- };
3337
- const edgeObstaclePenalty = ({ sx, sy, cx, cy, ex, ey, fromId, toId, skipId = '' }) => {
3338
- const avoidRadius = nodeRadius + 11;
3339
- let penalty = 0;
3340
- for (let i = 0; i < obstacleNodes.length; i++) {
3341
- const o = obstacleNodes[i];
3342
- if (!o) continue;
3343
- if (o.id === fromId || o.id === toId || (skipId && o.id === skipId)) continue;
3344
- const chordDist = pointToSegmentDistance(o.x, o.y, sx, sy, ex, ey);
3345
- if (chordDist > (avoidRadius + 36)) continue;
3346
- let minDist = Infinity;
3347
- const tSamples = [0.15, 0.30, 0.50, 0.70, 0.85];
3348
- for (let s = 0; s < tSamples.length; s++) {
3349
- const qp = quadraticPointAt(tSamples[s], sx, sy, cx, cy, ex, ey);
3350
- const d = Math.hypot(qp.x - o.x, qp.y - o.y);
3351
- if (d < minDist) minDist = d;
3352
- }
3353
- if (minDist < avoidRadius) {
3354
- const miss = avoidRadius - minDist;
3355
- penalty += (miss * miss);
3356
- }
3357
- }
3358
- return penalty;
3359
- };
3360
- const nodeInFlowById = new Map(sortedNodes.map(n => [String(n.id || ''), n.inFlow !== false]));
3361
-
3362
- // Edge thickness is proportional to traffic in current selection.
3363
- const maxWeight = Math.max(1, ...edges.map(e => Number(e.weight || 0)));
3364
- const now = Date.now();
3365
- // A link is visually active only if its latest telegram is within 5s.
3366
- const activeTrafficWindowMs = 5000;
3367
- const activeWindowSec = Math.max(1, Math.round(activeTrafficWindowMs / 1000));
3368
- const edgePriority = (edge) => {
3369
- const byEvent = (edge && edge.edgeByEvent && typeof edge.edgeByEvent === 'object') ? edge.edgeByEvent : {};
3370
- const keys = Object.keys(byEvent).map(k => String(k || ''));
3371
- const isResponder = keys.some(k => /response\s*\(responder\)/i.test(k));
3372
- const isReply = keys.some(k => /response\s*\(reply\)/i.test(k));
3373
- if (isResponder) return 3;
3374
- if (isReply) return 2;
3375
- if (edge && edge.keepVisible === true) return 1;
3376
- return 0;
3377
- };
3378
- const edgesToRender = edges.slice().sort((a, b) => edgePriority(a) - edgePriority(b));
3379
- edgesToRender.forEach((edge) => {
3380
- const fromPos = positions.get(edge.from);
3381
- const toPos = positions.get(edge.to);
3382
- if (!fromPos || !toPos) return;
3383
- const dx = toPos.x - fromPos.x;
3384
- const dy = toPos.y - fromPos.y;
3385
- const dist = Math.max(0.001, Math.sqrt((dx * dx) + (dy * dy)));
3386
- const ux = dx / dist;
3387
- const uy = dy / dist;
3388
- const startX = fromPos.x + (ux * (nodeRadius + 2));
3389
- const startY = fromPos.y + (uy * (nodeRadius + 2));
3390
- const endX = toPos.x - (ux * (nodeRadius + 8));
3391
- const endY = toPos.y - (uy * (nodeRadius + 8));
3392
-
3393
- const w = Number(edge.weight || 0);
3394
- const thickness = (1.2 + ((w / maxWeight) * 6.4)).toFixed(2);
3395
- const hot = Number(edge.delta || 0) > 0;
3396
- const lastAtMs = Number(edge.lastAtMs || 0);
3397
- const fromInFlow = nodeInFlowById.get(String(edge.from || '')) !== false;
3398
- const toInFlow = nodeInFlowById.get(String(edge.to || '')) !== false;
3399
- const isExternalLink = !(fromInFlow && toInFlow);
3400
- const delayMs = Math.max(0, Number(edge.delayMs || 0));
3401
- const isWaitingDelay = !edge.disabled && lastAtMs > 0 && delayMs > 0 && now < (lastAtMs + delayMs);
3402
- if (isWaitingDelay) return;
3403
- const rowDelta = Number(toPos.row || 0) - Number(fromPos.row || 0);
3404
- const colDelta = Number(toPos.col || 0) - Number(fromPos.col || 0);
3405
- const bendSign = rowDelta === 0 ? (colDelta >= 0 ? 1 : -1) : (rowDelta > 0 ? 1 : -1);
3406
- const bendMag = Math.max(12, Math.min(60, (Math.abs(rowDelta) * 14) + (Math.abs(colDelta) <= 1 ? 22 : 10)));
3407
- const perpX = -uy;
3408
- const perpY = ux;
3409
- const byEvent = (edge && edge.edgeByEvent && typeof edge.edgeByEvent === 'object') ? edge.edgeByEvent : {};
3410
- const evKeys = Object.keys(byEvent).map(k => String(k || ''));
3411
- const isResponderEdge = evKeys.some(k => /response\s*\(responder\)/i.test(k));
3412
- const isReplyEdge = evKeys.some(k => /response\s*\(reply\)/i.test(k));
3413
- const parallelOffset = isResponderEdge ? 9 : (isReplyEdge ? -7 : 0);
3414
- const sX = startX + (perpX * parallelOffset);
3415
- const sY = startY + (perpY * parallelOffset);
3416
- const eX = endX + (perpX * parallelOffset);
3417
- const eY = endY + (perpY * parallelOffset);
3418
- let cx = ((sX + eX) / 2) + (perpX * bendMag * bendSign);
3419
- let cy = ((sY + eY) / 2) + (perpY * bendMag * bendSign);
3420
- const viaGa = String(edge && edge.viaGa ? edge.viaGa : '').trim();
3421
- if (viaGa && positions.has(viaGa)) {
3422
- const viaPos = positions.get(viaGa);
3423
- const mx = (sX + eX) / 2;
3424
- const my = (sY + eY) / 2;
3425
- // Force responder->requester path to run near GA->requester path.
3426
- cx = (mx * 0.35) + ((Number(viaPos.x || mx) + (perpX * parallelOffset * 0.45)) * 0.65);
3427
- cy = (my * 0.35) + ((Number(viaPos.y || my) + (perpY * parallelOffset * 0.45)) * 0.65);
3428
- }
3429
- const baseCx = cx;
3430
- const baseCy = cy;
3431
- const avoidShift = Math.max(18, nodeRadius + 8 + Math.min(18, Math.abs(parallelOffset) * 1.5));
3432
- const candidates = [
3433
- { cx: baseCx, cy: baseCy, bias: 0 },
3434
- { cx: baseCx + (perpX * avoidShift), cy: baseCy + (perpY * avoidShift), bias: 1 },
3435
- { cx: baseCx - (perpX * avoidShift), cy: baseCy - (perpY * avoidShift), bias: 1 },
3436
- { cx: baseCx + (perpX * avoidShift * 1.75), cy: baseCy + (perpY * avoidShift * 1.75), bias: 2 },
3437
- { cx: baseCx - (perpX * avoidShift * 1.75), cy: baseCy - (perpY * avoidShift * 1.75), bias: 2 }
3438
- ];
3439
- let bestIdx = 0;
3440
- let bestScore = Infinity;
3441
- for (let ci = 0; ci < candidates.length; ci++) {
3442
- const c = candidates[ci];
3443
- const avoidScore = edgeObstaclePenalty({
3444
- sx: sX,
3445
- sy: sY,
3446
- cx: c.cx,
3447
- cy: c.cy,
3448
- ex: eX,
3449
- ey: eY,
3450
- fromId: String(edge.from || ''),
3451
- toId: String(edge.to || ''),
3452
- skipId: viaGa
3453
- });
3454
- const shapePenalty = Number(c.bias || 0) * 0.38;
3455
- const score = avoidScore + shapePenalty;
3456
- if (score < bestScore) {
3457
- bestScore = score;
3458
- bestIdx = ci;
3459
- }
3460
- }
3461
- cx = candidates[bestIdx].cx;
3462
- cy = candidates[bestIdx].cy;
3463
- const q0x = (sX + cx) / 2;
3464
- const q0y = (sY + cy) / 2;
3465
- const q1x = (cx + eX) / 2;
3466
- const q1y = (cy + eY) / 2;
3467
- const midX = (q0x + q1x) / 2;
3468
- const midY = (q0y + q1y) / 2;
3469
-
3470
- const isActiveTraffic = !edge.disabled
3471
- && lastAtMs > 0
3472
- && now >= (lastAtMs + delayMs)
3473
- && (now - lastAtMs) <= (activeTrafficWindowMs + delayMs);
3474
- const isIdle = !isActiveTraffic;
3475
- const tooltipState = isIdle
3476
- ? (` | state: idle (>${activeWindowSec}s)`)
3477
- : (` | state: active (<=${activeWindowSec}s)`);
3478
- const eventSummary = Object.keys(edge.edgeByEvent || {})
3479
- .sort((a, b) => String(a).localeCompare(String(b)))
3480
- .map((ev) => `${ev}: ${edge.edgeByEvent[ev]}`)
3481
- .join(' | ');
3482
- const relationTag = isExternalLink ? ' | relation: flow<->external' : ' | relation: flow<->flow';
3483
- const telegramType = classifyEdgeTelegramType(edge);
3484
- const edgeColor = EVENT_COLORS[telegramType] || EVENT_COLORS.other;
3485
- const markerId = edgeMarkerIdForType(telegramType);
3486
- const tooltip = `${edge.from} -> ${edge.to} | traffic: ${w}${edge.delta ? (' | delta: ' + (edge.delta > 0 ? '+' : '') + edge.delta) : ''}${tooltipState}${relationTag} | type: ${telegramType}${eventSummary ? (' | events: ' + eventSummary) : ''}`;
3487
-
3488
- // Idle links stay visible as gray baseline; active links are split in/out with different arrows.
3489
- if (isIdle) {
3490
- const idleThickness = Math.max(1.1, Number(thickness) * 0.62).toFixed(2);
3491
- const idlePath = createSvgEl('path', {
3492
- d: `M ${sX} ${sY} Q ${cx} ${cy} ${eX} ${eY}`,
3493
- class: 'flow-edge flow-edge-idle' + (isExternalLink ? ' flow-edge-external-link' : ''),
3494
- 'stroke-width': idleThickness,
3495
- stroke: edgeColor,
3496
- 'marker-end': `url(#${markerId})`
3497
- });
3498
- idlePath.appendChild(createSvgEl('title', {}));
3499
- idlePath.lastChild.textContent = tooltip;
3500
- $flowSvg.appendChild(idlePath);
3501
- } else {
3502
- const pOut = createSvgEl('path', {
3503
- d: `M ${sX} ${sY} Q ${q0x} ${q0y} ${midX} ${midY}`,
3504
- class: 'flow-edge flow-edge-out flow-edge-active' + (hot ? ' flow-edge-hot' : '') + (isExternalLink ? ' flow-edge-external-link' : ''),
3505
- 'stroke-width': thickness,
3506
- stroke: edgeColor,
3507
- 'marker-end': `url(#${markerId})`
3508
- });
3509
- const pIn = createSvgEl('path', {
3510
- d: `M ${midX} ${midY} Q ${q1x} ${q1y} ${eX} ${eY}`,
3511
- class: 'flow-edge flow-edge-in flow-edge-active' + (hot ? ' flow-edge-hot' : '') + (isExternalLink ? ' flow-edge-external-link' : ''),
3512
- 'stroke-width': thickness,
3513
- stroke: edgeColor,
3514
- 'marker-end': `url(#${markerId})`
3515
- });
3516
- pOut.appendChild(createSvgEl('title', {}));
3517
- pOut.lastChild.textContent = tooltip;
3518
- pIn.appendChild(createSvgEl('title', {}));
3519
- pIn.lastChild.textContent = tooltip;
3520
- $flowSvg.appendChild(pOut);
3521
- $flowSvg.appendChild(pIn);
3522
- }
3523
- });
3524
-
3525
- // Draw node bubbles after links so labels stay readable.
3526
- sortedNodes.forEach((node) => {
3527
- const pos = positions.get(node.id);
3528
- if (!pos) return;
3529
- const isDisabledNode = !!node.disabled;
3530
- const nodeKind = String(node && node.kind ? node.kind : (String(node && node.id ? node.id : '').startsWith('N:') ? 'node' : 'ga')).trim() || 'ga';
3531
- const isUnmappedNode = nodeKind !== 'node' && node.inFlow === false;
3532
- const g = createSvgEl('g', { class: 'flow-node flow-node-kind-' + nodeKind + (node.anomalyCount > 0 ? ' flow-node-anomaly' : '') + (isDisabledNode ? ' flow-node-disabled' : '') + (isUnmappedNode ? ' flow-node-unmapped' : '') });
3533
- const circle = createSvgEl('circle', { cx: pos.x, cy: pos.y, r: nodeRadius });
3534
- const label = createSvgEl('text', { x: pos.x, y: pos.y, class: 'flow-node-label' });
3535
- label.textContent = shortNodeLabel(node.displayId || node.id);
3536
- g.appendChild(circle);
3537
- g.appendChild(label);
3538
- let nextY = pos.y + nodeRadius + 12;
3539
- const subtitleText = shortNodeSubtitle(node.subtitle || node.id);
3540
- if (subtitleText) {
3541
- const subtitle = createSvgEl('text', { x: pos.x, y: nextY, class: 'flow-node-subtitle' });
3542
- subtitle.textContent = subtitleText;
3543
- g.appendChild(subtitle);
3544
- nextY += 11;
3545
- }
3546
- const payloadText = shortNodePayload(node.payload);
3547
- if (payloadText) {
3548
- const payload = createSvgEl('text', { x: pos.x, y: nextY, class: 'flow-node-payload' });
3549
- payload.textContent = payloadText;
3550
- g.appendChild(payload);
3551
- nextY += 11;
3552
- }
3553
- if (isUnmappedNode) {
3554
- const extTag = createSvgEl('text', { x: pos.x, y: nextY, class: 'flow-node-ext-tag' });
3555
- extTag.textContent = 'EXT';
3556
- g.appendChild(extTag);
3557
- nextY += 10;
3558
- }
3559
- if (!isDisabledNode && node.anomalyCount > 0) {
3560
- const badge = createSvgEl('text', { x: pos.x, y: nextY, class: 'flow-node-badge' });
3561
- badge.textContent = 'anomaly x' + node.anomalyCount;
3562
- g.appendChild(badge);
3563
- }
3564
- $flowSvg.appendChild(g);
3565
- });
3566
- };
3567
-
3568
- // Single render entrypoint for all visual panels.
3569
- const renderDashboard = (data) => {
3570
- lastDashboardRawData = data || null;
3571
- const dashboard = buildDashboardData(data || {});
3572
- const activityContext = buildAnalysisActivityContext(data || {}, dashboard);
3573
- updateFlowGaOptions(dashboard.nodes || []);
3574
- renderBusConnection(data || {});
3575
- const flowGraph = applyFlowFilters(dashboard);
3576
- renderFlowMap(flowGraph);
3577
- renderPie($eventPieSvg, $eventPieLegend, dashboard.eventEntries, 'Event');
3578
- renderPie($anomalyPieSvg, $anomalyPieLegend, dashboard.anomalyEntries, 'Anomaly', {
3579
- showAnalyzing: true,
3580
- activityContext
3581
- });
3582
- renderEdgeStack(flowGraph.edges || []);
3583
- return dashboard;
3584
- };
3585
-
3586
- // Populate node selector while preserving preferred/query/stored priority.
3587
- const populateNodes = (nodes, preferredId) => {
3588
- nodesCache = Array.isArray(nodes) ? nodes : [];
3589
- $nodeSelect.innerHTML = '';
3590
-
3591
- if (!nodesCache.length) {
3592
- setEnabled(false);
3593
- $summary.textContent = 'No KNX AI nodes found.';
3594
- const emptyDashboard = renderDashboard(null);
3595
- renderAnomalies([], buildAnalysisActivityContext(null, emptyDashboard));
3596
- return;
3597
- }
3598
-
3599
- setEnabled(true);
3600
- nodesCache.forEach((n) => {
3601
- const option = document.createElement('option');
3602
- option.value = n.id;
3603
- option.textContent = (n.name || 'KNX AI') + (n.gatewayName ? (' | ' + n.gatewayName) : '');
3604
- $nodeSelect.appendChild(option);
3605
- });
3606
-
3607
- const queryPreferred = queryNodeId && nodesCache.find(n => n.id === queryNodeId) ? queryNodeId : '';
3608
- const stored = loadStoredNode();
3609
- const fallbackStored = stored && nodesCache.find(n => n.id === stored) ? stored : '';
3610
- const fallbackPreferred = preferredId && nodesCache.find(n => n.id === preferredId) ? preferredId : '';
3611
- const selected = fallbackPreferred || queryPreferred || fallbackStored || (nodesCache[0] ? nodesCache[0].id : '');
3612
- if (selected) $nodeSelect.value = selected;
3613
- storeNode(selected);
3614
- if (queryPreferred) queryNodeId = '';
3615
- };
3616
-
3617
- // Poll available KNX AI nodes from backend.
3618
- const fetchNodes = async (preferredId) => {
3619
- setStatus('Loading nodes...');
3620
- const currentSelected = preferredId || $nodeSelect.value || loadStoredNode() || '';
3621
- try {
3622
- const data = await requestJson(apiUrl('nodes'));
3623
- populateNodes(data && data.nodes ? data.nodes : [], currentSelected);
3624
- setStatus('Ready');
3625
- } catch (error) {
3626
- setEnabled(false);
3627
- setStatus(error.message || 'Failed to load nodes');
3628
- }
3629
- };
3630
-
3631
- // Poll dashboard state for selected node; "fresh" can force backend recalculation.
3632
- const fetchState = async (fresh) => {
3633
- const nodeId = $nodeSelect.value || '';
3634
- if (!nodeId) return;
3635
- if (stateRequestInFlight) return;
3636
- stateRequestInFlight = true;
3637
- if (fresh) setStatus('Loading...');
3638
- try {
3639
- const data = await requestJson(apiUrl('state?nodeId=' + encodeURIComponent(nodeId) + '&fresh=' + (fresh ? 1 : 0)));
3640
- $summary.textContent = formatSummaryText(data);
3641
- const dashboard = renderDashboard(data);
3642
- renderAnomalies(data && data.anomalies ? data.anomalies : [], buildAnalysisActivityContext(data, dashboard));
3643
-
3644
- const llmEnabled = data && data.node ? !!data.node.llmEnabled : false;
3645
- if (!llmEnabled) {
3646
- $chatInput.disabled = true;
3647
- $chatSend.disabled = true;
3648
- $chatInput.placeholder = 'LLM disabled in node config';
3649
- } else {
3650
- $chatInput.disabled = false;
3651
- $chatSend.disabled = false;
3652
- $chatInput.placeholder = 'Ask a question about KNX traffic...';
3653
- }
3654
- if (fresh) setStatus('Ready');
3655
- } catch (error) {
3656
- setStatus(error.message || 'Failed to load state');
3657
- } finally {
3658
- stateRequestInFlight = false;
3659
- }
3660
- };
3661
-
3662
- // Send a user/preset question to backend LLM endpoint and append chat output.
3663
- const sendAsk = async (questionOverride) => {
3664
- const nodeId = $nodeSelect.value || '';
3665
- const question = (questionOverride !== undefined ? String(questionOverride || '') : String($chatInput.value || '')).trim();
3666
- if (!nodeId || !question) return;
3667
-
3668
- if (questionOverride === undefined) $chatInput.value = '';
3669
- appendChat('user', question);
3670
- setStatus('Asking...');
3671
- $chatSend.disabled = true;
3672
- showChatPending();
3673
-
3674
- try {
3675
- const data = await requestJson(apiUrl('ask'), {
3676
- method: 'POST',
3677
- headers: { 'content-type': 'application/json' },
3678
- body: JSON.stringify({ nodeId, question })
3679
- });
3680
- appendChat('assistant', data && data.answer !== undefined ? data.answer : '');
3681
- } catch (error) {
3682
- appendChat('error', error.message || 'Ask failed');
3683
- } finally {
3684
- hideChatPending();
3685
- $chatSend.disabled = false;
3686
- setStatus('Ready');
3687
- }
3688
- };
3689
-
3690
- // Start polling loops once, then keep them running for live updates.
3691
- const startTimers = () => {
3692
- if (!stateInterval) {
3693
- stateInterval = window.setInterval(() => {
3694
- if (!$auto.checked) return;
3695
- fetchState(false);
3696
- }, 450);
3697
- }
3698
- if (!nodesInterval) {
3699
- nodesInterval = window.setInterval(() => {
3700
- fetchNodes();
3701
- }, 10000);
3702
- }
3703
- };
3704
-
3705
- // Hard reset for Flow Map: clear all local caches/ordering and rebuild from fresh backend data.
3706
- const resetFlowMapHard = async () => {
3707
- flowFilters = parseFlowFilters(null);
3708
- saveFlowFilters(flowFilters);
3709
- flowEdgeCache = new Map();
3710
- flowNodeCache = new Map();
3711
- lastDashboardRawData = null;
3712
-
3713
- if ($flowMaxGa) $flowMaxGa.value = String(flowFilters.maxGa || 14);
3714
- if ($flowGaSearch) $flowGaSearch.value = '';
3715
- if ($flowGaSelect) $flowGaSelect.innerHTML = '';
3716
- if ($flowSvg) $flowSvg.innerHTML = '';
3717
- if ($flowMeta) $flowMeta.textContent = 'Resetting layout...';
3718
- if ($flowEmpty) $flowEmpty.hidden = false;
3719
- if ($edgeStackWrap) $edgeStackWrap.innerHTML = '';
3720
-
3721
- await fetchState(true);
3722
- };
3723
-
3724
- // UI wiring: manual actions + local filter interactions.
3725
- if ($themeBtns.length) {
3726
- $themeBtns.forEach((btn) => {
3727
- btn.addEventListener('click', () => {
3728
- const t = String(btn.dataset.theme || '').trim();
3729
- if (!t) return;
3730
- applyTheme(t, { rerender: true });
3731
- });
3732
- });
3733
- }
3734
- $refreshNodes.addEventListener('click', () => {
3735
- fetchNodes().then(() => fetchState(false));
3736
- });
3737
- $nodeSelect.addEventListener('change', () => {
3738
- const nodeId = $nodeSelect.value || '';
3739
- storeNode(nodeId);
3740
- $chatLog.innerHTML = '';
3741
- fetchState(false);
3742
- });
3743
- $auto.addEventListener('change', () => {
3744
- storeAuto($auto.checked);
3745
- });
3746
- $chatSend.addEventListener('click', (evt) => {
3747
- evt.preventDefault();
3748
- sendAsk();
3749
- });
3750
- $chatInput.addEventListener('keydown', (evt) => {
3751
- if (evt.key === 'Enter') {
3752
- evt.preventDefault();
3753
- sendAsk();
3754
- }
3755
- });
3756
- if ($flowMaxGa) {
3757
- $flowMaxGa.value = String(flowFilters.maxGa || 14);
3758
- $flowMaxGa.addEventListener('change', () => {
3759
- const n = Number($flowMaxGa.value || 14);
3760
- flowFilters.maxGa = Number.isFinite(n) ? Math.max(4, Math.min(60, Math.round(n))) : 14;
3761
- $flowMaxGa.value = String(flowFilters.maxGa);
3762
- saveFlowFilters(flowFilters);
3763
- if (lastDashboardRawData) renderDashboard(lastDashboardRawData);
3764
- });
3765
- }
3766
- if ($flowGaSelect) {
3767
- $flowGaSelect.addEventListener('change', () => {
3768
- flowFilters.selectedGa = collectSelectedFlowGAs();
3769
- saveFlowFilters(flowFilters);
3770
- if (lastDashboardRawData) renderDashboard(lastDashboardRawData);
3771
- });
3772
- }
3773
- if ($flowGaSearch) {
3774
- $flowGaSearch.value = '';
3775
- $flowGaSearch.addEventListener('input', () => {
3776
- applyFlowSearchVisibility();
3777
- });
3778
- }
3779
- if ($flowClearGa) {
3780
- $flowClearGa.addEventListener('click', () => {
3781
- Array.from($flowGaSelect.options).forEach((opt) => { opt.selected = false; });
3782
- flowFilters.selectedGa = [];
3783
- saveFlowFilters(flowFilters);
3784
- if (lastDashboardRawData) renderDashboard(lastDashboardRawData);
3785
- });
3786
- }
3787
- if ($flowResetLayout) {
3788
- $flowResetLayout.addEventListener('click', async () => {
3789
- try {
3790
- $flowResetLayout.disabled = true;
3791
- await resetFlowMapHard();
3792
- } finally {
3793
- $flowResetLayout.disabled = false;
3794
- }
3795
- });
3796
- }
3797
- if ($chatPresetBtns.length) {
3798
- $chatPresetBtns.forEach((btn) => {
3799
- btn.addEventListener('click', () => {
3800
- if ($chatInput.disabled || $chatSend.disabled) return;
3801
- const q = String(btn.dataset.question || '').trim();
3802
- if (!q) return;
3803
- sendAsk(q);
3804
- });
3805
- });
3806
- }
3807
- // Debounced resize to recompute SVG layout without thrashing.
3808
- window.addEventListener('resize', () => {
3809
- if (resizeHandle) window.clearTimeout(resizeHandle);
3810
- resizeHandle = window.setTimeout(() => {
3811
- if (lastDashboardRawData) renderDashboard(lastDashboardRawData);
3812
- }, 180);
3813
- });
3814
-
3815
- // Bootstrap page state and start background polling.
3816
- applyTheme(currentTheme, { rerender: false });
3817
- $auto.checked = loadAuto();
3818
- fetchNodes(queryNodeId || loadStoredNode())
3819
- .then(() => resetFlowMapHard())
3820
- .finally(() => startTimers());
3821
- })();
3822
- </script>
3823
- <br /><br /><br /><br />
3824
- </body>
3825
-
3826
- </html>