node-red-contrib-alarm-ultimate 0.1.1 → 0.1.3

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.
@@ -53,6 +53,28 @@
53
53
  grid-template-columns: 1fr;
54
54
  }
55
55
 
56
+ body.view-keypad .grid,
57
+ body.view-zones .grid {
58
+ grid-template-columns: 1fr;
59
+ }
60
+
61
+ body.view-keypad #zonesCard {
62
+ display: none;
63
+ }
64
+
65
+ body.view-zones #keypadCard {
66
+ display: none;
67
+ }
68
+
69
+ body.view-keypad #logCard,
70
+ body.view-zones #logCard {
71
+ display: none;
72
+ }
73
+
74
+ body.view-log .grid {
75
+ display: none;
76
+ }
77
+
56
78
  header {
57
79
  padding: 16px;
58
80
  border-bottom: 1px solid var(--border);
@@ -143,11 +165,13 @@
143
165
  padding: 8px 10px;
144
166
  cursor: pointer;
145
167
  font-size: 12px;
168
+ transition: background-color 120ms ease, border-color 120ms ease, transform 80ms ease;
146
169
  }
147
170
 
148
171
  button.primary {
149
- border-color: rgba(110, 168, 254, 0.5);
150
- background: rgba(110, 168, 254, 0.22);
172
+ border-color: rgba(110, 168, 254, 0.85);
173
+ background: var(--accent);
174
+ color: #fff;
151
175
  }
152
176
 
153
177
  button.danger {
@@ -155,6 +179,62 @@
155
179
  background: rgba(255, 107, 107, 0.14);
156
180
  }
157
181
 
182
+ button:hover {
183
+ background: rgba(110, 168, 254, 0.18);
184
+ border-color: rgba(110, 168, 254, 0.35);
185
+ }
186
+
187
+ button.primary:hover {
188
+ filter: brightness(1.05);
189
+ }
190
+
191
+ button:active {
192
+ transform: translateY(1px);
193
+ }
194
+
195
+ button.selected {
196
+ border-color: rgba(110, 168, 254, 0.9);
197
+ background: rgba(110, 168, 254, 0.28);
198
+ }
199
+
200
+ button.selected.ok {
201
+ border-color: rgba(47, 191, 113, 0.65);
202
+ background: rgba(47, 191, 113, 0.16);
203
+ }
204
+
205
+ button.selected.warn {
206
+ border-color: rgba(255, 204, 102, 0.65);
207
+ background: rgba(255, 204, 102, 0.14);
208
+ }
209
+
210
+ button.selected.danger {
211
+ border-color: rgba(255, 107, 107, 0.65);
212
+ background: rgba(255, 107, 107, 0.14);
213
+ }
214
+
215
+ button.node {
216
+ display: inline-flex;
217
+ align-items: center;
218
+ gap: 8px;
219
+ }
220
+
221
+ button.node .dot {
222
+ width: 8px;
223
+ height: 8px;
224
+ border-radius: 999px;
225
+ background: var(--border);
226
+ }
227
+
228
+ button.node.ok .dot {
229
+ background: var(--ok);
230
+ }
231
+ button.node.warn .dot {
232
+ background: var(--warn);
233
+ }
234
+ button.node.danger .dot {
235
+ background: var(--danger);
236
+ }
237
+
158
238
  .status {
159
239
  border: 1px solid var(--border);
160
240
  border-radius: 10px;
@@ -180,6 +260,35 @@
180
260
  font-size: 12px;
181
261
  }
182
262
 
263
+ .status .meta .lines {
264
+ display: grid;
265
+ gap: 6px;
266
+ }
267
+
268
+ .status .meta .line {
269
+ display: grid;
270
+ grid-template-columns: 180px auto;
271
+ gap: 10px;
272
+ align-items: center;
273
+ }
274
+
275
+ .status .meta .line .name {
276
+ font-family: var(--mono);
277
+ font-size: 12px;
278
+ white-space: nowrap;
279
+ overflow: hidden;
280
+ text-overflow: ellipsis;
281
+ }
282
+
283
+ .status .meta .line .details {
284
+ font-family: var(--mono);
285
+ font-size: 11px;
286
+ color: var(--muted);
287
+ overflow: hidden;
288
+ text-overflow: ellipsis;
289
+ white-space: nowrap;
290
+ }
291
+
183
292
  .pill {
184
293
  font-family: var(--mono);
185
294
  font-size: 12px;
@@ -201,6 +310,11 @@
201
310
  color: var(--danger);
202
311
  }
203
312
 
313
+ .pill.small {
314
+ padding: 4px 8px;
315
+ font-size: 11px;
316
+ }
317
+
204
318
  table {
205
319
  width: 100%;
206
320
  border-collapse: collapse;
@@ -249,6 +363,11 @@
249
363
  font-size: 12px;
250
364
  margin: 8px 0 0 0;
251
365
  }
366
+
367
+ .log-details {
368
+ color: var(--muted);
369
+ font-size: 11px;
370
+ }
252
371
  </style>
253
372
  </head>
254
373
  <body>
@@ -258,29 +377,35 @@
258
377
  </header>
259
378
 
260
379
  <main>
261
- <section class="card">
262
- <h2>Node</h2>
263
- <div class="row">
264
- <label for="nodeSelect">Alarm node</label>
265
- <select id="nodeSelect"></select>
266
- </div>
267
- <div class="hint" id="nodeHint"></div>
268
- <div id="nodeStatus" class="status" style="display: none">
269
- <div class="left">
270
- <div class="title" id="statusTitle"></div>
271
- <div class="meta" id="statusMeta"></div>
272
- </div>
380
+ <section class="card">
381
+ <h2>Node</h2>
382
+ <div class="row">
383
+ <label>Alarm nodes</label>
384
+ <div>
385
+ <div class="buttons" id="nodeButtons"></div>
386
+ <div class="hint" id="nodeHint"></div>
387
+ <div class="hint" id="selectionHint"></div>
388
+ <div class="buttons" style="margin-top:8px;">
389
+ <button id="btnSelectAll" type="button">Select all</button>
390
+ </div>
391
+ </div>
392
+ </div>
393
+ <div id="nodeStatus" class="status" style="display: none">
394
+ <div class="left">
395
+ <div class="title" id="statusTitle"></div>
396
+ <div class="meta" id="statusMeta"></div>
397
+ </div>
273
398
  <div class="pill" id="statusPill"></div>
274
399
  </div>
275
400
  </section>
276
401
 
277
- <div class="grid">
278
- <section class="card">
279
- <h2>Keypad</h2>
280
- <div class="row">
281
- <label for="code">Code</label>
282
- <input id="code" type="password" autocomplete="one-time-code" placeholder="(optional)" />
283
- </div>
402
+ <div class="grid">
403
+ <section class="card" id="keypadCard" data-section="keypad">
404
+ <h2>Keypad</h2>
405
+ <div class="row">
406
+ <label for="code">Code</label>
407
+ <input id="code" type="password" autocomplete="one-time-code" placeholder="(optional)" />
408
+ </div>
284
409
  <div class="buttons">
285
410
  <button class="primary" id="btnArm">Arm</button>
286
411
  <button class="danger" id="btnDisarm">Disarm</button>
@@ -301,15 +426,20 @@
301
426
  </div>
302
427
  <p class="hint">Commands are sent to the selected node using the Node-RED admin HTTP endpoint.</p>
303
428
  <p class="hint" id="cmdStatus" style="display: none"></p>
304
- </section>
305
-
306
- <section class="card">
307
- <h2>Zones</h2>
308
- <div class="hint" id="zonesHint">Loading...</div>
309
- <div style="overflow: auto; margin-top: 10px">
310
- <table>
311
- <thead>
312
- <tr>
429
+ </section>
430
+
431
+ <section class="card" id="zonesCard" data-section="zones">
432
+ <h2>Zones</h2>
433
+ <div class="hint" id="zonesHint">Loading...</div>
434
+ <div class="buttons" style="margin-top:8px;">
435
+ <button id="btnZonesFilterOpen" type="button">OPEN</button>
436
+ <button id="btnZonesFilterAll" type="button">ALL</button>
437
+ </div>
438
+ <div style="overflow: auto; margin-top: 10px">
439
+ <table>
440
+ <thead>
441
+ <tr>
442
+ <th>Alarm</th>
313
443
  <th>Zone</th>
314
444
  <th>State</th>
315
445
  <th>Type</th>
@@ -321,40 +451,100 @@
321
451
  </div>
322
452
  </section>
323
453
  </div>
454
+
455
+ <section class="card" id="logCard" data-section="log">
456
+ <h2>Log</h2>
457
+ <div class="hint" id="logHint">Loading...</div>
458
+ <div class="buttons" style="margin-top:8px;">
459
+ <button id="btnLogFilterAll" type="button">ALL</button>
460
+ <button id="btnLogFilterAlarm" type="button">ALARM</button>
461
+ <button id="btnLogFilterArming" type="button">ARMING</button>
462
+ <button id="btnLogFilterZones" type="button">ZONES</button>
463
+ <button id="btnLogFilterErrors" type="button">ERRORS</button>
464
+ <button id="btnLogDownload" type="button">Download JSON</button>
465
+ </div>
466
+ <div style="overflow:auto; margin-top:10px;">
467
+ <table>
468
+ <thead>
469
+ <tr>
470
+ <th>Alarm</th>
471
+ <th>Time</th>
472
+ <th>Event</th>
473
+ <th>Details</th>
474
+ </tr>
475
+ </thead>
476
+ <tbody id="logBody"></tbody>
477
+ </table>
478
+ </div>
479
+ </section>
324
480
  </main>
325
481
 
326
482
  <script>
327
- const els = {
328
- nodeSelect: document.getElementById("nodeSelect"),
329
- nodeHint: document.getElementById("nodeHint"),
330
- nodeStatus: document.getElementById("nodeStatus"),
331
- statusTitle: document.getElementById("statusTitle"),
332
- statusMeta: document.getElementById("statusMeta"),
333
- statusPill: document.getElementById("statusPill"),
334
- zonesBody: document.getElementById("zonesBody"),
335
- zonesHint: document.getElementById("zonesHint"),
336
- code: document.getElementById("code"),
337
- cmdStatus: document.getElementById("cmdStatus"),
338
- btnArm: document.getElementById("btnArm"),
339
- btnDisarm: document.getElementById("btnDisarm"),
340
- };
483
+ const els = {
484
+ nodeButtons: document.getElementById("nodeButtons"),
485
+ nodeHint: document.getElementById("nodeHint"),
486
+ selectionHint: document.getElementById("selectionHint"),
487
+ btnSelectAll: document.getElementById("btnSelectAll"),
488
+ nodeStatus: document.getElementById("nodeStatus"),
489
+ statusTitle: document.getElementById("statusTitle"),
490
+ statusMeta: document.getElementById("statusMeta"),
491
+ statusPill: document.getElementById("statusPill"),
492
+ zonesBody: document.getElementById("zonesBody"),
493
+ zonesHint: document.getElementById("zonesHint"),
494
+ btnZonesFilterOpen: document.getElementById("btnZonesFilterOpen"),
495
+ btnZonesFilterAll: document.getElementById("btnZonesFilterAll"),
496
+ logBody: document.getElementById("logBody"),
497
+ logHint: document.getElementById("logHint"),
498
+ btnLogFilterAll: document.getElementById("btnLogFilterAll"),
499
+ btnLogFilterAlarm: document.getElementById("btnLogFilterAlarm"),
500
+ btnLogFilterArming: document.getElementById("btnLogFilterArming"),
501
+ btnLogFilterZones: document.getElementById("btnLogFilterZones"),
502
+ btnLogFilterErrors: document.getElementById("btnLogFilterErrors"),
503
+ btnLogDownload: document.getElementById("btnLogDownload"),
504
+ code: document.getElementById("code"),
505
+ cmdStatus: document.getElementById("cmdStatus"),
506
+ btnArm: document.getElementById("btnArm"),
507
+ btnDisarm: document.getElementById("btnDisarm"),
508
+ };
341
509
 
342
510
  const params = new URLSearchParams(window.location.search);
343
511
  const preselectId = params.get("id") || "";
344
512
  const embedMode = params.get("embed") === "1" || params.get("embed") === "true";
513
+ const view = (params.get("view") || "").toLowerCase();
345
514
 
346
515
  if (embedMode) {
347
516
  document.body.classList.add("embed");
348
517
  }
349
518
 
350
- let selectedId = "";
351
- let pollTimer = null;
519
+ if (view === "zones") {
520
+ document.body.classList.add("view-zones");
521
+ } else if (view === "keypad") {
522
+ document.body.classList.add("view-keypad");
523
+ } else if (view === "log") {
524
+ document.body.classList.add("view-log");
525
+ }
526
+
527
+ const showLog = view !== "zones" && view !== "keypad";
352
528
 
353
- let audioCtx = null;
354
- let armingBeepTimer = null;
355
- let hasSeenState = false;
356
- let lastMode = null;
357
- let lastArmingActive = null;
529
+ let nodesList = [];
530
+ let selectedIds = new Set();
531
+ let pollTimer = null;
532
+ let nodeStateTimer = null;
533
+ let nodeStateById = new Map();
534
+
535
+ let logFilter = "all"; // all | alarm | arming | zones | errors
536
+ let logsById = new Map(); // alarmId -> entries[]
537
+ let logLastTsById = new Map(); // alarmId -> last ts
538
+
539
+ let audioCtx = null;
540
+ let armingBeepTimer = null;
541
+ let hasSeenState = false;
542
+ let lastMode = null;
543
+ let lastArmingActive = null;
544
+ let lastZonesSnapshot = [];
545
+ let zonesFilter = "open"; // "open" | "all"
546
+ let zonesFilterUserSelected = false;
547
+ let zonesFilterInitialized = false;
358
548
 
359
549
  function getAudioContext() {
360
550
  if (audioCtx) return audioCtx;
@@ -547,24 +737,99 @@
547
737
  return { label: "DISARMED", cls: "" };
548
738
  }
549
739
 
550
- function renderZones(zones) {
551
- els.zonesBody.innerHTML = "";
552
- const list = Array.isArray(zones) ? zones : [];
553
- if (list.length === 0) {
554
- els.zonesHint.textContent = "No zones configured.";
555
- return;
740
+ function lastEventSummary(state) {
741
+ const log = state && Array.isArray(state.log) ? state.log : [];
742
+ const last = log.length ? log[log.length - 1] : null;
743
+ if (!last || typeof last !== "object") return "";
744
+ const e = typeof last.event === "string" ? last.event : "";
745
+ if (!e) return "";
746
+ if (e === "arm_blocked") {
747
+ const violations = Array.isArray(last.violations) ? last.violations : [];
748
+ const names = violations
749
+ .map((v) => (v && typeof v.name === "string" && v.name.trim() ? v.name.trim() : v && v.id ? String(v.id) : ""))
750
+ .filter(Boolean);
751
+ const shown = names.slice(0, 3).join(", ");
752
+ const suffix = names.length > 3 ? "…" : "";
753
+ return `arm_blocked${names.length ? ` (${shown}${suffix})` : ""}`;
754
+ }
755
+ if (e === "denied") {
756
+ const a = last.action ? String(last.action) : "";
757
+ return `denied${a ? ` (${a})` : ""}`;
758
+ }
759
+ if (e === "arming") {
760
+ const s = Number.isFinite(Number(last.seconds)) ? Number(last.seconds) : null;
761
+ return `arming${s !== null ? ` (${s}s)` : ""}`;
556
762
  }
557
- els.zonesHint.textContent = `${list.length} zones`;
763
+ if (e === "entry_delay") {
764
+ const s = Number.isFinite(Number(last.seconds)) ? Number(last.seconds) : null;
765
+ return `entry_delay${s !== null ? ` (${s}s)` : ""}`;
766
+ }
767
+ if (e === "alarm") {
768
+ const k = last.kind ? String(last.kind) : "";
769
+ return `alarm${k ? ` (${k})` : ""}`;
770
+ }
771
+ return e;
772
+ }
558
773
 
559
- for (const z of list) {
560
- const tr = document.createElement("tr");
561
- const title = z.name ? `${z.name}` : z.id;
562
- const stateText = z.bypassed ? "BYPASSED" : z.open ? "OPEN" : "CLOSED";
563
- const stateClass = z.bypassed ? "zone-bypassed" : z.open ? "zone-open" : "zone-closed";
564
- const topic = z.topic || z.topicPattern || "";
774
+ function armingErrorPill(state) {
775
+ const log = state && Array.isArray(state.log) ? state.log : [];
776
+ const last = log.length ? log[log.length - 1] : null;
777
+ const e = last && typeof last.event === "string" ? last.event : "";
778
+ if (e === "arm_blocked") {
779
+ return { cls: "warn", label: "ARM BLOCKED", details: lastEventSummary(state) };
780
+ }
781
+ if (e === "denied" && last && String(last.action || "") === "arm") {
782
+ return { cls: "danger", label: "ARM DENIED", details: lastEventSummary(state) };
783
+ }
784
+ return null;
785
+ }
786
+
787
+ function updateZonesFilterButtons() {
788
+ if (!els.btnZonesFilterOpen || !els.btnZonesFilterAll) return;
789
+ els.btnZonesFilterOpen.classList.toggle("selected", zonesFilter === "open");
790
+ els.btnZonesFilterAll.classList.toggle("selected", zonesFilter === "all");
791
+ }
792
+
793
+ function setZonesFilter(next, opts) {
794
+ const options = opts && typeof opts === "object" ? opts : {};
795
+ const user = options.user === true;
796
+ zonesFilter = String(next || "").toLowerCase() === "all" ? "all" : "open";
797
+ if (user) zonesFilterUserSelected = true;
798
+ updateZonesFilterButtons();
799
+ renderZones(lastZonesSnapshot);
800
+ }
801
+
802
+ function renderZones(zones) {
803
+ els.zonesBody.innerHTML = "";
804
+ const list = Array.isArray(zones) ? zones : [];
805
+ lastZonesSnapshot = list;
806
+ const filtered = zonesFilter === "open" ? list.filter((z) => z && z.open) : list;
807
+
808
+ if (list.length === 0) {
809
+ els.zonesHint.textContent = "No zones configured.";
810
+ return;
811
+ }
812
+ if (filtered.length === 0) {
813
+ els.zonesHint.textContent = "No OPEN zones.";
814
+ return;
815
+ }
816
+ if (zonesFilter === "open") {
817
+ els.zonesHint.textContent = `${filtered.length} OPEN zones (of ${list.length})`;
818
+ } else {
819
+ els.zonesHint.textContent = `${list.length} zones`;
820
+ }
821
+
822
+ for (const z of filtered) {
823
+ const tr = document.createElement("tr");
824
+ const title = z.name ? `${z.name}` : z.id;
825
+ const stateText = z.bypassed ? "BYPASSED" : z.open ? "OPEN" : "CLOSED";
826
+ const stateClass = z.bypassed ? "zone-bypassed" : z.open ? "zone-open" : "zone-closed";
827
+ const topic = z.topic || z.topicPattern || "";
828
+ const alarmLabel = z.__alarmName || z.__alarmId || "";
565
829
 
566
830
  tr.innerHTML = `
567
- <td>${escapeHtml(title)}<div style="color:var(--muted); font-size:11px">${escapeHtml(z.id || "")}</div></td>
831
+ <td>${escapeHtml(alarmLabel)}</td>
832
+ <td>${escapeHtml(title)}</td>
568
833
  <td class="${stateClass}">${escapeHtml(stateText)}</td>
569
834
  <td>${escapeHtml(z.type || "")}</td>
570
835
  <td>${escapeHtml(topic)}</td>
@@ -582,57 +847,527 @@
582
847
  .replace(/'/g, "&#039;");
583
848
  }
584
849
 
585
- async function loadNodes() {
586
- const res = await fetch(apiUrl("/alarm-ultimate/alarm/nodes"), {
587
- credentials: "same-origin",
588
- headers: { ...authHeaders() },
589
- });
590
- if (!res.ok) throw new Error(`Unable to load nodes (${res.status})`);
591
- const data = await res.json();
592
- const nodes = Array.isArray(data.nodes) ? data.nodes : [];
593
-
594
- els.nodeSelect.innerHTML = "";
595
- els.nodeSelect.disabled = nodes.length === 0;
596
- for (const n of nodes) {
597
- const opt = document.createElement("option");
598
- opt.value = n.id;
599
- opt.textContent = n.name ? `${n.name} (${n.id})` : n.id;
600
- els.nodeSelect.appendChild(opt);
850
+ function pad2(n) {
851
+ return String(Math.max(0, Math.trunc(Number(n) || 0))).padStart(2, "0");
852
+ }
853
+
854
+ function formatTs(ts) {
855
+ const t = Number(ts) || 0;
856
+ if (!t) return "";
857
+ const d = new Date(t);
858
+ return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(
859
+ d.getMinutes(),
860
+ )}:${pad2(d.getSeconds())}`;
861
+ }
862
+
863
+ function logGroupForEvent(e) {
864
+ const evt = String(e || "").toLowerCase();
865
+ if (evt === "alarm") return "alarm";
866
+ if (evt === "error" || evt === "denied") return "errors";
867
+ if (
868
+ evt === "arming" ||
869
+ evt === "armed" ||
870
+ evt === "disarmed" ||
871
+ evt === "entry_delay" ||
872
+ evt === "arm_blocked" ||
873
+ evt === "already_armed" ||
874
+ evt === "reset" ||
875
+ evt === "siren_on" ||
876
+ evt === "siren_off"
877
+ )
878
+ return "arming";
879
+ if (
880
+ evt === "bypassed" ||
881
+ evt === "unbypassed" ||
882
+ evt === "chime" ||
883
+ evt === "zone_open" ||
884
+ evt === "zone_close" ||
885
+ evt === "zone_ignored_exit" ||
886
+ evt === "zone_bypassed_trigger" ||
887
+ evt === "zone_restore"
888
+ )
889
+ return "zones";
890
+ return "all";
891
+ }
892
+
893
+ function eventPillClass(evt) {
894
+ const g = logGroupForEvent(evt);
895
+ if (g === "alarm") return "danger";
896
+ if (g === "errors") return "danger";
897
+ if (g === "arming") return "warn";
898
+ if (g === "zones") return "";
899
+ return "";
900
+ }
901
+
902
+ function eventDetails(entry) {
903
+ const e = entry && entry.event ? String(entry.event) : "";
904
+
905
+ if (e === "alarm") {
906
+ const k = entry && entry.kind ? String(entry.kind) : "";
907
+ const z = entry && entry.zone && typeof entry.zone === "object" ? entry.zone : null;
908
+ const zn = z && (z.name || z.id) ? String(z.name || z.id) : "";
909
+ const silent = entry && entry.silent === true ? "silent" : "";
910
+ return [k ? `kind=${k}` : "", zn ? `zone=${zn}` : "", silent].filter(Boolean).join(" • ");
911
+ }
912
+
913
+ if (e === "denied") {
914
+ const a = entry && entry.action ? String(entry.action) : "";
915
+ const t = entry && entry.target ? String(entry.target) : "";
916
+ return [a ? `action=${a}` : "", t ? `target=${t}` : ""].filter(Boolean).join(" • ");
917
+ }
918
+
919
+ if (e === "error") {
920
+ const err = entry && entry.error ? String(entry.error) : "";
921
+ const z = entry && entry.zone ? String(entry.zone) : "";
922
+ return [err ? `error=${err}` : "", z ? `zone=${z}` : ""].filter(Boolean).join(" • ");
923
+ }
924
+
925
+ if (e === "arm_blocked") {
926
+ const v = Array.isArray(entry && entry.violations) ? entry.violations : [];
927
+ if (!v.length) return "violations";
928
+ const top = v
929
+ .slice(0, 3)
930
+ .map((x) => (x && typeof x === "object" ? x.id || x.name || x.zone || "" : ""))
931
+ .filter(Boolean)
932
+ .join(", ");
933
+ return `violations=${v.length}${top ? ` (${top}${v.length > 3 ? ", …" : ""})` : ""}`;
934
+ }
935
+
936
+ if (e === "arming" || e === "entry_delay") {
937
+ const s = Number.isFinite(Number(entry && entry.seconds)) ? Number(entry.seconds) : null;
938
+ const z = entry && entry.zone && typeof entry.zone === "object" ? entry.zone : null;
939
+ const zn = z && (z.name || z.id) ? String(z.name || z.id) : "";
940
+ return [s !== null ? `seconds=${s}` : "", zn ? `zone=${zn}` : ""].filter(Boolean).join(" • ");
941
+ }
942
+
943
+ if (e === "armed" || e === "disarmed") {
944
+ const r = entry && entry.reason ? String(entry.reason) : "";
945
+ const d = entry && entry.duress === true ? "duress" : "";
946
+ return [r ? `reason=${r}` : "", d].filter(Boolean).join(" • ");
947
+ }
948
+
949
+ if (e === "siren_on" || e === "siren_off") {
950
+ const r = entry && entry.reason ? String(entry.reason) : "";
951
+ return r ? `reason=${r}` : "";
952
+ }
953
+
954
+ if (
955
+ e === "bypassed" ||
956
+ e === "unbypassed" ||
957
+ e === "chime" ||
958
+ e === "zone_open" ||
959
+ e === "zone_close" ||
960
+ e === "zone_ignored_exit" ||
961
+ e === "zone_bypassed_trigger" ||
962
+ e === "zone_restore"
963
+ ) {
964
+ const z = entry && entry.zone && typeof entry.zone === "object" ? entry.zone : null;
965
+ const zn = z && (z.name || z.id) ? String(z.name || z.id) : "";
966
+ const b = entry && entry.bypassed === true ? "bypassed" : "";
967
+ return [zn ? `zone=${zn}` : "", b].filter(Boolean).join(" • ");
968
+ }
969
+
970
+ try {
971
+ const copy = { ...(entry || {}) };
972
+ delete copy.ts;
973
+ delete copy.event;
974
+ const keys = Object.keys(copy);
975
+ if (!keys.length) return "";
976
+ const txt = JSON.stringify(copy);
977
+ return txt.length > 220 ? `${txt.slice(0, 220)}…` : txt;
978
+ } catch (_err) {
979
+ return "";
980
+ }
981
+ }
982
+
983
+ function updateLogFilterButtons() {
984
+ const map = [
985
+ ["all", els.btnLogFilterAll],
986
+ ["alarm", els.btnLogFilterAlarm],
987
+ ["arming", els.btnLogFilterArming],
988
+ ["zones", els.btnLogFilterZones],
989
+ ["errors", els.btnLogFilterErrors],
990
+ ];
991
+ map.forEach(([key, el]) => {
992
+ if (!el) return;
993
+ el.classList.toggle("selected", logFilter === key);
994
+ });
995
+ }
996
+
997
+ function setLogFilter(next) {
998
+ const v = String(next || "").toLowerCase();
999
+ logFilter = ["all", "alarm", "arming", "zones", "errors"].includes(v) ? v : "all";
1000
+ updateLogFilterButtons();
1001
+ renderLogs();
1002
+ }
1003
+
1004
+ function resetLogTracking() {
1005
+ logsById = new Map();
1006
+ logLastTsById = new Map();
1007
+ }
1008
+
1009
+ async function fetchLogForNode(nodeId, since) {
1010
+ const qs = new URLSearchParams();
1011
+ qs.set("limit", "200");
1012
+ if (Number.isFinite(Number(since)) && Number(since) > 0) {
1013
+ qs.set("since", String(Number(since)));
1014
+ }
1015
+ const res = await fetch(apiUrl(`/alarm-ultimate/alarm/${encodeURIComponent(nodeId)}/log?${qs.toString()}`), {
1016
+ credentials: "same-origin",
1017
+ headers: { ...authHeaders() },
1018
+ });
1019
+ if (!res.ok) throw new Error(`Unable to load log (${res.status})`);
1020
+ return res.json();
1021
+ }
1022
+
1023
+ async function loadLogs(okStates) {
1024
+ if (!showLog) return;
1025
+ if (!els.logBody || !els.logHint) return;
1026
+ const ids = Array.from(selectedIds);
1027
+ if (!ids.length) return;
1028
+
1029
+ const nameById = new Map(
1030
+ (Array.isArray(okStates) ? okStates : []).map((x) => [x.id, (x.data && x.data.name) || x.id]),
1031
+ );
1032
+
1033
+ const results = await Promise.allSettled(
1034
+ ids.map(async (id) => {
1035
+ const since = logLastTsById.has(id) ? logLastTsById.get(id) : null;
1036
+ const data = await fetchLogForNode(id, since);
1037
+ return { id, data };
1038
+ }),
1039
+ );
1040
+
1041
+ for (const r of results) {
1042
+ if (r.status !== "fulfilled") continue;
1043
+ const id = r.value.id;
1044
+ const payload = r.value.data && typeof r.value.data === "object" ? r.value.data : {};
1045
+ const entries = Array.isArray(payload.log) ? payload.log : [];
1046
+
1047
+ const prev = logsById.get(id) || [];
1048
+ const next = logLastTsById.has(id) ? prev.concat(entries) : entries;
1049
+ logsById.set(id, next);
1050
+
1051
+ const maxTs = next.reduce((m, e) => Math.max(m, Number(e && e.ts) || 0), 0);
1052
+ logLastTsById.set(id, maxTs);
1053
+ }
1054
+
1055
+ renderLogs(nameById);
1056
+ }
1057
+
1058
+ function renderLogs(nameById) {
1059
+ if (!els.logBody || !els.logHint) return;
1060
+ const ids = Array.from(selectedIds);
1061
+ const nameMap = nameById instanceof Map ? nameById : new Map(ids.map((id) => [id, id]));
1062
+
1063
+ const all = [];
1064
+ ids.forEach((id) => {
1065
+ const alarmName = nameMap.get(id) || id;
1066
+ const list = logsById.get(id) || [];
1067
+ list.forEach((e) => all.push({ ...(e || {}), __alarmId: id, __alarmName: alarmName }));
1068
+ });
1069
+
1070
+ const filtered =
1071
+ logFilter === "all" ? all : all.filter((e) => logGroupForEvent(e && e.event) === logFilter);
1072
+
1073
+ filtered.sort((a, b) => (Number(b.ts) || 0) - (Number(a.ts) || 0));
1074
+ const show = filtered.slice(0, 200);
1075
+
1076
+ if (!show.length) {
1077
+ els.logHint.textContent = "No log entries yet.";
1078
+ els.logBody.innerHTML = "";
1079
+ return;
1080
+ }
1081
+
1082
+ els.logHint.textContent = `Showing ${show.length} entr${show.length === 1 ? "y" : "ies"}${
1083
+ logFilter !== "all" ? ` (${logFilter})` : ""
1084
+ }.`;
1085
+ els.logBody.innerHTML = "";
1086
+
1087
+ for (const entry of show) {
1088
+ const tr = document.createElement("tr");
1089
+ const alarmLabel = entry.__alarmName || entry.__alarmId || "";
1090
+ const evt = entry && entry.event ? String(entry.event) : "";
1091
+ const cls = eventPillClass(evt);
1092
+ const details = eventDetails(entry);
1093
+
1094
+ tr.innerHTML = `
1095
+ <td>${escapeHtml(alarmLabel)}</td>
1096
+ <td>${escapeHtml(formatTs(entry.ts))}</td>
1097
+ <td><span class="pill small ${cls}">${escapeHtml(evt)}</span></td>
1098
+ <td class="log-details">${escapeHtml(details)}</td>
1099
+ `;
1100
+ els.logBody.appendChild(tr);
1101
+ }
1102
+ }
1103
+
1104
+ function normalizePreselectIds(raw) {
1105
+ const v = String(raw || "").trim();
1106
+ if (!v) return [];
1107
+ if (v.includes(",")) {
1108
+ return v
1109
+ .split(",")
1110
+ .map((s) => s.trim())
1111
+ .filter(Boolean);
1112
+ }
1113
+ return [v];
1114
+ }
1115
+
1116
+ function persistSelectedIds() {
1117
+ try {
1118
+ localStorage.setItem("alarm-ultimate-panel:selectedIds", JSON.stringify(Array.from(selectedIds)));
1119
+ } catch (_err) {}
1120
+ }
1121
+
1122
+ function restoreSelectedIds() {
1123
+ const idsFromUrl = normalizePreselectIds(preselectId);
1124
+ if (idsFromUrl.length) return idsFromUrl;
1125
+ try {
1126
+ const raw = localStorage.getItem("alarm-ultimate-panel:selectedIds");
1127
+ const parsed = JSON.parse(raw);
1128
+ if (Array.isArray(parsed)) return parsed.map(String).filter(Boolean);
1129
+ } catch (_err) {}
1130
+ return [];
601
1131
  }
602
1132
 
603
- const preferred = nodes.find((n) => n.id === preselectId) ? preselectId : nodes[0] ? nodes[0].id : "";
604
- selectedId = preferred;
605
- els.nodeSelect.value = preferred;
606
- if (!preferred) {
607
- els.zonesHint.textContent = "No Alarm nodes found.";
608
- setNodeHint("No Alarm nodes found. Deploy your flow and refresh.");
609
- } else {
1133
+ function updateSelectAllVisibility() {
1134
+ if (!els.btnSelectAll) return;
1135
+ const container = els.btnSelectAll.closest(".buttons") || els.btnSelectAll;
1136
+ container.style.display = nodesList.length > 1 ? "" : "none";
1137
+ }
1138
+
1139
+ function statusFromStateSafe(state) {
1140
+ const st = statusFromState(state);
1141
+ return st && typeof st === "object" ? st : { label: "Unknown", cls: "" };
1142
+ }
1143
+
1144
+ function updateSelectionHint() {
1145
+ const count = selectedIds.size;
1146
+ if (!els.selectionHint) return;
1147
+ if (count === 0) {
1148
+ els.selectionHint.textContent = "Select at least one Alarm node.";
1149
+ return;
1150
+ }
1151
+ els.selectionHint.innerHTML = `Selected: <span class="pill small">${count}</span> (click buttons to add/remove)`;
1152
+ }
1153
+
1154
+ function setSelectedIds(next) {
1155
+ const ids = Array.isArray(next) ? next.map(String).filter(Boolean) : [];
1156
+ selectedIds = new Set(ids);
1157
+ // enforce non-empty selection when possible
1158
+ if (selectedIds.size === 0 && nodesList[0]) {
1159
+ selectedIds.add(nodesList[0].id);
1160
+ }
1161
+ persistSelectedIds();
1162
+ resetStateAudioTracking();
1163
+ resetLogTracking();
1164
+ updateSelectionHint();
1165
+ renderNodeButtons();
1166
+ loadState().catch(() => {});
1167
+ }
1168
+
1169
+ function selectAll() {
1170
+ if (!nodesList.length) return;
1171
+ setSelectedIds(nodesList.map((n) => n.id));
1172
+ }
1173
+
1174
+ function toggleSelectedId(id) {
1175
+ const key = String(id || "").trim();
1176
+ if (!key) return;
1177
+ const next = new Set(selectedIds);
1178
+ if (next.has(key)) {
1179
+ if (next.size === 1) return; // never allow empty
1180
+ next.delete(key);
1181
+ } else {
1182
+ next.add(key);
1183
+ }
1184
+ setSelectedIds(Array.from(next));
1185
+ }
1186
+
1187
+ function renderNodeButtons() {
1188
+ if (!els.nodeButtons) return;
1189
+ updateSelectAllVisibility();
1190
+ els.nodeButtons.innerHTML = "";
1191
+ if (!nodesList.length) return;
1192
+
1193
+ nodesList.forEach((n) => {
1194
+ const state = nodeStateById.get(n.id);
1195
+ const st = statusFromStateSafe(state);
1196
+ const btn = document.createElement("button");
1197
+ btn.type = "button";
1198
+ btn.className = `node ${st.cls || ""} ${selectedIds.has(n.id) ? `selected ${st.cls || ""}` : ""}`.trim();
1199
+ btn.dataset.id = n.id;
1200
+ const name = n.name ? n.name : n.id;
1201
+ btn.innerHTML = `<span class="dot"></span><span>${escapeHtml(name)}</span>`;
1202
+ btn.addEventListener("click", () => toggleSelectedId(n.id));
1203
+ els.nodeButtons.appendChild(btn);
1204
+ });
1205
+ }
1206
+
1207
+ async function loadNodes() {
1208
+ const res = await fetch(apiUrl("/alarm-ultimate/alarm/nodes"), {
1209
+ credentials: "same-origin",
1210
+ headers: { ...authHeaders() },
1211
+ });
1212
+ if (!res.ok) throw new Error(`Unable to load nodes (${res.status})`);
1213
+ const data = await res.json();
1214
+ nodesList = Array.isArray(data.nodes) ? data.nodes : [];
1215
+ updateSelectAllVisibility();
1216
+ if (!nodesList.length) {
1217
+ els.zonesHint.textContent = "No Alarm nodes found.";
1218
+ setNodeHint("No Alarm nodes found. Deploy your flow and refresh.");
1219
+ renderNodeButtons();
1220
+ updateSelectionHint();
1221
+ } else {
610
1222
  setNodeHint("");
1223
+ const restored = restoreSelectedIds().filter((id) => nodesList.some((n) => n.id === id));
1224
+ setSelectedIds(restored.length ? restored : [nodesList[0].id]);
611
1225
  }
612
1226
  }
613
1227
 
614
- async function loadState() {
615
- if (!selectedId) return;
616
- const res = await fetch(apiUrl(`/alarm-ultimate/alarm/${encodeURIComponent(selectedId)}/state`), {
617
- credentials: "same-origin",
618
- headers: { ...authHeaders() },
619
- });
620
- if (!res.ok) throw new Error(`Unable to load state (${res.status})`);
621
- const data = await res.json();
622
- const state = data.state;
623
- const st = statusFromState(state);
624
-
625
- els.nodeStatus.style.display = "";
626
- els.statusTitle.textContent = data.name ? data.name : selectedId;
627
- const ct = data.controlTopic ? `controlTopic=${data.controlTopic}` : "";
628
- const openCount = (Array.isArray(data.zones) ? data.zones.filter((z) => z.open && !z.bypassed).length : 0) || 0;
629
- els.statusMeta.textContent = `${st.label}${ct ? " • " + ct : ""}${openCount ? " • open=" + openCount : ""}`;
630
- els.statusPill.textContent = st.label;
631
- els.statusPill.className = `pill ${st.cls}`;
632
-
633
- renderZones(data.zones);
634
- handleStateBeeps(state);
635
- }
1228
+ async function fetchStateForNode(nodeId) {
1229
+ const res = await fetch(apiUrl(`/alarm-ultimate/alarm/${encodeURIComponent(nodeId)}/state`), {
1230
+ credentials: "same-origin",
1231
+ headers: { ...authHeaders() },
1232
+ });
1233
+ if (!res.ok) throw new Error(`Unable to load state (${res.status})`);
1234
+ return res.json();
1235
+ }
1236
+
1237
+ async function loadNodeStatesForButtons() {
1238
+ if (!nodesList.length) return;
1239
+ const ids = nodesList.map((n) => n.id);
1240
+ const results = await Promise.allSettled(ids.map((id) => fetchStateForNode(id)));
1241
+ for (let i = 0; i < results.length; i += 1) {
1242
+ const id = ids[i];
1243
+ const r = results[i];
1244
+ if (r.status === "fulfilled") {
1245
+ nodeStateById.set(id, r.value && r.value.state ? r.value.state : null);
1246
+ }
1247
+ }
1248
+ renderNodeButtons();
1249
+ }
1250
+
1251
+ async function loadState() {
1252
+ const ids = Array.from(selectedIds);
1253
+ if (!ids.length) return;
1254
+
1255
+ const results = await Promise.allSettled(ids.map((id) => fetchStateForNode(id)));
1256
+ const okStates = [];
1257
+ for (let i = 0; i < results.length; i += 1) {
1258
+ const id = ids[i];
1259
+ const r = results[i];
1260
+ if (r.status === "fulfilled") {
1261
+ okStates.push({ id, data: r.value });
1262
+ nodeStateById.set(id, r.value && r.value.state ? r.value.state : null);
1263
+ }
1264
+ }
1265
+
1266
+ renderNodeButtons();
1267
+ updateSelectionHint();
1268
+
1269
+ if (okStates.length === 0) {
1270
+ els.nodeStatus.style.display = "none";
1271
+ els.zonesHint.textContent = "Unable to load selected alarm state.";
1272
+ return;
1273
+ }
1274
+
1275
+ // Combine status and zones.
1276
+ const zones = [];
1277
+ okStates.forEach(({ id, data }) => {
1278
+ const alarmName = data && data.name ? data.name : id;
1279
+ const list = Array.isArray(data && data.zones) ? data.zones : [];
1280
+ list.forEach((z) => zones.push({ ...z, __alarmId: id, __alarmName: alarmName }));
1281
+ });
1282
+
1283
+ zones.sort((a, b) => {
1284
+ const an = String(a.__alarmName || "").localeCompare(String(b.__alarmName || ""));
1285
+ if (an !== 0) return an;
1286
+ return String(a.name || a.id || "").localeCompare(String(b.name || b.id || ""));
1287
+ });
1288
+
1289
+ const multiple = okStates.length > 1;
1290
+ if (multiple) {
1291
+ stopArmingBeeps();
1292
+ }
1293
+
1294
+ els.nodeStatus.style.display = "";
1295
+ els.statusTitle.textContent = multiple
1296
+ ? `Selected alarms (${okStates.length})`
1297
+ : okStates[0].data && okStates[0].data.name
1298
+ ? okStates[0].data.name
1299
+ : okStates[0].id;
1300
+
1301
+ if (multiple) {
1302
+ const lines = okStates
1303
+ .map(({ id, data }) => {
1304
+ const state = data && data.state ? data.state : null;
1305
+ const st = statusFromStateSafe(state);
1306
+ const name = data && data.name ? data.name : id;
1307
+ const openCount = Array.isArray(data && data.zones)
1308
+ ? data.zones.filter((z) => z && z.open && !z.bypassed).length
1309
+ : 0;
1310
+ const lastEvt = lastEventSummary(state);
1311
+ const armErr = armingErrorPill(state);
1312
+ const details = [
1313
+ `state=${st.label}`,
1314
+ openCount ? `open=${openCount}` : "",
1315
+ lastEvt ? `last=${lastEvt}` : "",
1316
+ ]
1317
+ .filter(Boolean)
1318
+ .join(" • ");
1319
+
1320
+ return `
1321
+ <div class="line">
1322
+ <div class="name">${escapeHtml(name)}</div>
1323
+ <div class="details">
1324
+ <span class="pill small ${st.cls || ""}">${escapeHtml(st.label)}</span>
1325
+ ${
1326
+ armErr
1327
+ ? ` <span class="pill small ${armErr.cls}">${escapeHtml(armErr.label)}</span>`
1328
+ : ""
1329
+ }
1330
+ ${details ? `&nbsp;${escapeHtml(details)}` : ""}
1331
+ </div>
1332
+ </div>
1333
+ `;
1334
+ })
1335
+ .join("");
1336
+
1337
+ els.statusMeta.innerHTML = `<div class="lines">${lines}</div>`;
1338
+ els.statusPill.textContent = "MULTI";
1339
+ els.statusPill.className = "pill warn";
1340
+ } else {
1341
+ const state = okStates[0].data && okStates[0].data.state ? okStates[0].data.state : null;
1342
+ const st = statusFromStateSafe(state);
1343
+ const openCount = zones.filter((z) => z.open && !z.bypassed).length;
1344
+ const lastEvt = lastEventSummary(state);
1345
+ const armErr = armingErrorPill(state);
1346
+ const meta = [`${st.label}`, openCount ? `open=${openCount}` : "", lastEvt ? `last=${lastEvt}` : ""]
1347
+ .filter(Boolean)
1348
+ .join(" • ");
1349
+ els.statusMeta.textContent = meta;
1350
+ els.statusPill.textContent = st.label;
1351
+ els.statusPill.className = `pill ${st.cls}`;
1352
+ if (armErr && els.statusPill) {
1353
+ els.statusPill.textContent = armErr.label;
1354
+ els.statusPill.className = `pill ${armErr.cls}`;
1355
+ }
1356
+ }
1357
+
1358
+ if (!zonesFilterInitialized && !zonesFilterUserSelected) {
1359
+ zonesFilter = zones.some((z) => z && z.open) ? "open" : "all";
1360
+ zonesFilterInitialized = true;
1361
+ updateZonesFilterButtons();
1362
+ }
1363
+ renderZones(zones);
1364
+ if (!multiple) {
1365
+ handleStateBeeps(okStates[0].data && okStates[0].data.state);
1366
+ }
1367
+ if (showLog) {
1368
+ await loadLogs(okStates);
1369
+ }
1370
+ }
636
1371
 
637
1372
  function startPolling() {
638
1373
  if (pollTimer) clearInterval(pollTimer);
@@ -643,18 +1378,85 @@
643
1378
  }, 1000);
644
1379
  }
645
1380
 
646
- async function sendCommand(payload) {
647
- if (!selectedId) return;
648
- const res = await fetch(apiUrl(`/alarm-ultimate/alarm/${encodeURIComponent(selectedId)}/command`), {
649
- method: "POST",
650
- headers: { "content-type": "application/json", ...authHeaders() },
651
- body: JSON.stringify(payload),
652
- credentials: "same-origin",
653
- });
654
- if (!res.ok) {
655
- const text = await res.text();
656
- throw new Error(`Command failed (${res.status}): ${text}`);
1381
+ function startNodeButtonPolling() {
1382
+ if (nodeStateTimer) clearInterval(nodeStateTimer);
1383
+ nodeStateTimer = setInterval(() => {
1384
+ loadNodeStatesForButtons().catch(() => {});
1385
+ }, 2500);
1386
+ }
1387
+
1388
+ function extractResultState(json) {
1389
+ const root = json && typeof json === "object" ? json : null;
1390
+ const result = root && root.result && typeof root.result === "object" ? root.result : null;
1391
+ return result && result.state ? result.state : null;
1392
+ }
1393
+
1394
+ function extractResultName(json, fallbackId) {
1395
+ const root = json && typeof json === "object" ? json : null;
1396
+ const result = root && root.result && typeof root.result === "object" ? root.result : null;
1397
+ return (result && result.name) || fallbackId || "";
1398
+ }
1399
+
1400
+ function isArmSuccess(state) {
1401
+ if (!state) return false;
1402
+ if (state.mode === "armed") return true;
1403
+ return Boolean(state.arming && state.arming.active);
1404
+ }
1405
+
1406
+ function isDisarmSuccess(state) {
1407
+ if (!state) return false;
1408
+ return state.mode === "disarmed";
657
1409
  }
1410
+
1411
+ async function sendCommand(payload) {
1412
+ const ids = Array.from(selectedIds);
1413
+ if (!ids.length) return;
1414
+ const results = await Promise.allSettled(
1415
+ ids.map(async (id) => {
1416
+ const res = await fetch(apiUrl(`/alarm-ultimate/alarm/${encodeURIComponent(id)}/command`), {
1417
+ method: "POST",
1418
+ headers: { "content-type": "application/json", ...authHeaders() },
1419
+ body: JSON.stringify(payload),
1420
+ credentials: "same-origin",
1421
+ });
1422
+ if (!res.ok) {
1423
+ const text = await res.text();
1424
+ throw new Error(`(${id}) ${res.status}: ${text}`);
1425
+ }
1426
+ const json = await res.json().catch(() => null);
1427
+ const state = extractResultState(json);
1428
+ if (state) {
1429
+ nodeStateById.set(id, state);
1430
+ }
1431
+ return {
1432
+ id,
1433
+ name: extractResultName(json, id),
1434
+ state,
1435
+ };
1436
+ }),
1437
+ );
1438
+ const ok = results.filter((r) => r.status === "fulfilled").map((r) => r.value);
1439
+ const err = results
1440
+ .filter((r) => r.status === "rejected")
1441
+ .map((r) => (r.reason && r.reason.message ? r.reason.message : String(r.reason)));
1442
+
1443
+ // Heuristic feedback: HTTP 200 doesn't mean the Alarm accepted the command.
1444
+ const cmd = payload && typeof payload.command === "string" ? payload.command : "";
1445
+ const check = cmd === "disarm" ? isDisarmSuccess : cmd === "arm" ? isArmSuccess : null;
1446
+ const okApplied = check ? ok.filter((r) => check(r && r.state)).map((r) => r.name || r.id) : [];
1447
+ const maybeRejected = check ? ok.filter((r) => !check(r && r.state)).map((r) => r.name || r.id) : [];
1448
+
1449
+ renderNodeButtons();
1450
+
1451
+ if (err.length) {
1452
+ throw new Error(`Sent to ${ok.length}/${ids.length}. ${err[0]}`);
1453
+ }
1454
+ if (check && maybeRejected.length) {
1455
+ showCmdStatus(
1456
+ `${cmd} sent to ${ids.length}. Applied: ${okApplied.length}/${ids.length}. Not applied: ${maybeRejected.slice(0, 3).join(", ")}${maybeRejected.length > 3 ? "…" : ""}`,
1457
+ "warn",
1458
+ );
1459
+ }
658
1460
  }
659
1461
 
660
1462
  function codeValue() {
@@ -662,17 +1464,63 @@
662
1464
  return v.length ? v : undefined;
663
1465
  }
664
1466
 
665
- els.nodeSelect.addEventListener("change", () => {
666
- selectedId = els.nodeSelect.value;
667
- resetStateAudioTracking();
668
- loadState().catch(() => {});
669
- });
670
-
671
- els.btnArm.addEventListener("click", async () => {
672
- playKeyClick("action");
673
- try {
674
- await sendCommand({ command: "arm", code: codeValue() });
675
- showCmdStatus("Arm sent.", "ok");
1467
+ // No select: buttons manage selection.
1468
+ if (els.btnSelectAll) {
1469
+ els.btnSelectAll.addEventListener("click", () => selectAll());
1470
+ }
1471
+ if (els.btnZonesFilterOpen) {
1472
+ els.btnZonesFilterOpen.addEventListener("click", () => setZonesFilter("open", { user: true }));
1473
+ }
1474
+ if (els.btnZonesFilterAll) {
1475
+ els.btnZonesFilterAll.addEventListener("click", () => setZonesFilter("all", { user: true }));
1476
+ }
1477
+ if (els.btnLogFilterAll) {
1478
+ els.btnLogFilterAll.addEventListener("click", () => setLogFilter("all"));
1479
+ }
1480
+ if (els.btnLogFilterAlarm) {
1481
+ els.btnLogFilterAlarm.addEventListener("click", () => setLogFilter("alarm"));
1482
+ }
1483
+ if (els.btnLogFilterArming) {
1484
+ els.btnLogFilterArming.addEventListener("click", () => setLogFilter("arming"));
1485
+ }
1486
+ if (els.btnLogFilterZones) {
1487
+ els.btnLogFilterZones.addEventListener("click", () => setLogFilter("zones"));
1488
+ }
1489
+ if (els.btnLogFilterErrors) {
1490
+ els.btnLogFilterErrors.addEventListener("click", () => setLogFilter("errors"));
1491
+ }
1492
+ if (els.btnLogDownload) {
1493
+ els.btnLogDownload.addEventListener("click", () => {
1494
+ const ids = Array.from(selectedIds);
1495
+ const out = [];
1496
+ ids.forEach((id) => {
1497
+ const alarmName = nodesList.find((n) => n && n.id === id)?.name || id;
1498
+ const list = logsById.get(id) || [];
1499
+ list.forEach((e) => out.push({ ...(e || {}), alarmId: id, alarmName }));
1500
+ });
1501
+ out.sort((a, b) => (Number(a.ts) || 0) - (Number(b.ts) || 0));
1502
+ const blob = new Blob([JSON.stringify(out, null, 2)], { type: "application/json" });
1503
+ const a = document.createElement("a");
1504
+ a.href = URL.createObjectURL(blob);
1505
+ a.download = `alarm-log-${new Date().toISOString().replace(/[:.]/g, "-")}.json`;
1506
+ document.body.appendChild(a);
1507
+ a.click();
1508
+ setTimeout(() => {
1509
+ try {
1510
+ URL.revokeObjectURL(a.href);
1511
+ a.remove();
1512
+ } catch (_err) {}
1513
+ }, 0);
1514
+ });
1515
+ }
1516
+
1517
+ els.btnArm.addEventListener("click", async () => {
1518
+ playKeyClick("action");
1519
+ try {
1520
+ await sendCommand({ command: "arm", code: codeValue() });
1521
+ if (els.cmdStatus.style.display === "none") {
1522
+ showCmdStatus(`Arm sent to ${selectedIds.size} node(s).`, "ok");
1523
+ }
676
1524
  } catch (err) {
677
1525
  showCmdStatus(err.message, "err");
678
1526
  }
@@ -681,7 +1529,9 @@
681
1529
  playKeyClick("action");
682
1530
  try {
683
1531
  await sendCommand({ command: "disarm", code: codeValue() });
684
- showCmdStatus("Disarm sent.", "ok");
1532
+ if (els.cmdStatus.style.display === "none") {
1533
+ showCmdStatus(`Disarm sent to ${selectedIds.size} node(s).`, "ok");
1534
+ }
685
1535
  } catch (err) {
686
1536
  showCmdStatus(err.message, "err");
687
1537
  }
@@ -712,12 +1562,18 @@
712
1562
  else if (/^\d$/.test(e.key)) playKeyClick("key");
713
1563
  });
714
1564
 
715
- (async function init() {
716
- try {
717
- setNodeHint(`API root: ${httpAdminRoot()}`);
718
- await loadNodes();
719
- await loadState();
720
- startPolling();
1565
+ (async function init() {
1566
+ try {
1567
+ setNodeHint(`API root: ${httpAdminRoot()}`);
1568
+ updateLogFilterButtons();
1569
+ if (showLog && els.logHint) {
1570
+ els.logHint.textContent = "Loading...";
1571
+ }
1572
+ await loadNodes();
1573
+ await loadNodeStatesForButtons().catch(() => {});
1574
+ await loadState();
1575
+ startPolling();
1576
+ startNodeButtonPolling();
721
1577
  } catch (err) {
722
1578
  setNodeHint(err.message);
723
1579
  els.zonesHint.textContent = err.message;