app.js 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261
  1. const grid = document.getElementById("service-grid");
  2. const modalBackdrop = document.getElementById("service-modal-backdrop");
  3. const modalClose = document.getElementById("service-modal-close");
  4. const modalTitle = document.getElementById("service-modal-title");
  5. const modalCategory = document.getElementById("service-modal-category");
  6. const modalGrid = document.getElementById("service-modal-grid");
  7. const services = new Map();
  8. const tiles = new Map();
  9. let openServiceName = "";
  10. function formatTimestamp(value) {
  11. const date = new Date(value);
  12. return date.toLocaleString(undefined, {
  13. month: "short",
  14. day: "numeric",
  15. year: "numeric",
  16. hour: "numeric",
  17. minute: "2-digit",
  18. second: "2-digit",
  19. timeZoneName: "short"
  20. });
  21. }
  22. function formatTime(value) {
  23. const date = new Date(value);
  24. return date.toLocaleTimeString(undefined, {
  25. hour: "numeric",
  26. minute: "2-digit",
  27. second: "2-digit"
  28. });
  29. }
  30. function escapeHTML(value) {
  31. return String(value).replace(/[&<>"']/g, function(char) {
  32. const entities = {
  33. "&": "&amp;",
  34. "<": "&lt;",
  35. ">": "&gt;",
  36. '"': "&quot;",
  37. "'": "&#39;"
  38. };
  39. return entities[char];
  40. });
  41. }
  42. function sortServices(items) {
  43. return items.sort(function(a, b) {
  44. if (a.healthy === b.healthy) {
  45. return a.name.localeCompare(b.name);
  46. }
  47. return a.healthy ? -1 : 1;
  48. });
  49. }
  50. function serviceTile(service) {
  51. const tile = document.createElement("article");
  52. tile.className = "tile tile-enter";
  53. tile.dataset.serviceName = service.name;
  54. tile.innerHTML =
  55. '<div class="row">' +
  56. '<span class="category"></span>' +
  57. '<span class="pill"><span class="dot"></span><span class="pill-label"></span></span>' +
  58. '</div>' +
  59. '<h2 class="name"></h2>' +
  60. '<div class="stats">' +
  61. '<div class="stat">' +
  62. '<span class="label">HTTP Status</span>' +
  63. '<span class="value status-code"></span>' +
  64. '</div>' +
  65. '<div class="stat">' +
  66. '<span class="label">Response Time</span>' +
  67. '<span class="value response-time"></span>' +
  68. '</div>' +
  69. '</div>';
  70. applyServiceToTile(tile, service);
  71. tile.setAttribute("role", "button");
  72. tile.setAttribute("tabindex", "0");
  73. tile.addEventListener("click", function() {
  74. openModal(service.name);
  75. });
  76. tile.addEventListener("keydown", function(event) {
  77. if (event.key === "Enter" || event.key === " ") {
  78. event.preventDefault();
  79. openModal(service.name);
  80. }
  81. });
  82. window.setTimeout(function() {
  83. tile.classList.remove("tile-enter");
  84. }, 500);
  85. return tile;
  86. }
  87. function applyServiceToTile(tile, service) {
  88. const pill = tile.querySelector(".pill");
  89. const pillLabel = tile.querySelector(".pill-label");
  90. const statusCode = service.status_code > 0 ? service.status_code : "n/a";
  91. tile.querySelector(".category").textContent = service.category + " · " + String(service.protocol || "").toUpperCase();
  92. tile.querySelector(".name").textContent = service.name;
  93. tile.querySelector(".status-code").textContent = statusCode;
  94. tile.querySelector(".response-time").textContent = service.response_time;
  95. pill.classList.toggle("ok", service.healthy);
  96. pill.classList.toggle("bad", !service.healthy);
  97. pillLabel.textContent = service.healthy ? "Healthy" : "Down";
  98. }
  99. function modalField(label, value) {
  100. return '<div class="modal-stat">' +
  101. '<span class="label">' + escapeHTML(label) + '</span>' +
  102. '<span class="value">' + escapeHTML(value) + '</span>' +
  103. '</div>';
  104. }
  105. function renderModal(service) {
  106. const statusCode = service.status_code > 0 ? String(service.status_code) : "n/a";
  107. modalCategory.textContent = service.category + " · " + String(service.protocol || "").toUpperCase();
  108. modalTitle.textContent = service.name;
  109. modalGrid.innerHTML =
  110. modalField("Status", service.healthy ? "Healthy" : "Down") +
  111. modalField("URL", service.url || "n/a") +
  112. modalField("HTTP Status", statusCode) +
  113. modalField("Response Time", service.response_time || "n/a") +
  114. modalField("Last Checked", formatTimestamp(service.last_checked)) +
  115. modalField("Message", service.message || "n/a") +
  116. modalField("Details", service.details || "n/a") +
  117. modalField("Category", service.category || "n/a") +
  118. modalField("Protocol", String(service.protocol || "").toUpperCase() || "n/a");
  119. }
  120. function openModal(serviceName) {
  121. const service = services.get(serviceName);
  122. if (!service) {
  123. return;
  124. }
  125. openServiceName = serviceName;
  126. renderModal(service);
  127. modalBackdrop.classList.remove("hidden");
  128. document.body.classList.add("modal-open");
  129. }
  130. function closeModal() {
  131. openServiceName = "";
  132. modalBackdrop.classList.add("hidden");
  133. document.body.classList.remove("modal-open");
  134. }
  135. function syncGridOrder(items) {
  136. items.forEach(function(service) {
  137. let tile = tiles.get(service.name);
  138. if (!tile) {
  139. tile = serviceTile(service);
  140. tiles.set(service.name, tile);
  141. } else {
  142. applyServiceToTile(tile, service);
  143. }
  144. grid.appendChild(tile);
  145. });
  146. }
  147. function syncTiles(items) {
  148. const liveNames = new Set(items.map(function(service) {
  149. return service.name;
  150. }));
  151. Array.from(tiles.keys()).forEach(function(name) {
  152. if (!liveNames.has(name)) {
  153. const tile = tiles.get(name);
  154. if (tile) {
  155. tile.remove();
  156. }
  157. tiles.delete(name);
  158. }
  159. });
  160. }
  161. function renderSnapshot() {
  162. const items = sortServices(Array.from(services.values()));
  163. if (items.length === 0) {
  164. grid.replaceChildren();
  165. return;
  166. }
  167. syncTiles(items);
  168. syncGridOrder(items);
  169. }
  170. function upsertService(service) {
  171. services.set(service.name, service);
  172. const items = sortServices(Array.from(services.values()));
  173. syncGridOrder(items);
  174. if (openServiceName === service.name) {
  175. renderModal(service);
  176. }
  177. }
  178. function connectWebSocket() {
  179. const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
  180. const socket = new WebSocket(protocol + "//" + window.location.host + "/ws");
  181. socket.addEventListener("open", function() {
  182. });
  183. socket.addEventListener("message", function(event) {
  184. const payload = JSON.parse(event.data);
  185. if (payload.type === "service_update" && payload.service) {
  186. upsertService(payload.service);
  187. }
  188. });
  189. socket.addEventListener("close", function() {
  190. window.setTimeout(connectWebSocket, 2000);
  191. });
  192. socket.addEventListener("error", function() {
  193. socket.close();
  194. });
  195. }
  196. modalClose.addEventListener("click", closeModal);
  197. modalBackdrop.addEventListener("click", function(event) {
  198. if (event.target === modalBackdrop) {
  199. closeModal();
  200. }
  201. });
  202. document.addEventListener("keydown", function(event) {
  203. if (event.key === "Escape" && !modalBackdrop.classList.contains("hidden")) {
  204. closeModal();
  205. }
  206. });
  207. function loadInitialStatus() {
  208. return fetch("/api/status", {
  209. headers: {
  210. "Accept": "application/json"
  211. }
  212. }).then(function(response) {
  213. if (!response.ok) {
  214. throw new Error("Request failed with status " + response.status);
  215. }
  216. return response.json();
  217. }).then(function(payload) {
  218. services.clear();
  219. (payload.services || []).forEach(function(service) {
  220. services.set(service.name, service);
  221. });
  222. renderSnapshot();
  223. });
  224. }
  225. loadInitialStatus().finally(function() {
  226. connectWebSocket();
  227. });