@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/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: OpenAPI Tester Panel & MCP Panel Layout */
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 / MCP Setup / API CURL) */
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-fill, minmax(200px, 1fr));
1044
- gap: 12px;
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: 10px;
1051
- padding: 14px 16px;
1093
+ border-radius: 12px;
1094
+ padding: 18px 20px;
1052
1095
  display: flex;
1053
1096
  flex-direction: column;
1054
- gap: 4px;
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.82rem;
1059
- font-weight: 600;
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.78rem;
1150
+ font-size: 0.82rem;
1065
1151
  color: var(--text-muted);
1066
- line-height: 1.5;
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: block; width: 100%; text-align: left; background: transparent; border: 0; white-space: normal; overflow-wrap: anywhere; line-height: 1.5; }
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 Endpoint definitions with comprehensive parameters configurations
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: 'Retrieve current global configuration parameters, including API, docs availability status, and Webhook target endpoint.'
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: 'Dynamically update app behaviors: rename the agent, enable/disable docs pages, enable/disable the REST API, or configure the Webhook endpoint. Fields may be sent ungrouped (as below) - the agent routes each into its group (notifications / settings / bridges).',
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:8316',
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:8317',
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 only honored on a real Pi or Pico; on Mac/dev machines it falls back to a process restart.',
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: 'Retrieve the last 50 log entries from the server.'
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
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 = (tabId === 'testerPane' || tabId === 'mcpPane') ? 'grid' : 'flex';
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
- // The webhook viewer always hits ctrlpi.com directly - the agent URL/port
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
- const INFO_PANELS = ['pinoutPanel', 'welcomePanel', 'apiCurlPanel'];
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
- function showPinout(src, label) {
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
- function closePinout() {
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
- // Groupings categories (rendered top-to-bottom: Config, GPIO, Sensors)
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
- // The Get Started panels (Welcome / MCP Setup / API cURL / Pinouts) live in the API
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 shared "Get Started" section (info panels + pinouts) into a sidebar list.
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 pinouts = [
3370
- {
3371
- label: 'Raspberry Pi Pinout', src: '/pages/Raspberry%20Pi%20Pinout.html',
3372
- icon: _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>')
3373
- },
3374
- {
3375
- label: 'Pico Pinout', src: '/pages/Pico%20Pinout.html',
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
- closePinout();
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
- // Pick an existing sensor into the config form: set the name and prefill the
3594
- // file/script body from its current config (like fetchAndPopulateGpioConfig for pins).
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 >= 2 && n <= 27;
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">Interesting choice. Unfortunately, reality caps out at 2–27.</p>' : ''}
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').style.display = value === 'input' ? '' : 'none';
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
- // Auto-convert string digits or null/flips into appropriate types if desired
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 = 1;
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: `Ensure your API server is running, and api is enabled, if not start it using python main.py --enable-api in your shell.`
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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("Are you sure you want to completely erase the server-side agent.log file?");
4711
+ const confirmReset = confirm("Erase the agent's log?");
4503
4712
  if (!confirmReset) return;
4504
4713
 
4505
- // The Pi has no dedicated clear endpoint - drive it the same way log_days=0
4506
- // does: read the current value, zero it (truncates immediately), then restore
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
- if (!response.ok) return;
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
- } catch { /* Offline or unauthorized: keep the agent selectable. */ }
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('button');
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
- item.innerHTML = `<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:#9ca3af;position:relative;top:1px">${server.url.replace(/^https?:\/\//, '')}</span>`;
4649
- item.onclick = () => pickSavedServer(server.url);
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 Pi (authenticated with the CURRENT key), and only
4742
- // switches the monitor over to the new key once the Pi confirms the change.
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 Pi…');
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(`Pi rejected the update (HTTP ${response.status}). Key was NOT changed. ${text.slice(0, 200)}`, 'error');
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
- // Pi accepted the new key - switch the monitor over to it.
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 Pi. The monitor is now using the new key.', 'success');
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 (!_savedServersCache.some(server => server.url === agent.url)) _savedServersCache.push(agent);
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
- // Compatibility with a server that still returns a single JSON response.
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">