@ctrlpi/ctrlpi-lab 0.9.25 → 0.9.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -85
- package/ctrlpi-lab.js +231 -103
- package/package.json +3 -2
- package/pages/api.html +0 -9
- package/pages/api_curl.html +2 -2
- package/pages/main.html +423 -112
- package/pages/welcome.html +88 -20
- package/pages/Pico Pinout.html +0 -186
- package/pages/Raspberry Pi Pinout.html +0 -184
package/pages/main.html
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
rel="stylesheet">
|
|
15
15
|
|
|
16
16
|
<style>
|
|
17
|
+
/* Theme colors: light by default, overridden under body.dark-mode. */
|
|
17
18
|
:root {
|
|
18
19
|
/* Bright (Light) Mode Variables (DEFAULT) */
|
|
19
20
|
--bg-dark: #f0f2f5;
|
|
@@ -51,6 +52,7 @@
|
|
|
51
52
|
--border-terminal: rgba(255, 255, 255, 0.08);
|
|
52
53
|
}
|
|
53
54
|
|
|
55
|
+
/* Dark theme values for the same variables. */
|
|
54
56
|
body.dark-mode {
|
|
55
57
|
/* Dark Mode Variables Override */
|
|
56
58
|
--bg-dark: #07060d;
|
|
@@ -169,6 +171,7 @@
|
|
|
169
171
|
align-items: center;
|
|
170
172
|
}
|
|
171
173
|
|
|
174
|
+
/* Header: logo, agent name and board model. */
|
|
172
175
|
.logo-group {
|
|
173
176
|
min-width: 0;
|
|
174
177
|
display: flex;
|
|
@@ -237,6 +240,7 @@
|
|
|
237
240
|
top: 3px;
|
|
238
241
|
}
|
|
239
242
|
|
|
243
|
+
/* Agent name and board model next to the logo. */
|
|
240
244
|
#logoDeviceName, #logoDeviceModel {
|
|
241
245
|
max-width: 180px;
|
|
242
246
|
overflow: hidden;
|
|
@@ -269,6 +273,7 @@
|
|
|
269
273
|
pointer-events: all;
|
|
270
274
|
}
|
|
271
275
|
|
|
276
|
+
/* Header: agent picker, address and port fields. */
|
|
272
277
|
.config-input-group {
|
|
273
278
|
display: flex;
|
|
274
279
|
align-items: center;
|
|
@@ -299,6 +304,7 @@
|
|
|
299
304
|
text-align: center;
|
|
300
305
|
}
|
|
301
306
|
|
|
307
|
+
/* The key field in the saved-key editor. */
|
|
302
308
|
#apiKeyInput {
|
|
303
309
|
width: 100%;
|
|
304
310
|
}
|
|
@@ -316,6 +322,7 @@
|
|
|
316
322
|
box-shadow: 0 0 6px var(--primary-glow);
|
|
317
323
|
}
|
|
318
324
|
|
|
325
|
+
/* Header: the connection status badge. */
|
|
319
326
|
.status-badge {
|
|
320
327
|
display: inline-flex;
|
|
321
328
|
align-items: center;
|
|
@@ -486,6 +493,7 @@
|
|
|
486
493
|
animation: spin 1s linear infinite;
|
|
487
494
|
}
|
|
488
495
|
|
|
496
|
+
/* Short pop-up messages. */
|
|
489
497
|
.toast-popup {
|
|
490
498
|
position: fixed;
|
|
491
499
|
top: 24px;
|
|
@@ -524,10 +532,6 @@
|
|
|
524
532
|
merely clicking/selecting a transport stays uncolored. */
|
|
525
533
|
|
|
526
534
|
|
|
527
|
-
/* MCP URL shown as read-only text once connected over HTTP */
|
|
528
|
-
|
|
529
|
-
/* MCP HTTP address dialog */
|
|
530
|
-
|
|
531
535
|
/* Saved Servers Dropdown */
|
|
532
536
|
.servers-dd-wrap {
|
|
533
537
|
position: relative;
|
|
@@ -561,6 +565,7 @@
|
|
|
561
565
|
background: #334155;
|
|
562
566
|
color: #e2e8f0;
|
|
563
567
|
}
|
|
568
|
+
/* Agent picker drop-down. */
|
|
564
569
|
.servers-dd-panel {
|
|
565
570
|
display: none;
|
|
566
571
|
position: absolute;
|
|
@@ -586,10 +591,11 @@
|
|
|
586
591
|
font-size: 0.82rem;
|
|
587
592
|
font-family: monospace;
|
|
588
593
|
color: var(--text, #1e293b);
|
|
589
|
-
white-space: nowrap;
|
|
590
|
-
overflow: hidden;
|
|
591
|
-
text-overflow: ellipsis;
|
|
592
594
|
border-bottom: 1px solid var(--border, #e2e8f0);
|
|
595
|
+
display: flex;
|
|
596
|
+
align-items: center;
|
|
597
|
+
justify-content: space-between;
|
|
598
|
+
gap: 10px;
|
|
593
599
|
}
|
|
594
600
|
.servers-dd-item:last-child { border-bottom: none; }
|
|
595
601
|
.servers-dd-item:hover {
|
|
@@ -597,6 +603,39 @@
|
|
|
597
603
|
}
|
|
598
604
|
body.dark-mode .servers-dd-item { color: #e2e8f0; }
|
|
599
605
|
body.dark-mode .servers-dd-item:hover { background: #334155; }
|
|
606
|
+
.servers-dd-item-content {
|
|
607
|
+
flex: 1;
|
|
608
|
+
min-width: 0;
|
|
609
|
+
white-space: nowrap;
|
|
610
|
+
overflow: hidden;
|
|
611
|
+
text-overflow: ellipsis;
|
|
612
|
+
}
|
|
613
|
+
.servers-dd-delete-btn {
|
|
614
|
+
background: transparent;
|
|
615
|
+
border: none;
|
|
616
|
+
color: var(--danger, #dc2626);
|
|
617
|
+
cursor: pointer;
|
|
618
|
+
padding: 3px 5px;
|
|
619
|
+
border-radius: 4px;
|
|
620
|
+
display: inline-flex;
|
|
621
|
+
align-items: center;
|
|
622
|
+
justify-content: center;
|
|
623
|
+
opacity: 0.75;
|
|
624
|
+
transition: opacity 0.15s ease, background 0.15s ease, transform 0.15s ease;
|
|
625
|
+
flex-shrink: 0;
|
|
626
|
+
line-height: 1;
|
|
627
|
+
}
|
|
628
|
+
.servers-dd-delete-btn:hover {
|
|
629
|
+
opacity: 1;
|
|
630
|
+
background: rgba(220, 38, 38, 0.12);
|
|
631
|
+
transform: scale(1.15);
|
|
632
|
+
}
|
|
633
|
+
body.dark-mode .servers-dd-delete-btn {
|
|
634
|
+
color: var(--danger, #ef4444);
|
|
635
|
+
}
|
|
636
|
+
body.dark-mode .servers-dd-delete-btn:hover {
|
|
637
|
+
background: rgba(239, 68, 68, 0.2);
|
|
638
|
+
}
|
|
600
639
|
.servers-dd-empty {
|
|
601
640
|
padding: 10px 12px;
|
|
602
641
|
font-size: 0.8rem;
|
|
@@ -646,6 +685,7 @@
|
|
|
646
685
|
fill: currentColor;
|
|
647
686
|
}
|
|
648
687
|
|
|
688
|
+
/* The saved-key editor and the add-agent dialog. */
|
|
649
689
|
.api-key-panel {
|
|
650
690
|
padding: 12px;
|
|
651
691
|
min-width: 240px;
|
|
@@ -823,6 +863,7 @@
|
|
|
823
863
|
background: rgba(0, 0, 0, 0.2);
|
|
824
864
|
}
|
|
825
865
|
|
|
866
|
+
/* Tabs: API, Logs, Webhooks. */
|
|
826
867
|
.tab-link {
|
|
827
868
|
text-decoration: none;
|
|
828
869
|
color: #707f94;
|
|
@@ -867,6 +908,7 @@
|
|
|
867
908
|
position: relative;
|
|
868
909
|
}
|
|
869
910
|
|
|
911
|
+
/* One pane per tab; only the active one is shown. */
|
|
870
912
|
.pane {
|
|
871
913
|
display: none;
|
|
872
914
|
opacity: 0;
|
|
@@ -881,7 +923,7 @@
|
|
|
881
923
|
opacity: 1;
|
|
882
924
|
}
|
|
883
925
|
|
|
884
|
-
/* Page 1:
|
|
926
|
+
/* Page 1: API Tester Panel Layout */
|
|
885
927
|
#testerPane {
|
|
886
928
|
grid-template-columns: 320px 1fr;
|
|
887
929
|
}
|
|
@@ -911,6 +953,7 @@
|
|
|
911
953
|
letter-spacing: 0.5px;
|
|
912
954
|
}
|
|
913
955
|
|
|
956
|
+
/* API sidebar: endpoint search. */
|
|
914
957
|
.search-box {
|
|
915
958
|
position: relative;
|
|
916
959
|
display: flex;
|
|
@@ -952,7 +995,7 @@
|
|
|
952
995
|
gap: 10px;
|
|
953
996
|
}
|
|
954
997
|
|
|
955
|
-
/* Info panels (Welcome /
|
|
998
|
+
/* Info panels (Welcome / API cURL) */
|
|
956
999
|
.info-panel {
|
|
957
1000
|
display: none;
|
|
958
1001
|
flex-direction: column;
|
|
@@ -1040,32 +1083,105 @@
|
|
|
1040
1083
|
|
|
1041
1084
|
.info-cards {
|
|
1042
1085
|
display: grid;
|
|
1043
|
-
grid-template-columns: repeat(auto-
|
|
1044
|
-
gap:
|
|
1086
|
+
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
|
1087
|
+
gap: 16px;
|
|
1045
1088
|
}
|
|
1046
1089
|
|
|
1047
1090
|
.info-card {
|
|
1048
1091
|
background: var(--bg-card);
|
|
1049
1092
|
border: 1px solid var(--border-muted);
|
|
1050
|
-
border-radius:
|
|
1051
|
-
padding:
|
|
1093
|
+
border-radius: 12px;
|
|
1094
|
+
padding: 18px 20px;
|
|
1052
1095
|
display: flex;
|
|
1053
1096
|
flex-direction: column;
|
|
1054
|
-
gap:
|
|
1097
|
+
gap: 8px;
|
|
1098
|
+
box-shadow: 0 2px 8px var(--card-shadow);
|
|
1099
|
+
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
.info-card:hover {
|
|
1103
|
+
transform: translateY(-2px);
|
|
1104
|
+
border-color: var(--border-glow);
|
|
1105
|
+
box-shadow: 0 4px 14px var(--card-shadow);
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
.info-card-icon {
|
|
1109
|
+
width: 44px;
|
|
1110
|
+
height: 44px;
|
|
1111
|
+
border-radius: 12px;
|
|
1112
|
+
display: flex;
|
|
1113
|
+
align-items: center;
|
|
1114
|
+
justify-content: center;
|
|
1115
|
+
margin-bottom: 4px;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
.info-card-icon svg {
|
|
1119
|
+
width: 22px;
|
|
1120
|
+
height: 22px;
|
|
1121
|
+
stroke: currentColor;
|
|
1122
|
+
fill: none;
|
|
1123
|
+
stroke-width: 1.6;
|
|
1124
|
+
stroke-linecap: round;
|
|
1125
|
+
stroke-linejoin: round;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
.info-card-icon.i-green {
|
|
1129
|
+
background: rgba(16, 185, 129, 0.14);
|
|
1130
|
+
color: #10b981;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
.info-card-icon.i-violet {
|
|
1134
|
+
background: rgba(139, 92, 246, 0.13);
|
|
1135
|
+
color: #8b5cf6;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
.info-card-icon.i-rose {
|
|
1139
|
+
background: rgba(236, 72, 153, 0.12);
|
|
1140
|
+
color: #ec4899;
|
|
1055
1141
|
}
|
|
1056
1142
|
|
|
1057
1143
|
.info-card-title {
|
|
1058
|
-
font-size: 0.
|
|
1059
|
-
font-weight:
|
|
1144
|
+
font-size: 0.92rem;
|
|
1145
|
+
font-weight: 700;
|
|
1060
1146
|
color: var(--text-main);
|
|
1061
1147
|
}
|
|
1062
1148
|
|
|
1063
1149
|
.info-card-desc {
|
|
1064
|
-
font-size: 0.
|
|
1150
|
+
font-size: 0.82rem;
|
|
1065
1151
|
color: var(--text-muted);
|
|
1066
|
-
line-height: 1.
|
|
1152
|
+
line-height: 1.55;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
.info-flow {
|
|
1156
|
+
margin-top: 6px;
|
|
1157
|
+
display: flex;
|
|
1158
|
+
flex-direction: column;
|
|
1159
|
+
gap: 8px;
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
.info-flow-pre {
|
|
1163
|
+
background: var(--bg-terminal);
|
|
1164
|
+
color: var(--text-terminal);
|
|
1165
|
+
font-family: 'JetBrains Mono', monospace;
|
|
1166
|
+
font-size: 0.78rem;
|
|
1167
|
+
line-height: 1.6;
|
|
1168
|
+
padding: 16px 20px;
|
|
1169
|
+
border-radius: 10px;
|
|
1170
|
+
overflow-x: auto;
|
|
1171
|
+
border: 1px solid var(--border-terminal);
|
|
1172
|
+
margin: 0;
|
|
1173
|
+
width: 100%;
|
|
1174
|
+
box-sizing: border-box;
|
|
1067
1175
|
}
|
|
1068
1176
|
|
|
1177
|
+
.c-blue { color: #3b82f6; font-weight: 600; }
|
|
1178
|
+
.c-green { color: #10b981; font-weight: 600; }
|
|
1179
|
+
.c-cyan { color: #06b6d4; font-weight: 600; }
|
|
1180
|
+
.c-violet { color: #8b5cf6; font-weight: 600; }
|
|
1181
|
+
.c-rose { color: #ec4899; font-weight: 600; }
|
|
1182
|
+
.c-mut { color: #94a3b8; }
|
|
1183
|
+
|
|
1184
|
+
/* API sidebar: Get Started links (Welcome, cURL, Pinouts). */
|
|
1069
1185
|
.pinout-link {
|
|
1070
1186
|
display: flex;
|
|
1071
1187
|
align-items: center;
|
|
@@ -1077,6 +1193,11 @@
|
|
|
1077
1193
|
font-size: 0.82rem;
|
|
1078
1194
|
cursor: pointer;
|
|
1079
1195
|
transition: all 0.2s ease;
|
|
1196
|
+
text-decoration: none;
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
.pinout-link:visited {
|
|
1200
|
+
color: var(--text-muted);
|
|
1080
1201
|
}
|
|
1081
1202
|
|
|
1082
1203
|
.pinout-link:hover {
|
|
@@ -1096,6 +1217,7 @@
|
|
|
1096
1217
|
color: var(--text-main);
|
|
1097
1218
|
}
|
|
1098
1219
|
|
|
1220
|
+
/* API sidebar: endpoint list, grouped by category. */
|
|
1099
1221
|
.endpoint-category-title {
|
|
1100
1222
|
font-size: 0.75rem;
|
|
1101
1223
|
font-weight: 700;
|
|
@@ -1139,6 +1261,7 @@
|
|
|
1139
1261
|
border-color: rgba(52, 211, 153, 0.3);
|
|
1140
1262
|
}
|
|
1141
1263
|
|
|
1264
|
+
/* GET / POST badges. */
|
|
1142
1265
|
.method-badge {
|
|
1143
1266
|
font-family: 'JetBrains Mono', monospace;
|
|
1144
1267
|
font-size: 0.65rem;
|
|
@@ -1202,6 +1325,7 @@
|
|
|
1202
1325
|
transition: background 0.3s ease;
|
|
1203
1326
|
}
|
|
1204
1327
|
|
|
1328
|
+
/* Workbench header: method badge, path and description. */
|
|
1205
1329
|
.workbench-header {
|
|
1206
1330
|
padding: 24px 32px;
|
|
1207
1331
|
border-bottom: 1px solid var(--border-muted);
|
|
@@ -1283,6 +1407,7 @@
|
|
|
1283
1407
|
fill: var(--primary-hover);
|
|
1284
1408
|
}
|
|
1285
1409
|
|
|
1410
|
+
/* Workbench cards: request form and response. */
|
|
1286
1411
|
.glass-card {
|
|
1287
1412
|
background: var(--bg-card);
|
|
1288
1413
|
border: 1px solid var(--border-muted);
|
|
@@ -1330,10 +1455,6 @@
|
|
|
1330
1455
|
}
|
|
1331
1456
|
|
|
1332
1457
|
|
|
1333
|
-
#mcpPane .workbench-body.focus-request .glass-card.response-card {
|
|
1334
|
-
flex: 1 1 0%;
|
|
1335
|
-
}
|
|
1336
|
-
|
|
1337
1458
|
/* Parameters Form styling */
|
|
1338
1459
|
.params-form {
|
|
1339
1460
|
display: flex;
|
|
@@ -1344,6 +1465,7 @@
|
|
|
1344
1465
|
padding-right: 8px;
|
|
1345
1466
|
}
|
|
1346
1467
|
|
|
1468
|
+
/* Request form fields. */
|
|
1347
1469
|
.parameter-group {
|
|
1348
1470
|
display: flex;
|
|
1349
1471
|
flex-direction: column;
|
|
@@ -1417,6 +1539,7 @@
|
|
|
1417
1539
|
box-shadow: 0 0 6px rgba(229, 62, 62, 0.4);
|
|
1418
1540
|
}
|
|
1419
1541
|
|
|
1542
|
+
/* Note under a GPIO field that holds a value the route cannot take. */
|
|
1420
1543
|
.gpio-range-note {
|
|
1421
1544
|
color: #e53e3e;
|
|
1422
1545
|
font-size: 0.76rem;
|
|
@@ -1425,6 +1548,7 @@
|
|
|
1425
1548
|
margin-top: 3px;
|
|
1426
1549
|
}
|
|
1427
1550
|
|
|
1551
|
+
/* Select fields. */
|
|
1428
1552
|
.param-select {
|
|
1429
1553
|
appearance: none;
|
|
1430
1554
|
-webkit-appearance: none;
|
|
@@ -1447,6 +1571,7 @@
|
|
|
1447
1571
|
margin-top: 6px;
|
|
1448
1572
|
}
|
|
1449
1573
|
|
|
1574
|
+
/* Quick-pick buttons under GPIO and sensor fields. */
|
|
1450
1575
|
.quick-btn {
|
|
1451
1576
|
background: rgba(0, 0, 0, 0.02);
|
|
1452
1577
|
border: 1px solid var(--border-muted);
|
|
@@ -1490,6 +1615,7 @@
|
|
|
1490
1615
|
gap: 2px;
|
|
1491
1616
|
}
|
|
1492
1617
|
|
|
1618
|
+
/* On/off switch for boolean fields. */
|
|
1493
1619
|
.toggle-switch {
|
|
1494
1620
|
position: relative;
|
|
1495
1621
|
display: inline-block;
|
|
@@ -1504,6 +1630,7 @@
|
|
|
1504
1630
|
height: 0;
|
|
1505
1631
|
}
|
|
1506
1632
|
|
|
1633
|
+
/* The moving part of the on/off switch. */
|
|
1507
1634
|
.slider {
|
|
1508
1635
|
position: absolute;
|
|
1509
1636
|
cursor: pointer;
|
|
@@ -1655,6 +1782,7 @@
|
|
|
1655
1782
|
background: var(--primary);
|
|
1656
1783
|
}
|
|
1657
1784
|
|
|
1785
|
+
/* Raw JSON body editor. */
|
|
1658
1786
|
.raw-json-editor {
|
|
1659
1787
|
width: 100%;
|
|
1660
1788
|
height: 200px;
|
|
@@ -1691,6 +1819,7 @@
|
|
|
1691
1819
|
border-radius: 10px;
|
|
1692
1820
|
}
|
|
1693
1821
|
|
|
1822
|
+
/* Response status, time and size. */
|
|
1694
1823
|
.resp-meta-item {
|
|
1695
1824
|
display: flex;
|
|
1696
1825
|
align-items: center;
|
|
@@ -1753,6 +1882,7 @@
|
|
|
1753
1882
|
font-weight: 600;
|
|
1754
1883
|
}
|
|
1755
1884
|
|
|
1885
|
+
/* Copy buttons on responses and examples. */
|
|
1756
1886
|
.copy-btn {
|
|
1757
1887
|
background: none;
|
|
1758
1888
|
border: none;
|
|
@@ -1775,6 +1905,7 @@
|
|
|
1775
1905
|
fill: currentColor;
|
|
1776
1906
|
}
|
|
1777
1907
|
|
|
1908
|
+
/* Syntax-colored JSON in the response view. */
|
|
1778
1909
|
.json-content {
|
|
1779
1910
|
flex-grow: 1;
|
|
1780
1911
|
padding: 16px;
|
|
@@ -1913,6 +2044,7 @@
|
|
|
1913
2044
|
gap: 24px;
|
|
1914
2045
|
}
|
|
1915
2046
|
|
|
2047
|
+
/* Webhooks tab: the events table. */
|
|
1916
2048
|
.webhook-table-wrap {
|
|
1917
2049
|
background: var(--bg-card);
|
|
1918
2050
|
border: 1px solid var(--border-muted);
|
|
@@ -1975,6 +2107,7 @@
|
|
|
1975
2107
|
transition: background 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease;
|
|
1976
2108
|
}
|
|
1977
2109
|
|
|
2110
|
+
/* Logs and Webhooks toolbars: search, refresh and polling rate. */
|
|
1978
2111
|
.toolbar-left-group {
|
|
1979
2112
|
display: flex;
|
|
1980
2113
|
align-items: center;
|
|
@@ -2028,6 +2161,7 @@
|
|
|
2028
2161
|
fill: var(--text-dark);
|
|
2029
2162
|
}
|
|
2030
2163
|
|
|
2164
|
+
/* Right side of the toolbars: auto-scroll and polling rate. */
|
|
2031
2165
|
.toolbar-right-group {
|
|
2032
2166
|
display: flex;
|
|
2033
2167
|
align-items: center;
|
|
@@ -2054,6 +2188,7 @@
|
|
|
2054
2188
|
font-weight: 600;
|
|
2055
2189
|
}
|
|
2056
2190
|
|
|
2191
|
+
/* Polling rate picker (3s to Manual). */
|
|
2057
2192
|
.refresh-rate-group {
|
|
2058
2193
|
display: flex;
|
|
2059
2194
|
align-items: center;
|
|
@@ -2077,6 +2212,7 @@
|
|
|
2077
2212
|
border-color: var(--accent);
|
|
2078
2213
|
}
|
|
2079
2214
|
|
|
2215
|
+
/* Shared action buttons. */
|
|
2080
2216
|
.btn-action {
|
|
2081
2217
|
background: var(--bg-input);
|
|
2082
2218
|
border: 1px solid var(--border-muted);
|
|
@@ -2185,6 +2321,7 @@
|
|
|
2185
2321
|
flex: 1;
|
|
2186
2322
|
}
|
|
2187
2323
|
|
|
2324
|
+
/* Result line of the API key change in the Danger Zone. */
|
|
2188
2325
|
.danger-zone-result {
|
|
2189
2326
|
margin-top: 10px;
|
|
2190
2327
|
font-size: 0.78rem;
|
|
@@ -2239,6 +2376,7 @@
|
|
|
2239
2376
|
border-radius: 50%;
|
|
2240
2377
|
}
|
|
2241
2378
|
|
|
2379
|
+
/* Table cell accents. */
|
|
2242
2380
|
.td-red {
|
|
2243
2381
|
background: #ff5f56;
|
|
2244
2382
|
}
|
|
@@ -2251,6 +2389,7 @@
|
|
|
2251
2389
|
background: #27c93f;
|
|
2252
2390
|
}
|
|
2253
2391
|
|
|
2392
|
+
/* Logs tab: the terminal-style log view. */
|
|
2254
2393
|
.terminal-title {
|
|
2255
2394
|
font-family: 'JetBrains Mono', monospace;
|
|
2256
2395
|
font-size: 0.78rem;
|
|
@@ -2271,6 +2410,7 @@
|
|
|
2271
2410
|
color: var(--text-terminal);
|
|
2272
2411
|
}
|
|
2273
2412
|
|
|
2413
|
+
/* The scrolling log area. */
|
|
2274
2414
|
#logTerminalScreen {
|
|
2275
2415
|
gap: 0;
|
|
2276
2416
|
line-height: 1.35;
|
|
@@ -2391,6 +2531,7 @@
|
|
|
2391
2531
|
}
|
|
2392
2532
|
}
|
|
2393
2533
|
|
|
2534
|
+
/* Spin animation for refresh icons. */
|
|
2394
2535
|
.rotate-anim {
|
|
2395
2536
|
animation: spin 0.8s ease-in-out;
|
|
2396
2537
|
}
|
|
@@ -2412,7 +2553,7 @@
|
|
|
2412
2553
|
.agent-picker-btn:hover { color: var(--primary); }
|
|
2413
2554
|
.agent-picker .theme-toggle-btn { color: var(--primary); margin: 0; width: 26px; }
|
|
2414
2555
|
.agent-picker #savedServersPanel { width: max-content; max-width: min(620px, calc(100vw - 48px)); max-height: 300px; overflow-y: auto; }
|
|
2415
|
-
.servers-dd-item { display:
|
|
2556
|
+
.servers-dd-item { display: flex; align-items: center; justify-content: space-between; gap: 10px; width: 100%; text-align: left; background: transparent; border: 0; border-bottom: 1px solid var(--border-muted, #334155); white-space: normal; overflow-wrap: anywhere; line-height: 1.5; }
|
|
2416
2557
|
.header-right nav { white-space: nowrap; }
|
|
2417
2558
|
.add-agent-dialog { width: min(420px, calc(100vw - 32px)); margin: auto; padding: 24px; border: 1px solid var(--border-muted); border-radius: 16px; background: var(--bg-deep); color: var(--text-main); box-shadow: 0 20px 80px #0004; }
|
|
2418
2559
|
.add-agent-dialog::backdrop { background: #0007; backdrop-filter: blur(3px); }
|
|
@@ -2481,6 +2622,7 @@
|
|
|
2481
2622
|
<span id="agentAddress">localhost:8314</span>
|
|
2482
2623
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M7 10l5 5 5-5z"/></svg>
|
|
2483
2624
|
</button>
|
|
2625
|
+
<!-- Agent picker, filled by renderServersDropdown() -->
|
|
2484
2626
|
<div id="savedServersPanel" class="servers-dd-panel"></div>
|
|
2485
2627
|
<!-- Selected agent API key -->
|
|
2486
2628
|
<div class="key-dd-wrap">
|
|
@@ -2563,6 +2705,7 @@
|
|
|
2563
2705
|
<input type="hidden" id="apiHostInput" value="http://localhost">
|
|
2564
2706
|
<input type="hidden" id="apiPortInput" value="8314">
|
|
2565
2707
|
|
|
2708
|
+
<!-- + dialog: add an agent by address, port and key -->
|
|
2566
2709
|
<dialog id="addAgentDialog" class="add-agent-dialog" aria-labelledby="addAgentTitle" oncancel="if(document.getElementById('addAgentSubmit').disabled)event.preventDefault()">
|
|
2567
2710
|
<form onsubmit="event.preventDefault(); saveNewAgent()">
|
|
2568
2711
|
<h2 id="addAgentTitle">Add agent</h2>
|
|
@@ -2581,6 +2724,7 @@
|
|
|
2581
2724
|
</form>
|
|
2582
2725
|
</dialog>
|
|
2583
2726
|
|
|
2727
|
+
<!-- Saved-key editor for the selected agent -->
|
|
2584
2728
|
<dialog id="apiKeyDialog" class="add-agent-dialog" aria-labelledby="apiKeyDialogTitle" oncancel="cancelApiKeyEdit()">
|
|
2585
2729
|
<form onsubmit="event.preventDefault(); saveApiKey()">
|
|
2586
2730
|
<h2 id="apiKeyDialogTitle">API Key</h2>
|
|
@@ -2608,8 +2752,16 @@
|
|
|
2608
2752
|
|
|
2609
2753
|
<!-- Global Application Scripts -->
|
|
2610
2754
|
<script>
|
|
2611
|
-
// API
|
|
2755
|
+
// Every agent route the API tab offers, in sidebar order. Each entry drives the form:
|
|
2756
|
+
// pathParams / queryParams fields that fill {name} in the path or the query string
|
|
2757
|
+
// bodyFields JSON body fields; type is text, number, select or boolean
|
|
2758
|
+
// conditionalOn shown and sent only while another field has one of these values
|
|
2759
|
+
// sendEmpty sent even when left empty (clears the value on the agent)
|
|
2760
|
+
// quickOptions pick buttons, filled from the agent's current pins or sensors
|
|
2761
|
+
// defaultBody the starting raw JSON body
|
|
2762
|
+
// autoSend a quick-pick button also sends the request
|
|
2612
2763
|
const endpoints = [
|
|
2764
|
+
// GPIO: read, write and configure pins.
|
|
2613
2765
|
{
|
|
2614
2766
|
id: 'read_gpio_all',
|
|
2615
2767
|
method: 'GET',
|
|
@@ -2676,7 +2828,7 @@
|
|
|
2676
2828
|
id: 'config_gpio',
|
|
2677
2829
|
method: 'POST',
|
|
2678
2830
|
path: '/gpio/config/{gpio}',
|
|
2679
|
-
description: 'Configure setup parameters for a GPIO pin, such as custom alias names, initial direction/states, maximum safe pulse duration limits, and active-low logic reversal.',
|
|
2831
|
+
description: 'Configure setup parameters for a GPIO pin, such as custom alias names, initial direction/states, maximum safe pulse duration limits, and active-low logic reversal. Set parameters, or enable "remove" to delete its configuration.',
|
|
2680
2832
|
pathParams: [
|
|
2681
2833
|
{
|
|
2682
2834
|
name: 'gpio',
|
|
@@ -2762,6 +2914,14 @@
|
|
|
2762
2914
|
conditionalOn: { field: 'type', values: ['output', 'input'] },
|
|
2763
2915
|
description: 'Watch for state changes and POST to the configured webhook URL. With no webhook URL configured, watched events are logged on the agent instead of sent.',
|
|
2764
2916
|
default: false
|
|
2917
|
+
},
|
|
2918
|
+
{
|
|
2919
|
+
name: 'remove',
|
|
2920
|
+
type: 'boolean',
|
|
2921
|
+
required: false,
|
|
2922
|
+
description: 'Delete this pin configuration.',
|
|
2923
|
+
default: false,
|
|
2924
|
+
onchange: 'onGpioRemoveChange(this.checked)'
|
|
2765
2925
|
}
|
|
2766
2926
|
],
|
|
2767
2927
|
defaultBody: {
|
|
@@ -2769,9 +2929,11 @@
|
|
|
2769
2929
|
type: "output",
|
|
2770
2930
|
init: "None",
|
|
2771
2931
|
max: null,
|
|
2772
|
-
reversed: false
|
|
2932
|
+
reversed: false,
|
|
2933
|
+
remove: false
|
|
2773
2934
|
}
|
|
2774
2935
|
},
|
|
2936
|
+
// GPIO: watched pins and the board's pin scan.
|
|
2775
2937
|
{
|
|
2776
2938
|
id: 'get_watched_gpio',
|
|
2777
2939
|
method: 'GET',
|
|
@@ -2784,6 +2946,7 @@
|
|
|
2784
2946
|
path: '/gpio/scan',
|
|
2785
2947
|
description: 'Scan and return configurations and values for all currently active GPIO pins.'
|
|
2786
2948
|
},
|
|
2949
|
+
// Sensors: scripts in the agent's scripts/ folder.
|
|
2787
2950
|
{
|
|
2788
2951
|
id: 'config_sensor',
|
|
2789
2952
|
method: 'POST',
|
|
@@ -2844,6 +3007,7 @@
|
|
|
2844
3007
|
}
|
|
2845
3008
|
]
|
|
2846
3009
|
},
|
|
3010
|
+
// Agent: health check, configuration, upgrade, restart and logs.
|
|
2847
3011
|
{
|
|
2848
3012
|
id: 'hello',
|
|
2849
3013
|
method: 'GET',
|
|
@@ -2854,13 +3018,13 @@
|
|
|
2854
3018
|
id: 'get_config',
|
|
2855
3019
|
method: 'GET',
|
|
2856
3020
|
path: '/config/read',
|
|
2857
|
-
description: '
|
|
3021
|
+
description: 'Read the stored configuration (name, pins, sensors, notifications, bridges and settings, never the API key) plus the live agent block: platform, IP, Wi-Fi, uptime and version.'
|
|
2858
3022
|
},
|
|
2859
3023
|
{
|
|
2860
3024
|
id: 'set_config',
|
|
2861
3025
|
method: 'POST',
|
|
2862
3026
|
path: '/config/update',
|
|
2863
|
-
description: '
|
|
3027
|
+
description: 'Change part of the configuration: rename the agent, set the webhook or a bridge callback, turn the docs pages or logging on or off, or change log retention. Fields may be sent ungrouped (as below) - the agent routes each into its group (notifications / settings / bridges).',
|
|
2864
3028
|
bodyType: 'json',
|
|
2865
3029
|
bodyFields: [
|
|
2866
3030
|
{
|
|
@@ -2914,7 +3078,7 @@
|
|
|
2914
3078
|
type: 'text',
|
|
2915
3079
|
required: false,
|
|
2916
3080
|
description: 'Target URL of the Matter bridge for webhook events.',
|
|
2917
|
-
placeholder: 'http://localhost:
|
|
3081
|
+
placeholder: 'http://localhost:8317',
|
|
2918
3082
|
sendEmpty: true
|
|
2919
3083
|
},
|
|
2920
3084
|
{
|
|
@@ -2930,7 +3094,7 @@
|
|
|
2930
3094
|
type: 'text',
|
|
2931
3095
|
required: false,
|
|
2932
3096
|
description: 'Target URL of the HomeKit bridge for webhook events.',
|
|
2933
|
-
placeholder: 'http://localhost:
|
|
3097
|
+
placeholder: 'http://localhost:8316',
|
|
2934
3098
|
sendEmpty: true
|
|
2935
3099
|
},
|
|
2936
3100
|
{
|
|
@@ -2980,13 +3144,13 @@
|
|
|
2980
3144
|
id: 'upgrade',
|
|
2981
3145
|
method: 'POST',
|
|
2982
3146
|
path: '/upgrade',
|
|
2983
|
-
description: 'Upgrade the agent in place. Takes no parameters. On a Pi it runs install.sh --upgrade (re-downloads the latest release and reinstalls dependencies) then restarts the process; on a Pico it downloads the latest main.py (backing up the old one) then resets. Returns {"status": "upgrading"}.'
|
|
3147
|
+
description: 'Upgrade the agent in place. Takes no parameters. On a Pi it runs install.sh --upgrade (re-downloads the latest release and reinstalls dependencies) then restarts the process; on a Pico it downloads the latest main.py (backing up the old one) then resets; on an ESP32 it downloads and verifies app.mpy, boot.py and main.py, then resets. Returns {"status": "upgrading"}.'
|
|
2984
3148
|
},
|
|
2985
3149
|
{
|
|
2986
3150
|
id: 'restart',
|
|
2987
3151
|
method: 'POST',
|
|
2988
3152
|
path: '/restart',
|
|
2989
|
-
description: 'Restart the server process. Body is optional - pass {"reboot": true} to reboot the physical device instead. Reboot is
|
|
3153
|
+
description: 'Restart the server process. Body is optional - pass {"reboot": true} to reboot the physical device instead. Reboot is honored on a Pi, Pico or ESP32; on Mac/dev machines it falls back to a process restart.',
|
|
2990
3154
|
bodyType: 'json',
|
|
2991
3155
|
bodyFields: [
|
|
2992
3156
|
{
|
|
@@ -3005,10 +3169,11 @@
|
|
|
3005
3169
|
id: 'get_logs',
|
|
3006
3170
|
method: 'GET',
|
|
3007
3171
|
path: '/logs',
|
|
3008
|
-
description: '
|
|
3172
|
+
description: 'Read the most recent log lines from the agent (the last 50 on a Pi, 30 on a Pico or ESP32).'
|
|
3009
3173
|
}
|
|
3010
3174
|
];
|
|
3011
3175
|
|
|
3176
|
+
// Page state: the open endpoint, the last response, and the poll timers.
|
|
3012
3177
|
let selectedEndpointId = 'read_gpio_all';
|
|
3013
3178
|
let isHeadersOpen = false;
|
|
3014
3179
|
let lastResponseData = null;
|
|
@@ -3048,6 +3213,7 @@
|
|
|
3048
3213
|
}
|
|
3049
3214
|
}
|
|
3050
3215
|
|
|
3216
|
+
// Switches light/dark mode, remembers it, and redraws the status badges in the new colors.
|
|
3051
3217
|
function toggleTheme() {
|
|
3052
3218
|
document.body.classList.toggle('dark-mode');
|
|
3053
3219
|
const isDark = document.body.classList.contains('dark-mode');
|
|
@@ -3067,6 +3233,8 @@
|
|
|
3067
3233
|
// Dynamically fetch current config state to populate forms
|
|
3068
3234
|
let _cachedPiConfig = null;
|
|
3069
3235
|
|
|
3236
|
+
// Reads the selected agent's /config/read into _cachedPiConfig and shows its board model
|
|
3237
|
+
// in the header. Other views (Webhooks, form prefill) read from this cache.
|
|
3070
3238
|
async function fetchPiConfig() {
|
|
3071
3239
|
try {
|
|
3072
3240
|
const url = getApiUrl();
|
|
@@ -3085,6 +3253,8 @@
|
|
|
3085
3253
|
}
|
|
3086
3254
|
}
|
|
3087
3255
|
|
|
3256
|
+
// Prefills the Config Update form from the cached config, flattening its groups into
|
|
3257
|
+
// the root field names the form uses.
|
|
3088
3258
|
async function fetchAndPopulateConfigFields(prefix) {
|
|
3089
3259
|
try {
|
|
3090
3260
|
const cfg = _cachedPiConfig;
|
|
@@ -3113,6 +3283,8 @@
|
|
|
3113
3283
|
}
|
|
3114
3284
|
}
|
|
3115
3285
|
|
|
3286
|
+
// Shows the public ctrlpi.com test feed, only when the agent's webhook points at ctrlpi.com.
|
|
3287
|
+
// The feed is shared by everyone using that test endpoint, not private to this agent.
|
|
3116
3288
|
async function fetchWebhooks() {
|
|
3117
3289
|
const webhookUrl = ((_cachedPiConfig || {}).notifications || {}).webhook || '';
|
|
3118
3290
|
if (!webhookUrl.toLowerCase().includes('ctrlpi.com')) return;
|
|
@@ -3123,6 +3295,7 @@
|
|
|
3123
3295
|
const response = await fetch(`/api/proxy/url?url=${encodeURIComponent(dataUrl)}`);
|
|
3124
3296
|
if (!response.ok) throw new Error(response.statusText);
|
|
3125
3297
|
let data = await response.json();
|
|
3298
|
+
// The proxy wraps a non-JSON answer as {text}; parse it when it holds JSON.
|
|
3126
3299
|
if (data.text) { try { data = JSON.parse(data.text); } catch (_) {} }
|
|
3127
3300
|
const events = Array.isArray(data) ? data : (data.entries || data.events || data.data || []);
|
|
3128
3301
|
document.getElementById('webhookCount').textContent = events.length;
|
|
@@ -3131,6 +3304,7 @@
|
|
|
3131
3304
|
tbody.innerHTML = '<tr><td colspan="3" style="text-align:center;color:var(--text-muted);padding:48px;">No webhook events yet.</td></tr>';
|
|
3132
3305
|
return;
|
|
3133
3306
|
}
|
|
3307
|
+
// Anyone can post to the test endpoint, so event content is escaped.
|
|
3134
3308
|
const esc = s => String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
3135
3309
|
// Oldest first so newest ends up at bottom
|
|
3136
3310
|
tbody.innerHTML = [...events].reverse().map(evt => {
|
|
@@ -3153,6 +3327,7 @@
|
|
|
3153
3327
|
}
|
|
3154
3328
|
}
|
|
3155
3329
|
|
|
3330
|
+
// This network's first three octets (e.g. "192.168.1."), prefilled in the + dialog.
|
|
3156
3331
|
let _localNetworkPrefix = '';
|
|
3157
3332
|
|
|
3158
3333
|
// Every startup prefers localhost, then the first responding LAN agent.
|
|
@@ -3171,6 +3346,7 @@
|
|
|
3171
3346
|
} catch (e) {
|
|
3172
3347
|
console.warn('Could not fetch server defaults:', e);
|
|
3173
3348
|
}
|
|
3349
|
+
// Apply the saved keys, then use a local agent when one answers /hello.
|
|
3174
3350
|
await loadSavedServers();
|
|
3175
3351
|
applySavedKeyForCurrentHost();
|
|
3176
3352
|
updateApiKeyTooltip();
|
|
@@ -3188,6 +3364,7 @@
|
|
|
3188
3364
|
await new Promise(resolve => { runNetworkScan(true, resolve).finally(resolve); });
|
|
3189
3365
|
}
|
|
3190
3366
|
|
|
3367
|
+
// Resets the API key panel label and tooltip for the current tab.
|
|
3191
3368
|
function refreshKeyPanelContext(tabId) {
|
|
3192
3369
|
const label = document.getElementById('apiKeyPanelLabel');
|
|
3193
3370
|
const agentInput = document.getElementById('apiKeyInput');
|
|
@@ -3209,23 +3386,24 @@
|
|
|
3209
3386
|
|
|
3210
3387
|
const targetPane = document.getElementById(tabId);
|
|
3211
3388
|
if (targetPane) {
|
|
3212
|
-
targetPane.style.display =
|
|
3389
|
+
targetPane.style.display = tabId === 'testerPane' ? 'grid' : 'flex';
|
|
3213
3390
|
// Small buffer to allow layout to settle before fading opacity
|
|
3214
3391
|
setTimeout(() => {
|
|
3215
3392
|
targetPane.classList.add('active');
|
|
3216
3393
|
}, 10);
|
|
3217
3394
|
}
|
|
3218
3395
|
|
|
3219
|
-
//
|
|
3220
|
-
// bar isn't relevant there.
|
|
3396
|
+
// Reset the key panel, then start the polling of the tab being opened.
|
|
3221
3397
|
refreshKeyPanelContext(tabId);
|
|
3222
3398
|
|
|
3223
3399
|
if (tabId === 'logPane') { fetchLogs(); setupLogAutoRefresh(); }
|
|
3224
3400
|
if (tabId === 'webhookPane') { fetchWebhooks(); setupWebhookAutoRefresh(); }
|
|
3225
3401
|
}
|
|
3226
3402
|
|
|
3227
|
-
|
|
3403
|
+
// Get Started panels that take the workbench's place when opened.
|
|
3404
|
+
const INFO_PANELS = ['welcomePanel', 'apiCurlPanel'];
|
|
3228
3405
|
|
|
3406
|
+
// Hides the Welcome and cURL panels and shows the API workbench again.
|
|
3229
3407
|
function _hideAllRefPanels() {
|
|
3230
3408
|
INFO_PANELS.forEach(id => {
|
|
3231
3409
|
const el = document.getElementById(id);
|
|
@@ -3236,17 +3414,7 @@
|
|
|
3236
3414
|
document.querySelectorAll('.pinout-link').forEach(el => el.classList.remove('active'));
|
|
3237
3415
|
}
|
|
3238
3416
|
|
|
3239
|
-
|
|
3240
|
-
_hideAllRefPanels();
|
|
3241
|
-
document.querySelector('#testerPane .workbench-header').style.display = 'none';
|
|
3242
|
-
document.querySelector('#testerPane .workbench-body').style.display = 'none';
|
|
3243
|
-
document.getElementById('pinoutLabel').textContent = label;
|
|
3244
|
-
document.getElementById('pinoutFrame').src = src;
|
|
3245
|
-
document.getElementById('pinoutPanel').style.display = 'flex';
|
|
3246
|
-
document.querySelectorAll('.endpoint-item').forEach(el => el.classList.remove('active'));
|
|
3247
|
-
document.querySelectorAll('.pinout-link').forEach(el => el.classList.toggle('active', el.dataset.src === src));
|
|
3248
|
-
}
|
|
3249
|
-
|
|
3417
|
+
// Shows one Get Started panel in place of the workbench.
|
|
3250
3418
|
function showInfoPanel(panelId) {
|
|
3251
3419
|
_hideAllRefPanels();
|
|
3252
3420
|
document.querySelector('#testerPane .workbench-header').style.display = 'none';
|
|
@@ -3258,10 +3426,12 @@
|
|
|
3258
3426
|
if (panelId === 'apiCurlPanel') _populateCurlExamples();
|
|
3259
3427
|
}
|
|
3260
3428
|
|
|
3261
|
-
|
|
3429
|
+
// Close button of the Get Started panels.
|
|
3430
|
+
function closeInfoPanel() {
|
|
3262
3431
|
_hideAllRefPanels();
|
|
3263
3432
|
}
|
|
3264
3433
|
|
|
3434
|
+
// Fills the nine cURL examples with the selected agent's URL and key.
|
|
3265
3435
|
function _populateCurlExamples() {
|
|
3266
3436
|
const url = getApiUrl();
|
|
3267
3437
|
const key = document.getElementById('apiKeyInput').value || '<API_KEY>';
|
|
@@ -3291,7 +3461,7 @@
|
|
|
3291
3461
|
const list = document.getElementById('endpointList');
|
|
3292
3462
|
list.innerHTML = '';
|
|
3293
3463
|
|
|
3294
|
-
//
|
|
3464
|
+
// Three groups, top to bottom: Configuration, GPIO Operations, Sensors.
|
|
3295
3465
|
const gpios = endpoints.filter(e => e.path.startsWith('/gpio'));
|
|
3296
3466
|
// Sensors: reads first, /sensor/config last (stable sort keeps read-all before read-one)
|
|
3297
3467
|
const sensors = endpoints.filter(e => e.path.startsWith('/sensor'))
|
|
@@ -3326,23 +3496,21 @@
|
|
|
3326
3496
|
appendGetStartedSection(list);
|
|
3327
3497
|
}
|
|
3328
3498
|
|
|
3329
|
-
//
|
|
3330
|
-
// workbench (#testerPane), so ensure that tab is active before showing one - this lets
|
|
3331
|
-
// the same section work from the MCP tab's sidebar too.
|
|
3499
|
+
// Activates the API workbench before displaying an info panel or pinout reference.
|
|
3332
3500
|
function _showGetStartedPanel(fn) {
|
|
3333
3501
|
const testerPane = document.getElementById('testerPane');
|
|
3334
3502
|
if (testerPane && !testerPane.classList.contains('active')) switchTab('testerPane');
|
|
3335
3503
|
fn();
|
|
3336
3504
|
}
|
|
3337
3505
|
|
|
3338
|
-
// Renders the
|
|
3339
|
-
// Used by both the API sidebar (renderEndpointsSidebar) and the MCP sidebar.
|
|
3506
|
+
// Renders the "Get Started" section (info panels and pinouts) into the sidebar list.
|
|
3340
3507
|
function appendGetStartedSection(list) {
|
|
3341
3508
|
const refHead = document.createElement('div');
|
|
3342
3509
|
refHead.className = 'endpoint-category-title';
|
|
3343
3510
|
refHead.innerText = 'Get Started';
|
|
3344
3511
|
list.appendChild(refHead);
|
|
3345
3512
|
|
|
3513
|
+
// A small colored square holding an icon, used in the Get Started list.
|
|
3346
3514
|
function _refIcon(bg, svgInner) {
|
|
3347
3515
|
return `<span style="display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;border-radius:6px;background:${bg};flex-shrink:0;">${svgInner}</span>`;
|
|
3348
3516
|
}
|
|
@@ -3366,24 +3534,13 @@
|
|
|
3366
3534
|
list.appendChild(div);
|
|
3367
3535
|
});
|
|
3368
3536
|
|
|
3369
|
-
const
|
|
3370
|
-
|
|
3371
|
-
|
|
3372
|
-
|
|
3373
|
-
|
|
3374
|
-
|
|
3375
|
-
|
|
3376
|
-
icon: _refIcon('#10b981', '<svg width="11" height="11" viewBox="0 0 24 24" fill="white"><path d="M7 3H5c-1.1 0-2 .9-2 2v2h2V5h2V3zm10 0h-2v2h2v2h2V5c0-1.1-.9-2-2-2zM5 19H3v-2H1v2c0 1.1.9 2 2 2h2v-2zm14 2h2c1.1 0 2-.9 2-2v-2h-2v2h-2v2zM1 7v10h2V7H1zm20 0v10h2V7h-2zM5 5v14h14V5H5zm12 12H7V7h10v10zM9 9h6v6H9V9z"/></svg>')
|
|
3377
|
-
},
|
|
3378
|
-
];
|
|
3379
|
-
pinouts.forEach(({ label, src, icon }) => {
|
|
3380
|
-
const div = document.createElement('div');
|
|
3381
|
-
div.className = 'pinout-link';
|
|
3382
|
-
div.dataset.src = src;
|
|
3383
|
-
div.onclick = () => _showGetStartedPanel(() => showPinout(src, label));
|
|
3384
|
-
div.innerHTML = `${icon}${label}`;
|
|
3385
|
-
list.appendChild(div);
|
|
3386
|
-
});
|
|
3537
|
+
const pinoutLink = document.createElement('a');
|
|
3538
|
+
pinoutLink.className = 'pinout-link';
|
|
3539
|
+
pinoutLink.href = 'https://ctrlpi.com/pinout/pi';
|
|
3540
|
+
pinoutLink.target = '_blank';
|
|
3541
|
+
pinoutLink.rel = 'noopener noreferrer';
|
|
3542
|
+
pinoutLink.innerHTML = `${_refIcon('#dc2626', '<svg width="11" height="11" viewBox="0 0 24 24" fill="white"><path d="M15 9H9v6h6V9zm-2 4h-2v-2h2v2zm8-2V9h-2V7c0-1.1-.9-2-2-2h-2V3h-2v2h-2V3H9v2H7c-1.1 0-2 .9-2 2v2H3v2h2v2H3v2h2v2c0 1.1.9 2 2 2h2v2h2v-2h2v2h2v-2h2c1.1 0 2-.9 2-2v-2h2v-2h-2v-2h2zm-4 6H7V7h10v10z"/></svg>')}Pinouts<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="margin-left:auto;opacity:0.6;"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path><polyline points="15 3 21 3 21 9"></polyline><line x1="10" y1="14" x2="21" y2="3"></line></svg>`;
|
|
3543
|
+
list.appendChild(pinoutLink);
|
|
3387
3544
|
}
|
|
3388
3545
|
|
|
3389
3546
|
// Search endpoints in list
|
|
@@ -3407,7 +3564,7 @@
|
|
|
3407
3564
|
// Endpoint Selection & details populating
|
|
3408
3565
|
// autoRun: if true (default), auto-execute GET endpoints with no params
|
|
3409
3566
|
function selectEndpoint(id, autoRun = true) {
|
|
3410
|
-
|
|
3567
|
+
closeInfoPanel();
|
|
3411
3568
|
selectedEndpointId = id;
|
|
3412
3569
|
document.querySelectorAll('.endpoint-item').forEach(item => item.classList.remove('active'));
|
|
3413
3570
|
const selectedItem = document.getElementById(`sidebar-item-${id}`);
|
|
@@ -3443,6 +3600,7 @@
|
|
|
3443
3600
|
sendApiRequest();
|
|
3444
3601
|
}
|
|
3445
3602
|
|
|
3603
|
+
// Endpoint-specific setup: field visibility, prefills and quick-pick buttons.
|
|
3446
3604
|
if (ep.id === 'config_gpio') {
|
|
3447
3605
|
onGpioTypeChange('output');
|
|
3448
3606
|
const pinInput = document.getElementById('param-path-gpio');
|
|
@@ -3474,10 +3632,12 @@
|
|
|
3474
3632
|
}
|
|
3475
3633
|
}
|
|
3476
3634
|
|
|
3635
|
+
// Quick-pick label for a pin: "name (gpio)" when it has a name.
|
|
3477
3636
|
function pinButtonLabel(gpio, info) {
|
|
3478
3637
|
return info.name ? `${info.name} (${gpio})` : gpio;
|
|
3479
3638
|
}
|
|
3480
3639
|
|
|
3640
|
+
// Quick-pick buttons for GPIO Write: the output pins the agent has configured.
|
|
3481
3641
|
async function updateWriteGpioQuickOptions() {
|
|
3482
3642
|
try {
|
|
3483
3643
|
const url = getApiUrl();
|
|
@@ -3514,6 +3674,7 @@
|
|
|
3514
3674
|
const data = await resp.json();
|
|
3515
3675
|
const pins = data.gpios || {};
|
|
3516
3676
|
let gpioNum = target;
|
|
3677
|
+
// The target may be a pin number or a pin name.
|
|
3517
3678
|
let entry = pins[target];
|
|
3518
3679
|
if (!entry) {
|
|
3519
3680
|
const found = Object.entries(pins).find(([, info]) => info.name === target);
|
|
@@ -3527,6 +3688,7 @@
|
|
|
3527
3688
|
}
|
|
3528
3689
|
}
|
|
3529
3690
|
|
|
3691
|
+
// Quick-pick buttons for GPIO Read: every pin the agent has configured.
|
|
3530
3692
|
async function updateReadGpioQuickOptions() {
|
|
3531
3693
|
try {
|
|
3532
3694
|
const url = getApiUrl();
|
|
@@ -3547,6 +3709,7 @@
|
|
|
3547
3709
|
}
|
|
3548
3710
|
}
|
|
3549
3711
|
|
|
3712
|
+
// Quick-pick buttons for GPIO Config, from the pins /gpio/read reports.
|
|
3550
3713
|
async function updateConfigGpioQuickOptions() {
|
|
3551
3714
|
try {
|
|
3552
3715
|
const url = getApiUrl();
|
|
@@ -3590,8 +3753,8 @@
|
|
|
3590
3753
|
}
|
|
3591
3754
|
}
|
|
3592
3755
|
|
|
3593
|
-
//
|
|
3594
|
-
//
|
|
3756
|
+
// Picks an existing sensor into the config form: sets the name and prefills its
|
|
3757
|
+
// script from the current config (like fetchAndPopulateGpioConfig for pins).
|
|
3595
3758
|
async function pickSensorConfig(name) {
|
|
3596
3759
|
setInputValue('name', name);
|
|
3597
3760
|
try {
|
|
@@ -3601,9 +3764,7 @@
|
|
|
3601
3764
|
if (!resp.ok) return;
|
|
3602
3765
|
const data = await resp.json();
|
|
3603
3766
|
const s = (data.sensors || {})[name] || {};
|
|
3604
|
-
const fileEl = document.getElementById('param-body-file');
|
|
3605
3767
|
const scriptEl = document.getElementById('param-body-script');
|
|
3606
|
-
if (fileEl) fileEl.value = s.file || '';
|
|
3607
3768
|
if (scriptEl) scriptEl.value = s.script || '';
|
|
3608
3769
|
updateLiveUrlPreview();
|
|
3609
3770
|
} catch (e) {
|
|
@@ -3611,13 +3772,15 @@
|
|
|
3611
3772
|
}
|
|
3612
3773
|
}
|
|
3613
3774
|
|
|
3775
|
+
// Checks a GPIO path value and disables Send while it is invalid. Any pin number
|
|
3776
|
+
// passes, since each agent knows its own board; names pass only where the route takes one.
|
|
3614
3777
|
function validateGpioParam(value, inputEl, noteEl, btnEl, namesAllowed) {
|
|
3615
3778
|
const trimmed = (value || '').trim();
|
|
3616
3779
|
const n = parseInt(trimmed, 10);
|
|
3617
3780
|
const isNumeric = trimmed !== '' && String(n) === trimmed;
|
|
3618
3781
|
let valid;
|
|
3619
3782
|
if (!trimmed) valid = true;
|
|
3620
|
-
else if (isNumeric) valid = n >=
|
|
3783
|
+
else if (isNumeric) valid = n >= 0; // the agent checks the pin against its own board
|
|
3621
3784
|
else valid = !!namesAllowed;
|
|
3622
3785
|
const showError = !!trimmed && !valid;
|
|
3623
3786
|
if (inputEl) inputEl.classList.toggle('invalid', showError);
|
|
@@ -3625,6 +3788,7 @@
|
|
|
3625
3788
|
if (btnEl) btnEl.disabled = showError || !document.getElementById('apiHostInput').value.trim();
|
|
3626
3789
|
}
|
|
3627
3790
|
|
|
3791
|
+
// GPIO Config takes a pin number only.
|
|
3628
3792
|
function validateConfigGpio(value) {
|
|
3629
3793
|
validateGpioParam(value,
|
|
3630
3794
|
document.getElementById('param-path-gpio'),
|
|
@@ -3633,6 +3797,7 @@
|
|
|
3633
3797
|
false);
|
|
3634
3798
|
}
|
|
3635
3799
|
|
|
3800
|
+
// GPIO Read and Write take a pin number or a pin name.
|
|
3636
3801
|
function validateReadWriteGpio(value) {
|
|
3637
3802
|
validateGpioParam(value,
|
|
3638
3803
|
document.getElementById('param-path-name_or_gpio'),
|
|
@@ -3677,7 +3842,7 @@
|
|
|
3677
3842
|
<div class="param-input-wrapper">
|
|
3678
3843
|
<input type="text" id="param-path-${param.name}" class="param-input" placeholder="${param.placeholder || ''}" value="${param.default !== undefined ? param.default : (param.quickOptions && param.quickOptions[0] !== undefined ? param.quickOptions[0] : '')}" oninput="updateLiveUrlPreview();${ep.id === 'config_gpio' && param.name === 'gpio' ? 'fetchAndPopulateGpioConfig(this.value); validateConfigGpio(this.value);' : (ep.id === 'read_gpio' || ep.id === 'write_gpio') && param.name === 'name_or_gpio' ? 'validateReadWriteGpio(this.value);' : ''}">
|
|
3679
3844
|
</div>
|
|
3680
|
-
${(ep.id === 'config_gpio' && param.name === 'gpio') || ((ep.id === 'read_gpio' || ep.id === 'write_gpio') && param.name === 'name_or_gpio') ? '<p class="gpio-range-note" id="gpio-range-note">
|
|
3845
|
+
${(ep.id === 'config_gpio' && param.name === 'gpio') || ((ep.id === 'read_gpio' || ep.id === 'write_gpio') && param.name === 'name_or_gpio') ? '<p class="gpio-range-note" id="gpio-range-note">Enter a GPIO number.</p>' : ''}
|
|
3681
3846
|
${quickHtml}
|
|
3682
3847
|
`;
|
|
3683
3848
|
form.appendChild(group);
|
|
@@ -3740,7 +3905,7 @@
|
|
|
3740
3905
|
<p class="param-desc" style="margin-top: 2px;">${field.description}</p>
|
|
3741
3906
|
</div>
|
|
3742
3907
|
<label class="toggle-switch">
|
|
3743
|
-
<input type="checkbox" id="param-body-${field.name}" ${field.default === true ? 'checked' : ''}>
|
|
3908
|
+
<input type="checkbox" id="param-body-${field.name}" ${field.default === true ? 'checked' : ''}${field.onchange ? ` onchange="${field.onchange}"` : ''}>
|
|
3744
3909
|
<span class="slider"></span>
|
|
3745
3910
|
</label>
|
|
3746
3911
|
`;
|
|
@@ -3883,6 +4048,7 @@
|
|
|
3883
4048
|
}
|
|
3884
4049
|
}
|
|
3885
4050
|
|
|
4051
|
+
// Checks the raw JSON body and says whether it parses.
|
|
3886
4052
|
function verifyRawJson() {
|
|
3887
4053
|
const textarea = document.getElementById('bodyFieldsRawSection');
|
|
3888
4054
|
const result = document.getElementById('verifyJsonResult');
|
|
@@ -3897,6 +4063,8 @@
|
|
|
3897
4063
|
}
|
|
3898
4064
|
}
|
|
3899
4065
|
|
|
4066
|
+
// Loads the agent's current config into the raw JSON body, ready to edit and send to
|
|
4067
|
+
// /config/load.
|
|
3900
4068
|
async function populateCurrentConfig() {
|
|
3901
4069
|
const textarea = document.getElementById('bodyFieldsRawSection');
|
|
3902
4070
|
const result = document.getElementById('verifyJsonResult');
|
|
@@ -3915,12 +4083,14 @@
|
|
|
3915
4083
|
}
|
|
3916
4084
|
}
|
|
3917
4085
|
|
|
4086
|
+
// Shows only the GPIO Config fields that apply to the chosen pin type.
|
|
3918
4087
|
function onGpioTypeChange(value) {
|
|
3919
4088
|
const isSignal = value === 'output' || value === 'input';
|
|
3920
4089
|
const isClear = value === 'remove';
|
|
3921
4090
|
const nameGroup = document.getElementById('param-body-group-name');
|
|
3922
4091
|
if (nameGroup) nameGroup.style.display = isClear ? 'none' : '';
|
|
3923
|
-
document.getElementById('param-body-group-pullup')
|
|
4092
|
+
const pullupGroup = document.getElementById('param-body-group-pullup');
|
|
4093
|
+
if (pullupGroup) pullupGroup.style.display = value === 'input' ? '' : 'none';
|
|
3924
4094
|
const initGroup = document.getElementById('param-body-group-init');
|
|
3925
4095
|
if (initGroup) initGroup.style.display = isSignal ? '' : 'none';
|
|
3926
4096
|
const maxGroup = document.getElementById('param-body-group-max');
|
|
@@ -3929,9 +4099,25 @@
|
|
|
3929
4099
|
if (revGroup) revGroup.style.display = isSignal ? '' : 'none';
|
|
3930
4100
|
const watchedGroup = document.getElementById('param-body-group-watched');
|
|
3931
4101
|
if (watchedGroup) watchedGroup.style.display = isSignal ? '' : 'none';
|
|
4102
|
+
const removeEl = document.getElementById('param-body-remove');
|
|
4103
|
+
if (removeEl) removeEl.checked = isClear;
|
|
4104
|
+
}
|
|
4105
|
+
|
|
4106
|
+
// Keeps the Remove checkbox and the "remove" type in step.
|
|
4107
|
+
function onGpioRemoveChange(checked) {
|
|
4108
|
+
const typeEl = document.getElementById('param-body-type');
|
|
4109
|
+
if (checked) {
|
|
4110
|
+
if (typeEl) typeEl.value = 'remove';
|
|
4111
|
+
onGpioTypeChange('remove');
|
|
4112
|
+
} else {
|
|
4113
|
+
if (typeEl && typeEl.value === 'remove') typeEl.value = 'output';
|
|
4114
|
+
onGpioTypeChange(typeEl ? typeEl.value : 'output');
|
|
4115
|
+
}
|
|
3932
4116
|
}
|
|
3933
4117
|
|
|
4118
|
+
// Waits for typing to pause before reading the pin's config.
|
|
3934
4119
|
let _gpioConfigDebounce = null;
|
|
4120
|
+
// Prefills the GPIO Config form from the pin's current settings, debounced while typing.
|
|
3935
4121
|
async function fetchAndPopulateGpioConfig(nameOrGpio) {
|
|
3936
4122
|
if (!nameOrGpio || !nameOrGpio.trim()) return;
|
|
3937
4123
|
clearTimeout(_gpioConfigDebounce);
|
|
@@ -3961,6 +4147,7 @@
|
|
|
3961
4147
|
// Boolean fields
|
|
3962
4148
|
if (el('reversed')) el('reversed').checked = !!pin.reversed;
|
|
3963
4149
|
if (el('watched')) el('watched').checked = !!pin.watched;
|
|
4150
|
+
if (el('remove')) el('remove').checked = false;
|
|
3964
4151
|
onGpioTypeChange(pin.type || 'output');
|
|
3965
4152
|
} catch (_) {}
|
|
3966
4153
|
}, 400);
|
|
@@ -3997,6 +4184,10 @@
|
|
|
3997
4184
|
}
|
|
3998
4185
|
}
|
|
3999
4186
|
});
|
|
4187
|
+
if (ep.id === 'config_gpio') {
|
|
4188
|
+
const typeVal = currentRaw.type || (currentRaw.remove ? 'remove' : 'output');
|
|
4189
|
+
onGpioTypeChange(typeVal);
|
|
4190
|
+
}
|
|
4000
4191
|
}
|
|
4001
4192
|
} catch (e) { }
|
|
4002
4193
|
} else {
|
|
@@ -4036,16 +4227,22 @@
|
|
|
4036
4227
|
val = input.value === '' ? null : Number(input.value);
|
|
4037
4228
|
} else {
|
|
4038
4229
|
val = input.value;
|
|
4039
|
-
//
|
|
4230
|
+
// value and init take 1/0 as numbers; the MicroPython agents refuse "1"/"0" as text.
|
|
4040
4231
|
if (val === 'null') val = null;
|
|
4041
|
-
else if (val === '1' && field.name === 'value') val =
|
|
4042
|
-
else if (val === '0' && field.name === 'value') val = 0;
|
|
4232
|
+
else if ((val === '1' || val === '0') && (field.name === 'value' || field.name === 'init')) val = Number(val);
|
|
4043
4233
|
}
|
|
4044
4234
|
|
|
4045
4235
|
if (val !== null && (val !== '' || field.sendEmpty)) {
|
|
4046
4236
|
payload[field.name] = val;
|
|
4047
4237
|
}
|
|
4048
4238
|
});
|
|
4239
|
+
|
|
4240
|
+
if (ep.id === 'config_gpio') {
|
|
4241
|
+
if (payload.remove === true || payload.type === 'remove') {
|
|
4242
|
+
payload.type = 'remove';
|
|
4243
|
+
payload.remove = true;
|
|
4244
|
+
}
|
|
4245
|
+
}
|
|
4049
4246
|
return payload;
|
|
4050
4247
|
}
|
|
4051
4248
|
|
|
@@ -4164,7 +4361,7 @@
|
|
|
4164
4361
|
displayResponse(0, "Network Error / Offline", latency, {
|
|
4165
4362
|
error: "Failed to connect to API server.",
|
|
4166
4363
|
details: err.message,
|
|
4167
|
-
suggestion: `
|
|
4364
|
+
suggestion: `Check that the agent is running and reachable at this address and port.`
|
|
4168
4365
|
}, null);
|
|
4169
4366
|
} finally {
|
|
4170
4367
|
btn.disabled = false;
|
|
@@ -4214,6 +4411,7 @@
|
|
|
4214
4411
|
if (typeof json !== 'string') {
|
|
4215
4412
|
json = JSON.stringify(json, undefined, 4);
|
|
4216
4413
|
}
|
|
4414
|
+
// Escape first, then wrap keys, strings, numbers, booleans and null in colored spans.
|
|
4217
4415
|
json = json.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
4218
4416
|
return json.replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+-]?\d+)?)/g, function (match) {
|
|
4219
4417
|
let cls = 'json-number';
|
|
@@ -4271,9 +4469,6 @@
|
|
|
4271
4469
|
} else if (response.status === 403) {
|
|
4272
4470
|
isApiKeyValid = false;
|
|
4273
4471
|
return 403;
|
|
4274
|
-
} else if (response.status === 503) {
|
|
4275
|
-
isApiKeyValid = 'disabled';
|
|
4276
|
-
return 503;
|
|
4277
4472
|
}
|
|
4278
4473
|
isApiKeyValid = false;
|
|
4279
4474
|
return response.status;
|
|
@@ -4320,21 +4515,26 @@
|
|
|
4320
4515
|
await verifyApiKeyDirectly(url, key);
|
|
4321
4516
|
}
|
|
4322
4517
|
|
|
4518
|
+
// Mark it online in the picker, then color the status dot by the key check.
|
|
4519
|
+
_onlineAgents.add(url.replace(/\/+$/, ''));
|
|
4520
|
+
renderServersDropdown(_savedServersCache);
|
|
4521
|
+
|
|
4323
4522
|
if (isApiKeyValid === true) {
|
|
4324
4523
|
indicator.className = 'logo-indicator status-online';
|
|
4325
4524
|
indicator.title = 'API Status: Online';
|
|
4326
|
-
} else if (isApiKeyValid === 'disabled') {
|
|
4327
|
-
indicator.className = 'logo-indicator status-offline';
|
|
4328
|
-
indicator.title = 'API Status: REST API Disabled';
|
|
4329
4525
|
} else {
|
|
4330
4526
|
indicator.className = 'logo-indicator status-invalid-key';
|
|
4331
4527
|
indicator.title = 'API Status: Invalid API Key';
|
|
4332
4528
|
}
|
|
4333
4529
|
} else {
|
|
4530
|
+
_onlineAgents.delete(url.replace(/\/+$/, ''));
|
|
4531
|
+
renderServersDropdown(_savedServersCache);
|
|
4334
4532
|
indicator.className = 'logo-indicator status-offline';
|
|
4335
4533
|
indicator.title = 'API Status: Server Error';
|
|
4336
4534
|
}
|
|
4337
4535
|
} catch (e) {
|
|
4536
|
+
_onlineAgents.delete(url.replace(/\/+$/, ''));
|
|
4537
|
+
renderServersDropdown(_savedServersCache);
|
|
4338
4538
|
indicator.className = 'logo-indicator status-offline';
|
|
4339
4539
|
indicator.title = 'API Status: Connection Offline';
|
|
4340
4540
|
}
|
|
@@ -4353,6 +4553,7 @@
|
|
|
4353
4553
|
label.title = label.textContent;
|
|
4354
4554
|
}
|
|
4355
4555
|
|
|
4556
|
+
// Send is enabled only once an agent address is set.
|
|
4356
4557
|
function updateSendButtonState() {
|
|
4357
4558
|
updateAgentAddress();
|
|
4358
4559
|
const btn = document.getElementById('sendRequestBtn');
|
|
@@ -4360,14 +4561,17 @@
|
|
|
4360
4561
|
btn.disabled = !document.getElementById('apiHostInput').value.trim();
|
|
4361
4562
|
}
|
|
4362
4563
|
|
|
4564
|
+
// The selected agent as a base URL, e.g. http://192.168.1.50:8314.
|
|
4363
4565
|
function getApiUrl() {
|
|
4364
4566
|
const host = (document.getElementById('apiHostInput').value || 'http://localhost').replace(/\/$/, '').trim();
|
|
4365
4567
|
const port = document.getElementById('apiPortInput').value.trim();
|
|
4366
4568
|
return port ? `${host}:${port}` : host;
|
|
4367
4569
|
}
|
|
4368
4570
|
|
|
4571
|
+
// Coalesces quick address changes into one save.
|
|
4369
4572
|
let _saveConfigTimer = null;
|
|
4370
4573
|
|
|
4574
|
+
// Saves the selected agent to lab-config.json.
|
|
4371
4575
|
async function saveConnectionConfig() {
|
|
4372
4576
|
const host = document.getElementById('apiHostInput').value.trim();
|
|
4373
4577
|
const port = document.getElementById('apiPortInput').value.trim();
|
|
@@ -4394,6 +4598,8 @@
|
|
|
4394
4598
|
// When base address or key changes in top bar
|
|
4395
4599
|
let _connectionRevision = 0;
|
|
4396
4600
|
|
|
4601
|
+
// Runs whenever the selected agent changes: drops cached state, applies its saved key,
|
|
4602
|
+
// re-checks the connection and saves the selection.
|
|
4397
4603
|
function onApiConfigChange() {
|
|
4398
4604
|
_connectionRevision++;
|
|
4399
4605
|
isApiKeyValid = null;
|
|
@@ -4403,6 +4609,7 @@
|
|
|
4403
4609
|
updateLiveUrlPreview();
|
|
4404
4610
|
updateSendButtonState();
|
|
4405
4611
|
checkApiStatus();
|
|
4612
|
+
// Save the selection once changes pause, and remember it in this browser.
|
|
4406
4613
|
clearTimeout(_saveConfigTimer);
|
|
4407
4614
|
_saveConfigTimer = setTimeout(saveConnectionConfig, 600);
|
|
4408
4615
|
const fullUrl = getApiUrl();
|
|
@@ -4429,6 +4636,7 @@
|
|
|
4429
4636
|
if (!response.ok) throw new Error("API call error");
|
|
4430
4637
|
|
|
4431
4638
|
let logs = (await response.json()).logs || [];
|
|
4639
|
+
// Hide this viewer's own polling, then apply the search.
|
|
4432
4640
|
logs = logs.filter(l => !l.includes('GET /logs'));
|
|
4433
4641
|
if (q) logs = logs.filter(l => l.toLowerCase().includes(q.toLowerCase()));
|
|
4434
4642
|
renderLogLines(logs);
|
|
@@ -4463,6 +4671,7 @@
|
|
|
4463
4671
|
let formatted = line;
|
|
4464
4672
|
|
|
4465
4673
|
// Color Code brackets
|
|
4674
|
+
// Color the [API], [GPIO] and [webhook] tags.
|
|
4466
4675
|
if (line.includes('[API]')) {
|
|
4467
4676
|
formatted = line.replace('[API]', '<span class="log-tag-api">[API]</span>');
|
|
4468
4677
|
} else if (line.includes('[GPIO]')) {
|
|
@@ -4499,13 +4708,11 @@
|
|
|
4499
4708
|
|
|
4500
4709
|
// Clear Server log file
|
|
4501
4710
|
async function clearLogsOnServer() {
|
|
4502
|
-
const confirmReset = confirm("
|
|
4711
|
+
const confirmReset = confirm("Erase the agent's log?");
|
|
4503
4712
|
if (!confirmReset) return;
|
|
4504
4713
|
|
|
4505
|
-
// The
|
|
4506
|
-
//
|
|
4507
|
-
// it. Done client-side via piApiFetch against the currently selected Pi
|
|
4508
|
-
// (getApiUrl()/apiKeyInput), not whatever Pi the monitor defaults to.
|
|
4714
|
+
// The agents have no clear endpoint, so this reads log_days, sets it to 0 (which
|
|
4715
|
+
// clears the log at once), then restores it, on the selected agent.
|
|
4509
4716
|
const url = getApiUrl();
|
|
4510
4717
|
const apiKey = document.getElementById('apiKeyInput').value.trim();
|
|
4511
4718
|
const headers = { 'Content-Type': 'application/json', 'Api-Key': apiKey };
|
|
@@ -4542,6 +4749,7 @@
|
|
|
4542
4749
|
fetchLogs();
|
|
4543
4750
|
}
|
|
4544
4751
|
|
|
4752
|
+
// Refresh button of the Webhooks tab.
|
|
4545
4753
|
function triggerManualWebhookRefresh() {
|
|
4546
4754
|
const icon = document.getElementById('webhookRefreshBtnIcon');
|
|
4547
4755
|
icon.classList.add('rotate-anim');
|
|
@@ -4549,6 +4757,7 @@
|
|
|
4549
4757
|
fetchWebhooks();
|
|
4550
4758
|
}
|
|
4551
4759
|
|
|
4760
|
+
// Restarts the webhook poll at the chosen rate, or stops it for Manual.
|
|
4552
4761
|
function setupWebhookAutoRefresh() {
|
|
4553
4762
|
if (webhookPollIntervalId) { clearInterval(webhookPollIntervalId); webhookPollIntervalId = null; }
|
|
4554
4763
|
const rate = document.getElementById('webhookPollSelect').value;
|
|
@@ -4600,9 +4809,13 @@
|
|
|
4600
4809
|
// Cache of {url, api_key} records from /api/servers - lets the dropdown and
|
|
4601
4810
|
// host-change handlers look up a Pi's own saved key without refetching.
|
|
4602
4811
|
let _savedServersCache = [];
|
|
4812
|
+
// Agent picker state: who answered, each board's model, and a guard against
|
|
4813
|
+
// overlapping model fetches.
|
|
4814
|
+
let _onlineAgents = new Set();
|
|
4603
4815
|
const _agentModels = new Map();
|
|
4604
4816
|
let _loadingAgentModels = false;
|
|
4605
4817
|
|
|
4818
|
+
// Fetches the board model of each saved agent for the agent picker, four at a time.
|
|
4606
4819
|
async function loadAgentModels() {
|
|
4607
4820
|
if (_loadingAgentModels) return;
|
|
4608
4821
|
_loadingAgentModels = true;
|
|
@@ -4616,14 +4829,25 @@
|
|
|
4616
4829
|
headers: { 'Api-Key': server.api_key || '' },
|
|
4617
4830
|
signal: AbortSignal.timeout(2500)
|
|
4618
4831
|
});
|
|
4619
|
-
|
|
4832
|
+
// Any answer, even a refused key, shows the agent is up.
|
|
4833
|
+
if (response.ok || response.status === 401 || response.status === 403) {
|
|
4834
|
+
_onlineAgents.add(server.url.replace(/\/+$/, ''));
|
|
4835
|
+
}
|
|
4836
|
+
if (!response.ok) {
|
|
4837
|
+
renderServersDropdown(_savedServersCache);
|
|
4838
|
+
return;
|
|
4839
|
+
}
|
|
4620
4840
|
const config = await response.json();
|
|
4841
|
+
// The board model comes from the live agent block.
|
|
4621
4842
|
const model = config.agent?.platform;
|
|
4622
4843
|
if (typeof model === 'string' && model.trim()) {
|
|
4623
4844
|
_agentModels.set(server.url, model.trim());
|
|
4624
|
-
renderServersDropdown(_savedServersCache);
|
|
4625
4845
|
}
|
|
4626
|
-
|
|
4846
|
+
renderServersDropdown(_savedServersCache);
|
|
4847
|
+
} catch {
|
|
4848
|
+
_onlineAgents.delete(server.url.replace(/\/+$/, ''));
|
|
4849
|
+
renderServersDropdown(_savedServersCache);
|
|
4850
|
+
}
|
|
4627
4851
|
}));
|
|
4628
4852
|
}
|
|
4629
4853
|
} finally {
|
|
@@ -4631,6 +4855,47 @@
|
|
|
4631
4855
|
}
|
|
4632
4856
|
}
|
|
4633
4857
|
|
|
4858
|
+
// Deletes a saved agent after confirmation, via DELETE /api/servers.
|
|
4859
|
+
async function deleteSavedAgent(event, url) {
|
|
4860
|
+
if (event) {
|
|
4861
|
+
event.stopPropagation();
|
|
4862
|
+
if (event.preventDefault) event.preventDefault();
|
|
4863
|
+
}
|
|
4864
|
+
const server = (_savedServersCache || []).find(s => s.url === url);
|
|
4865
|
+
const displayName = server?.name || url.replace(/^https?:\/\//, '');
|
|
4866
|
+
if (typeof confirm === 'function' && !confirm(`Remove "${displayName}" from saved agents?`)) return;
|
|
4867
|
+
|
|
4868
|
+
try {
|
|
4869
|
+
const res = await fetch(`/api/servers?url=${encodeURIComponent(url)}`, {
|
|
4870
|
+
method: 'DELETE'
|
|
4871
|
+
});
|
|
4872
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
4873
|
+
const data = await res.json();
|
|
4874
|
+
_savedServersCache = data.servers || [];
|
|
4875
|
+
if (Array.isArray(data.online_agents)) {
|
|
4876
|
+
_onlineAgents = new Set(data.online_agents.map(u => u.replace(/\/+$/, '')));
|
|
4877
|
+
}
|
|
4878
|
+
_agentModels.delete(url);
|
|
4879
|
+
|
|
4880
|
+
// Deleting the selected agent moves the selection to the first one left.
|
|
4881
|
+
const currentUrl = getApiUrl().replace(/\/+$/, '');
|
|
4882
|
+
if (currentUrl === url.replace(/\/+$/, '')) {
|
|
4883
|
+
if (_savedServersCache.length > 0) {
|
|
4884
|
+
selectScannedHost(_savedServersCache[0].url);
|
|
4885
|
+
} else {
|
|
4886
|
+
document.getElementById('apiHostInput').value = '';
|
|
4887
|
+
onApiConfigChange();
|
|
4888
|
+
}
|
|
4889
|
+
}
|
|
4890
|
+
renderServersDropdown(_savedServersCache);
|
|
4891
|
+
} catch (err) {
|
|
4892
|
+
console.error('Failed to delete agent:', err);
|
|
4893
|
+
if (typeof alert === 'function') alert(`Could not remove agent: ${err.message}`);
|
|
4894
|
+
}
|
|
4895
|
+
}
|
|
4896
|
+
|
|
4897
|
+
// Draws the agent picker sorted by name: model and address (green when the last scan
|
|
4898
|
+
// reached it), and a delete button on agents that were not reached.
|
|
4634
4899
|
function renderServersDropdown(servers) {
|
|
4635
4900
|
const panel = document.getElementById('savedServersPanel');
|
|
4636
4901
|
if (!panel) return;
|
|
@@ -4640,13 +4905,18 @@
|
|
|
4640
4905
|
panel.innerHTML = '';
|
|
4641
4906
|
if (!sorted.length) panel.innerHTML = '<div class="servers-dd-empty">No saved agents. Use + to add one.</div>';
|
|
4642
4907
|
sorted.forEach(server => {
|
|
4643
|
-
const item = document.createElement('
|
|
4644
|
-
item.type = 'button';
|
|
4908
|
+
const item = document.createElement('div');
|
|
4645
4909
|
item.className = 'servers-dd-item';
|
|
4646
4910
|
const m = _agentModels.get(server.url);
|
|
4647
4911
|
const modelText = m ? ` <span style="font-family:'Inter',sans-serif;font-size:0.95rem;font-weight:500;color:var(--text-muted);position:relative;top:1px">- ${m}</span>` : '';
|
|
4648
|
-
|
|
4649
|
-
|
|
4912
|
+
const isOnline = _onlineAgents && _onlineAgents.has(server.url.replace(/\/+$/, ''));
|
|
4913
|
+
const addrColor = isOnline ? 'var(--success)' : '#9ca3af';
|
|
4914
|
+
const deleteBtnHtml = !isOnline ? `<button type="button" class="servers-dd-delete-btn" title="Remove offline agent from list" aria-label="Remove agent" onclick="deleteSavedAgent(event, '${server.url}')"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/></svg></button>` : '';
|
|
4915
|
+
item.innerHTML = `<span class="servers-dd-item-content"><span style="font-family:'Inter',sans-serif;font-size:1rem;font-weight:700">${server.name || 'Unnamed agent'}</span>${modelText} <span style="font-family:'Inter',sans-serif;font-size:0.95rem;font-weight:500;color:${addrColor};position:relative;top:1px">${server.url.replace(/^https?:\/\//, '')}</span></span>${deleteBtnHtml}`;
|
|
4916
|
+
item.onclick = (e) => {
|
|
4917
|
+
if (e && e.target && e.target.closest && e.target.closest('.servers-dd-delete-btn')) return;
|
|
4918
|
+
pickSavedServer(server.url);
|
|
4919
|
+
};
|
|
4650
4920
|
panel.appendChild(item);
|
|
4651
4921
|
});
|
|
4652
4922
|
}
|
|
@@ -4655,11 +4925,15 @@
|
|
|
4655
4925
|
// touching the underlying persisted set; omit it to load everything saved.
|
|
4656
4926
|
async function loadSavedServers(servers) {
|
|
4657
4927
|
try {
|
|
4928
|
+
// Without a list given, fetch it together with the scan history.
|
|
4658
4929
|
if (!servers) {
|
|
4659
4930
|
const res = await fetch('/api/servers');
|
|
4660
4931
|
if (!res.ok) return;
|
|
4661
4932
|
const data = await res.json();
|
|
4662
4933
|
servers = data.servers || [];
|
|
4934
|
+
if (Array.isArray(data.online_agents)) {
|
|
4935
|
+
_onlineAgents = new Set(data.online_agents.map(u => u.replace(/\/+$/, '')));
|
|
4936
|
+
}
|
|
4663
4937
|
_savedServersCache = servers;
|
|
4664
4938
|
} else {
|
|
4665
4939
|
_savedServersCache = servers;
|
|
@@ -4670,6 +4944,7 @@
|
|
|
4670
4944
|
}
|
|
4671
4945
|
}
|
|
4672
4946
|
|
|
4947
|
+
// Opens or closes the agent picker.
|
|
4673
4948
|
function toggleServersDropdown(e) {
|
|
4674
4949
|
e.stopPropagation();
|
|
4675
4950
|
const panel = document.getElementById('savedServersPanel');
|
|
@@ -4679,18 +4954,22 @@
|
|
|
4679
4954
|
document.getElementById('agentPickerBtn').setAttribute('aria-expanded', String(panel.classList.contains('open')));
|
|
4680
4955
|
}
|
|
4681
4956
|
|
|
4957
|
+
// Selects an agent from the picker.
|
|
4682
4958
|
function pickSavedServer(url) {
|
|
4683
4959
|
document.getElementById('savedServersPanel').classList.remove('open');
|
|
4684
4960
|
document.getElementById('agentPickerBtn').setAttribute('aria-expanded', 'false');
|
|
4685
4961
|
selectScannedHost(url);
|
|
4686
4962
|
}
|
|
4687
4963
|
|
|
4964
|
+
// The key before editing, restored on Cancel.
|
|
4688
4965
|
let _apiKeyOriginalValue = null;
|
|
4689
4966
|
|
|
4967
|
+
// The key field the API key panel edits.
|
|
4690
4968
|
function _activeKeyInput() {
|
|
4691
4969
|
return document.getElementById('apiKeyInput');
|
|
4692
4970
|
}
|
|
4693
4971
|
|
|
4972
|
+
// Opens or closes the saved-key editor.
|
|
4694
4973
|
function toggleApiKeyPanel(e) {
|
|
4695
4974
|
if (e) e.stopPropagation();
|
|
4696
4975
|
const dialog = document.getElementById('apiKeyDialog');
|
|
@@ -4717,6 +4996,7 @@
|
|
|
4717
4996
|
dialog.close();
|
|
4718
4997
|
}
|
|
4719
4998
|
|
|
4999
|
+
// Saves the edited key as this agent's stored key (it does not change the agent itself).
|
|
4720
5000
|
function saveApiKey() {
|
|
4721
5001
|
closeApiKeyPanel(false);
|
|
4722
5002
|
const url = getApiUrl();
|
|
@@ -4734,12 +5014,13 @@
|
|
|
4734
5014
|
onApiConfigChange();
|
|
4735
5015
|
}
|
|
4736
5016
|
|
|
5017
|
+
// Closes the key editor and restores the previous key.
|
|
4737
5018
|
function cancelApiKeyEdit() {
|
|
4738
5019
|
closeApiKeyPanel(true);
|
|
4739
5020
|
}
|
|
4740
5021
|
|
|
4741
|
-
// Sends a new API key to the
|
|
4742
|
-
// switches the
|
|
5022
|
+
// Sends a new API key to the agent (authenticated with the current key), and only
|
|
5023
|
+
// switches the dashboard to the new key once the agent confirms the change.
|
|
4743
5024
|
async function updateApiKeyDangerZone() {
|
|
4744
5025
|
const input = document.getElementById('dangerZoneNewApiKey');
|
|
4745
5026
|
const result = document.getElementById('dangerZoneResult');
|
|
@@ -4761,8 +5042,9 @@
|
|
|
4761
5042
|
const currentKey = document.getElementById('apiKeyInput').value;
|
|
4762
5043
|
|
|
4763
5044
|
btn.disabled = true;
|
|
4764
|
-
setResult('Sending update to the
|
|
5045
|
+
setResult('Sending update to the agent…');
|
|
4765
5046
|
try {
|
|
5047
|
+
// Authenticated with the current key; the agent switches to the new one at once.
|
|
4766
5048
|
const response = await piApiFetch(`${url}/config/update`, {
|
|
4767
5049
|
method: 'POST',
|
|
4768
5050
|
headers: {
|
|
@@ -4774,16 +5056,16 @@
|
|
|
4774
5056
|
|
|
4775
5057
|
if (!response.ok) {
|
|
4776
5058
|
const text = await response.text().catch(() => '');
|
|
4777
|
-
setResult(`
|
|
5059
|
+
setResult(`The agent rejected the update (HTTP ${response.status}). Key was NOT changed. ${text.slice(0, 200)}`, 'error');
|
|
4778
5060
|
return;
|
|
4779
5061
|
}
|
|
4780
5062
|
|
|
4781
|
-
//
|
|
5063
|
+
// The agent accepted the new key: use it from now on.
|
|
4782
5064
|
document.getElementById('apiKeyInput').value = newKey;
|
|
4783
5065
|
input.value = '';
|
|
4784
5066
|
updateApiKeyTooltip();
|
|
4785
5067
|
onApiConfigChange();
|
|
4786
|
-
setResult('✓ Key updated on the
|
|
5068
|
+
setResult('✓ Key updated on the agent. The dashboard now uses the new key.', 'success');
|
|
4787
5069
|
} catch (e) {
|
|
4788
5070
|
setResult(`Request failed - Key was NOT changed: ${e.message}`, 'error');
|
|
4789
5071
|
} finally {
|
|
@@ -4798,6 +5080,7 @@
|
|
|
4798
5080
|
if (input && btn) btn.title = `API Key: ${input.value || '(none set)'}`;
|
|
4799
5081
|
}
|
|
4800
5082
|
|
|
5083
|
+
// Escape, or a click anywhere else, closes the agent picker.
|
|
4801
5084
|
document.addEventListener('keydown', event => {
|
|
4802
5085
|
if (event.key === 'Escape') {
|
|
4803
5086
|
document.getElementById('savedServersPanel').classList.remove('open');
|
|
@@ -4811,6 +5094,7 @@
|
|
|
4811
5094
|
document.getElementById('agentPickerBtn').setAttribute('aria-expanded', 'false');
|
|
4812
5095
|
});
|
|
4813
5096
|
|
|
5097
|
+
// Opens the + dialog, prefilled with this network prefix, port 8314 and the default key.
|
|
4814
5098
|
function openAddAgentDialog() {
|
|
4815
5099
|
const hostInput = document.getElementById('newAgentHost');
|
|
4816
5100
|
hostInput.value = _localNetworkPrefix;
|
|
@@ -4822,11 +5106,13 @@
|
|
|
4822
5106
|
hostInput.setSelectionRange(hostInput.value.length, hostInput.value.length);
|
|
4823
5107
|
}
|
|
4824
5108
|
|
|
5109
|
+
// Builds the URL from the + dialog's fields, with a clear error for each bad input.
|
|
4825
5110
|
function newAgentUrl(host, port) {
|
|
4826
5111
|
host = host.trim();
|
|
4827
5112
|
if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.$/.test(host)) throw new Error('Enter the last number of the IP address.');
|
|
4828
5113
|
if (!host || /[\s/?#@]/.test(host)) throw new Error('Enter an IP address or hostname without a URL path.');
|
|
4829
5114
|
if (!/^\d+$/.test(port) || Number(port) < 1 || Number(port) > 65535) throw new Error('Enter a port from 1 to 65535.');
|
|
5115
|
+
// An IPv6 address needs brackets in a URL.
|
|
4830
5116
|
if (host.includes(':') && !host.startsWith('[')) host = `[${host}]`;
|
|
4831
5117
|
try {
|
|
4832
5118
|
const parsed = new URL(`http://${host}:${port}`);
|
|
@@ -4834,6 +5120,8 @@
|
|
|
4834
5120
|
} catch { throw new Error('Enter a valid IP address or hostname.'); }
|
|
4835
5121
|
}
|
|
4836
5122
|
|
|
5123
|
+
// Checks /hello and the key through the proxy, then saves and selects the agent.
|
|
5124
|
+
// The dialog stays open with an error when any check fails.
|
|
4837
5125
|
async function saveNewAgent() {
|
|
4838
5126
|
const button = document.getElementById('addAgentSubmit');
|
|
4839
5127
|
const cancel = document.getElementById('addAgentCancel');
|
|
@@ -4846,6 +5134,7 @@
|
|
|
4846
5134
|
button.disabled = cancel.disabled = true;
|
|
4847
5135
|
button.textContent = 'Saving…';
|
|
4848
5136
|
|
|
5137
|
+
// 1. The agent must answer /hello.
|
|
4849
5138
|
try {
|
|
4850
5139
|
const health = await piApiFetch(`${url}/hello`, { signal: AbortSignal.timeout(2000) });
|
|
4851
5140
|
if (!health.ok) throw new Error('Unreachable');
|
|
@@ -4853,6 +5142,7 @@
|
|
|
4853
5142
|
throw new Error('Agent unreachable. Please check the address and port and try again.');
|
|
4854
5143
|
}
|
|
4855
5144
|
|
|
5145
|
+
// 2. A key, when given, must be accepted by an authenticated route.
|
|
4856
5146
|
if (api_key) {
|
|
4857
5147
|
try {
|
|
4858
5148
|
const keyCheck = await piApiFetch(`${url}/config/read`, {
|
|
@@ -4867,6 +5157,7 @@
|
|
|
4867
5157
|
}
|
|
4868
5158
|
}
|
|
4869
5159
|
|
|
5160
|
+
// 3. Save it to the shared agent list, then select it.
|
|
4870
5161
|
const response = await fetch('/api/servers', {
|
|
4871
5162
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
4872
5163
|
body: JSON.stringify({ url, api_key })
|
|
@@ -4887,15 +5178,18 @@
|
|
|
4887
5178
|
|
|
4888
5179
|
/* ---------------- NETWORK SCAN SCRIPTS ---------------- */
|
|
4889
5180
|
|
|
5181
|
+
// Opens the scan window and starts a sweep.
|
|
4890
5182
|
function openScanModal() {
|
|
4891
5183
|
document.getElementById('scanModal').classList.add('open');
|
|
4892
5184
|
runNetworkScan();
|
|
4893
5185
|
}
|
|
4894
5186
|
|
|
5187
|
+
// Closes the scan window.
|
|
4895
5188
|
function closeScanModal() {
|
|
4896
5189
|
document.getElementById('scanModal').classList.remove('open');
|
|
4897
5190
|
}
|
|
4898
5191
|
|
|
5192
|
+
// Puts a URL's host and port into the connection fields.
|
|
4899
5193
|
function setAgentConnection(url) {
|
|
4900
5194
|
try {
|
|
4901
5195
|
const parsed = new URL(url);
|
|
@@ -4907,6 +5201,7 @@
|
|
|
4907
5201
|
onApiConfigChange();
|
|
4908
5202
|
}
|
|
4909
5203
|
|
|
5204
|
+
// Selects an agent found by a scan.
|
|
4910
5205
|
function selectScannedHost(url) {
|
|
4911
5206
|
setAgentConnection(url);
|
|
4912
5207
|
closeScanModal();
|
|
@@ -4922,6 +5217,8 @@
|
|
|
4922
5217
|
}
|
|
4923
5218
|
}
|
|
4924
5219
|
|
|
5220
|
+
// Reads a /api/scan answer: plain JSON, or NDJSON where each "agent" event is passed
|
|
5221
|
+
// to onAgent as it arrives and the "done" event is the result.
|
|
4925
5222
|
async function readScanResponse(response, onAgent) {
|
|
4926
5223
|
if (!response.headers?.get('content-type')?.includes('application/x-ndjson')) return response.json();
|
|
4927
5224
|
const reader = response.body.getReader();
|
|
@@ -4951,6 +5248,7 @@
|
|
|
4951
5248
|
} finally { reader.releaseLock(); }
|
|
4952
5249
|
}
|
|
4953
5250
|
|
|
5251
|
+
// Only one scan runs at a time.
|
|
4954
5252
|
let _scanInProgress = false;
|
|
4955
5253
|
// Startup selects the first response; later responses only update the choices.
|
|
4956
5254
|
async function runNetworkScan(autoPickFirst = false, onFirst = () => {}) {
|
|
@@ -4958,8 +5256,20 @@
|
|
|
4958
5256
|
_scanInProgress = true;
|
|
4959
5257
|
const revision = _connectionRevision;
|
|
4960
5258
|
let selectedFirst = false;
|
|
5259
|
+
// Adds or updates each agent as the scan reports it; with autoPickFirst the first one
|
|
5260
|
+
// is selected at once, unless the user changed the selection meanwhile.
|
|
4961
5261
|
const acceptAgent = agent => {
|
|
4962
|
-
if (
|
|
5262
|
+
if (agent.name) {
|
|
5263
|
+
_savedServersCache = _savedServersCache.filter(server =>
|
|
5264
|
+
server.url === agent.url || server.name !== agent.name
|
|
5265
|
+
);
|
|
5266
|
+
}
|
|
5267
|
+
const existingIdx = _savedServersCache.findIndex(server => server.url === agent.url);
|
|
5268
|
+
if (existingIdx !== -1) {
|
|
5269
|
+
_savedServersCache[existingIdx] = { ..._savedServersCache[existingIdx], ...agent };
|
|
5270
|
+
} else {
|
|
5271
|
+
_savedServersCache.push(agent);
|
|
5272
|
+
}
|
|
4963
5273
|
renderServersDropdown(_savedServersCache);
|
|
4964
5274
|
if (autoPickFirst && !selectedFirst) {
|
|
4965
5275
|
selectedFirst = true;
|
|
@@ -5019,7 +5329,7 @@
|
|
|
5019
5329
|
if (found.length === 0) {
|
|
5020
5330
|
resultsList.innerHTML = `<div class="scan-empty-state">No ctrlPi servers found on this network.</div>`;
|
|
5021
5331
|
} else if (autoPickFirst) {
|
|
5022
|
-
//
|
|
5332
|
+
// Pick the first agent here when no streamed event already selected one.
|
|
5023
5333
|
if (!selectedFirst) acceptAgent(_savedServersCache.find(server => server.url === found[0]) || { url: found[0], name: names[found[0]], api_key: 'your-secret-key' });
|
|
5024
5334
|
} else if (found.length === 1) {
|
|
5025
5335
|
statusEl.textContent = `Found 1 server - auto-selecting...`;
|
|
@@ -5049,6 +5359,7 @@
|
|
|
5049
5359
|
</script>
|
|
5050
5360
|
|
|
5051
5361
|
<!-- Network Scan Modal -->
|
|
5362
|
+
<!-- Network scan window: progress and the agents found -->
|
|
5052
5363
|
<div id="scanModal" class="scan-modal-overlay" onclick="if(event.target===this)closeScanModal()">
|
|
5053
5364
|
<div class="scan-modal">
|
|
5054
5365
|
<div class="scan-modal-header">
|