websocket-scope-delta-demo.html 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180
  1. <!doctype html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="utf-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1">
  6. <title>Cockpit WebSocket Scope Delta Demo</title>
  7. <style>
  8. :root { color-scheme: light; font-family: Arial, sans-serif; }
  9. * { box-sizing: border-box; }
  10. body { margin: 0; background: #f4f7fb; color: #172b4d; }
  11. header { background: #17324d; color: white; padding: 24px 32px; }
  12. h1 { margin: 0; font-size: 22px; }
  13. p { margin: 8px 0 0; opacity: .8; }
  14. main { max-width: 1080px; margin: 0 auto; padding: 24px; }
  15. .toolbar { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 20px; }
  16. button { border: 1px solid #2557a7; background: white; color: #2557a7; padding: 8px 12px; cursor: pointer; }
  17. button.primary { background: #2557a7; color: white; }
  18. button:disabled { opacity: .5; cursor: not-allowed; }
  19. .grid { display: grid; grid-template-columns: 1fr 360px; gap: 20px; }
  20. .panel { background: white; border: 1px solid #d5dce6; padding: 16px; }
  21. .panel h2 { margin-top: 0; font-size: 16px; }
  22. #devices { display: grid; gap: 10px; }
  23. .device { border: 1px solid #dde3ec; padding: 10px 12px; }
  24. .device-name { font-weight: 700; }
  25. .status { display: inline-block; min-width: 82px; text-align: center; padding: 2px 6px; color: white; font-size: 12px; }
  26. .ALARM { background: #c53030; } .WORK_ORDER { background: #b7791f; } .OFFLINE { background: #4a5568; }
  27. .NORMAL { background: #2f855a; } .EMPTY { background: #4a5568; }
  28. .meta { color: #5d6b82; font-size: 12px; margin-top: 4px; }
  29. #events { height: 320px; overflow: auto; font: 12px/1.5 Consolas, monospace; background: #f7f9fc; padding: 10px; }
  30. .event-old { color: #b83280; } .event-push { color: #2c7a7b; } .event-state { color: #805ad5; }
  31. @media (max-width: 860px) { .grid { grid-template-columns: 1fr; } }
  32. </style>
  33. </head>
  34. <body>
  35. <header>
  36. <h1>Cockpit WebSocket Scope Delta Demo</h1>
  37. <p>REST is the authoritative initial snapshot. WebSocket deltas are accepted only when serverUpdatedAt is newer.</p>
  38. </header>
  39. <main>
  40. <div class="toolbar">
  41. <button class="primary" id="load">1. Load REST snapshot</button>
  42. <button id="replay" disabled>2. Replay pushes / reconnect</button>
  43. <button id="live">Connect live WebSocket</button>
  44. </div>
  45. <div class="grid">
  46. <section class="panel">
  47. <h2>Devices</h2>
  48. <div id="devices"></div>
  49. </section>
  50. <section class="panel">
  51. <h2>Protocol events</h2>
  52. <div id="events"></div>
  53. </section>
  54. </div>
  55. </main>
  56. <script>
  57. const REST_URL = "http://localhost:8300/api/cockpit/v1/map-snapshot";
  58. const WS_URL = "ws://localhost:8300/ws/deviceStatus";
  59. let snapshotLoaded = false;
  60. const latest = new Map();
  61. const sampleDevices = [
  62. { code: "TC-PS-WS-202609020001", name: "Pressure monitor 001", status: "NORMAL", statusTime: "2026-09-02T09:58:00+08:00", serverUpdatedAt: "2026-09-02T10:00:00+08:00" },
  63. { code: "TC-PS-WS-202609020002", name: "Pressure monitor 002", status: "NORMAL", statusTime: "2026-09-02T09:58:00+08:00", serverUpdatedAt: "2026-09-02T10:00:00+08:00" }
  64. ];
  65. const scriptedMessages = [
  66. { type: "cockpitDeviceStatusDelta", deviceCode: "TC-PS-WS-202609020001", device_code: "TC-PS-WS-202609020001", monitoringPointCode: "MP-001",
  67. data: { status: "ALARM", statusTime: "2026-09-02T10:01:00+08:00", serverUpdatedAt: "2026-09-02T10:01:00+08:00" } },
  68. { type: "cockpitDeviceStatusDelta", deviceCode: "TC-PS-WS-202609020001", device_code: "TC-PS-WS-202609020001", monitoringPointCode: "MP-001",
  69. data: { status: "NORMAL", statusTime: "2026-09-02T10:00:30+08:00", serverUpdatedAt: "2026-09-02T10:00:30+08:00" } },
  70. { type: "cockpitDeviceStatusDelta", deviceCode: "TC-PS-WS-202609020099", device_code: "TC-PS-WS-202609020099", monitoringPointCode: null,
  71. data: { status: "EMPTY", reason: "DEVICE_NOT_FOUND", statusTime: null, serverUpdatedAt: "2026-09-02T10:02:00+08:00" } },
  72. { type: "__disconnect" },
  73. { type: "__reconnect" },
  74. { type: "cockpitDeviceStatusDelta", deviceCode: "TC-PS-WS-202609020001", device_code: "TC-PS-WS-202609020001", monitoringPointCode: "MP-001",
  75. data: { status: "NORMAL", statusTime: "2026-09-02T10:03:00+08:00", serverUpdatedAt: "2026-09-02T10:03:00+08:00" } }
  76. ];
  77. function time(value) {
  78. return value ? value.replace("T", " ") : "-";
  79. }
  80. function log(message, className) {
  81. const node = document.createElement("div");
  82. node.textContent = message;
  83. if (className) node.className = className;
  84. document.getElementById("events").appendChild(node);
  85. document.getElementById("events").scrollTop = document.getElementById("events").scrollHeight;
  86. }
  87. function render() {
  88. const root = document.getElementById("devices");
  89. root.replaceChildren();
  90. sampleDevices.forEach((device, index) => {
  91. const current = latest.get(device.code) || device;
  92. const item = document.createElement("article");
  93. item.className = "device";
  94. item.innerHTML = `<div class="device-name">${device.name}</div>
  95. <span class="status ${current.status}">${current.status}</span>
  96. <div class="meta">${device.code}<br>statusTime: ${time(current.statusTime)}<br>serverUpdatedAt: ${time(current.serverUpdatedAt)}</div>`;
  97. root.appendChild(item);
  98. });
  99. document.getElementById("replay").disabled = !snapshotLoaded;
  100. }
  101. function accept(message) {
  102. if (!message || message.type !== "cockpitDeviceStatusDelta") return;
  103. const code = message.deviceCode;
  104. const data = message.data || {};
  105. const previous = latest.get(code);
  106. if (previous && data.serverUpdatedAt <= previous.serverUpdatedAt) {
  107. log(`IGNORED OLD ${code} @ ${data.serverUpdatedAt}`, "event-old");
  108. return;
  109. }
  110. latest.set(code, data);
  111. render();
  112. log(`DELTA ${code} -> ${data.status}${data.reason ? ` (${data.reason})` : ""}`, "event-push");
  113. }
  114. function loadSnapshot() {
  115. log(`GET ${REST_URL}`);
  116. fetch(REST_URL, { headers: { Accept: "application/json" } })
  117. .then((response) => response.ok ? response.json() : Promise.reject(new Error(`HTTP ${response.status}`)))
  118. .then((body) => {
  119. const points = body.data?.monitoringPoints || [];
  120. const unassociated = body.data?.unassociatedEquipment || [];
  121. points.forEach((point, index) => {
  122. const code = `${point.pointCode}-DEMO`;
  123. latest.set(code, { status: point.pointStatus || "EMPTY", statusTime: body.data.snapshotAt, serverUpdatedAt: body.data.snapshotAt });
  124. sampleDevices[index] = { code, name: point.pointName || code, status: point.pointStatus || "EMPTY", statusTime: body.data.snapshotAt, serverUpdatedAt: body.data.snapshotAt };
  125. });
  126. unassociated.forEach((device, index) => {
  127. const offset = points.length + index;
  128. const code = device.deviceCode;
  129. latest.set(code, { status: "EMPTY", statusTime: null, serverUpdatedAt: body.data.snapshotAt });
  130. sampleDevices[offset] = { code, name: device.deviceName || code, status: "EMPTY", statusTime: null, serverUpdatedAt: body.data.snapshotAt };
  131. });
  132. snapshotLoaded = true;
  133. log(`SNAPSHOT ${body.data.snapshotAt}`, "event-state");
  134. render();
  135. })
  136. .catch(() => {
  137. sampleDevices.forEach((device) => latest.set(device.code, device));
  138. snapshotLoaded = true;
  139. log("SNAPSHOT fallback sample (REST unavailable)", "event-state");
  140. render();
  141. });
  142. }
  143. function replay() {
  144. let index = 0;
  145. const step = () => {
  146. if (index >= scriptedMessages.length) return;
  147. const message = scriptedMessages[index++];
  148. if (message.type === "__disconnect") {
  149. log("DISCONNECTED", "event-state");
  150. } else if (message.type === "__reconnect") {
  151. log("RECONNECT {\"type\":\"cockpitSubscribe\",\"scope\":\"all\"}", "event-state");
  152. } else {
  153. accept(message);
  154. }
  155. setTimeout(step, 650);
  156. };
  157. step();
  158. }
  159. function connectLive() {
  160. const socket = new WebSocket(WS_URL);
  161. socket.onopen = () => {
  162. log("WEBSOCKET OPEN", "event-state");
  163. socket.send(JSON.stringify({ type: "cockpitSubscribe", scope: "all" }));
  164. };
  165. socket.onmessage = (event) => {
  166. const message = JSON.parse(event.data);
  167. log(`MESSAGE ${JSON.stringify(message)}`, message.type === "cockpitDeviceStatusDelta" ? "event-push" : "event-state");
  168. accept(message);
  169. };
  170. socket.onclose = () => log("WEBSOCKET CLOSED", "event-state");
  171. socket.onerror = () => log("WEBSOCKET UNAVAILABLE; use scripted replay", "event-old");
  172. }
  173. document.getElementById("load").addEventListener("click", loadSnapshot);
  174. document.getElementById("replay").addEventListener("click", replay);
  175. document.getElementById("live").addEventListener("click", connectLive);
  176. render();
  177. </script>
  178. </body>
  179. </html>