app.js 7.5 KB

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