node-red-contrib-alarm-ultimate 0.1.2 → 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.
@@ -66,6 +66,15 @@
66
66
  display: none;
67
67
  }
68
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
+
69
78
  header {
70
79
  padding: 16px;
71
80
  border-bottom: 1px solid var(--border);
@@ -354,6 +363,11 @@
354
363
  font-size: 12px;
355
364
  margin: 8px 0 0 0;
356
365
  }
366
+
367
+ .log-details {
368
+ color: var(--muted);
369
+ font-size: 11px;
370
+ }
357
371
  </style>
358
372
  </head>
359
373
  <body>
@@ -437,6 +451,32 @@
437
451
  </div>
438
452
  </section>
439
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>
440
480
  </main>
441
481
 
442
482
  <script>
@@ -453,6 +493,14 @@
453
493
  zonesHint: document.getElementById("zonesHint"),
454
494
  btnZonesFilterOpen: document.getElementById("btnZonesFilterOpen"),
455
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"),
456
504
  code: document.getElementById("code"),
457
505
  cmdStatus: document.getElementById("cmdStatus"),
458
506
  btnArm: document.getElementById("btnArm"),
@@ -472,14 +520,22 @@
472
520
  document.body.classList.add("view-zones");
473
521
  } else if (view === "keypad") {
474
522
  document.body.classList.add("view-keypad");
523
+ } else if (view === "log") {
524
+ document.body.classList.add("view-log");
475
525
  }
476
526
 
527
+ const showLog = view !== "zones" && view !== "keypad";
528
+
477
529
  let nodesList = [];
478
530
  let selectedIds = new Set();
479
531
  let pollTimer = null;
480
532
  let nodeStateTimer = null;
481
533
  let nodeStateById = new Map();
482
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
+
483
539
  let audioCtx = null;
484
540
  let armingBeepTimer = null;
485
541
  let hasSeenState = false;
@@ -791,6 +847,260 @@
791
847
  .replace(/'/g, "&#039;");
792
848
  }
793
849
 
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
+
794
1104
  function normalizePreselectIds(raw) {
795
1105
  const v = String(raw || "").trim();
796
1106
  if (!v) return [];
@@ -841,19 +1151,20 @@
841
1151
  els.selectionHint.innerHTML = `Selected: <span class="pill small">${count}</span> (click buttons to add/remove)`;
842
1152
  }
843
1153
 
844
- function setSelectedIds(next) {
845
- const ids = Array.isArray(next) ? next.map(String).filter(Boolean) : [];
846
- selectedIds = new Set(ids);
847
- // enforce non-empty selection when possible
848
- if (selectedIds.size === 0 && nodesList[0]) {
849
- selectedIds.add(nodesList[0].id);
850
- }
851
- persistSelectedIds();
852
- resetStateAudioTracking();
853
- updateSelectionHint();
854
- renderNodeButtons();
855
- loadState().catch(() => {});
856
- }
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
+ }
857
1168
 
858
1169
  function selectAll() {
859
1170
  if (!nodesList.length) return;
@@ -1049,11 +1360,14 @@
1049
1360
  zonesFilterInitialized = true;
1050
1361
  updateZonesFilterButtons();
1051
1362
  }
1052
- renderZones(zones);
1053
- if (!multiple) {
1054
- handleStateBeeps(okStates[0].data && okStates[0].data.state);
1055
- }
1056
- }
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
+ }
1057
1371
 
1058
1372
  function startPolling() {
1059
1373
  if (pollTimer) clearInterval(pollTimer);
@@ -1157,14 +1471,53 @@
1157
1471
  if (els.btnZonesFilterOpen) {
1158
1472
  els.btnZonesFilterOpen.addEventListener("click", () => setZonesFilter("open", { user: true }));
1159
1473
  }
1160
- if (els.btnZonesFilterAll) {
1161
- els.btnZonesFilterAll.addEventListener("click", () => setZonesFilter("all", { user: true }));
1162
- }
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
+ }
1163
1516
 
1164
- els.btnArm.addEventListener("click", async () => {
1165
- playKeyClick("action");
1166
- try {
1167
- await sendCommand({ command: "arm", code: codeValue() });
1517
+ els.btnArm.addEventListener("click", async () => {
1518
+ playKeyClick("action");
1519
+ try {
1520
+ await sendCommand({ command: "arm", code: codeValue() });
1168
1521
  if (els.cmdStatus.style.display === "none") {
1169
1522
  showCmdStatus(`Arm sent to ${selectedIds.size} node(s).`, "ok");
1170
1523
  }
@@ -1209,14 +1562,18 @@
1209
1562
  else if (/^\d$/.test(e.key)) playKeyClick("key");
1210
1563
  });
1211
1564
 
1212
- (async function init() {
1213
- try {
1214
- setNodeHint(`API root: ${httpAdminRoot()}`);
1215
- await loadNodes();
1216
- await loadNodeStatesForButtons().catch(() => {});
1217
- await loadState();
1218
- startPolling();
1219
- startNodeButtonPolling();
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();
1220
1577
  } catch (err) {
1221
1578
  setNodeHint(err.message);
1222
1579
  els.zonesHint.textContent = err.message;