const grid = document.getElementById("service-grid"); const modalBackdrop = document.getElementById("service-modal-backdrop"); const modalClose = document.getElementById("service-modal-close"); const modalTitle = document.getElementById("service-modal-title"); const modalCategory = document.getElementById("service-modal-category"); const modalGrid = document.getElementById("service-modal-grid"); const services = new Map(); const tiles = new Map(); let openServiceName = ""; let socket = null; let reconnectTimer = 0; function formatTimestamp(value) { const date = new Date(value); return date.toLocaleString(undefined, { month: "short", day: "numeric", year: "numeric", hour: "numeric", minute: "2-digit", second: "2-digit", timeZoneName: "short" }); } function formatTime(value) { const date = new Date(value); return date.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit", second: "2-digit" }); } function escapeHTML(value) { return String(value).replace(/[&<>"']/g, function(char) { const entities = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }; return entities[char]; }); } function sortServices(items) { return items.sort(function(a, b) { if (a.healthy === b.healthy) { return a.name.localeCompare(b.name); } return a.healthy ? -1 : 1; }); } function serviceTile(service) { const tile = document.createElement("article"); tile.className = "tile tile-enter"; tile.dataset.serviceName = service.name; tile.innerHTML = '
' + '' + '' + '
' + '

' + '
' + '
' + 'HTTP Status' + '' + '
' + '
' + 'Response Time' + '' + '
' + '
'; applyServiceToTile(tile, service); tile.setAttribute("role", "button"); tile.setAttribute("tabindex", "0"); tile.addEventListener("click", function() { openModal(service.name); }); tile.addEventListener("keydown", function(event) { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openModal(service.name); } }); window.setTimeout(function() { tile.classList.remove("tile-enter"); }, 500); return tile; } function applyServiceToTile(tile, service) { const pill = tile.querySelector(".pill"); const pillLabel = tile.querySelector(".pill-label"); const statusCode = service.status_code > 0 ? service.status_code : "n/a"; tile.querySelector(".category").textContent = service.category + " · " + String(service.protocol || "").toUpperCase(); tile.querySelector(".name").textContent = service.name; tile.querySelector(".status-code").textContent = statusCode; tile.querySelector(".response-time").textContent = service.response_time; pill.classList.toggle("ok", service.healthy); pill.classList.toggle("bad", !service.healthy); pillLabel.textContent = service.healthy ? "Healthy" : "Down"; } function modalField(label, value) { return ''; } function renderModal(service) { const statusCode = service.status_code > 0 ? String(service.status_code) : "n/a"; modalCategory.textContent = service.category + " · " + String(service.protocol || "").toUpperCase(); modalTitle.textContent = service.name; modalGrid.innerHTML = modalField("Status", service.healthy ? "Healthy" : "Down") + modalField("URL", service.url || "n/a") + modalField("HTTP Status", statusCode) + modalField("Response Time", service.response_time || "n/a") + modalField("Last Checked", formatTimestamp(service.last_checked)) + modalField("Message", service.message || "n/a") + modalField("Details", service.details || "n/a") + modalField("Category", service.category || "n/a") + modalField("Protocol", String(service.protocol || "").toUpperCase() || "n/a"); } function openModal(serviceName) { const service = services.get(serviceName); if (!service) { return; } openServiceName = serviceName; renderModal(service); modalBackdrop.classList.remove("hidden"); document.body.classList.add("modal-open"); } function closeModal() { openServiceName = ""; modalBackdrop.classList.add("hidden"); document.body.classList.remove("modal-open"); } function syncGridOrder(items) { items.forEach(function(service) { let tile = tiles.get(service.name); if (!tile) { tile = serviceTile(service); tiles.set(service.name, tile); } else { applyServiceToTile(tile, service); } grid.appendChild(tile); }); } function syncTiles(items) { const liveNames = new Set(items.map(function(service) { return service.name; })); Array.from(tiles.keys()).forEach(function(name) { if (!liveNames.has(name)) { const tile = tiles.get(name); if (tile) { tile.remove(); } tiles.delete(name); } }); } function renderSnapshot() { const items = sortServices(Array.from(services.values())); if (items.length === 0) { grid.replaceChildren(); return; } syncTiles(items); syncGridOrder(items); } function upsertService(service) { services.set(service.name, service); const items = sortServices(Array.from(services.values())); syncGridOrder(items); if (openServiceName === service.name) { renderModal(service); } } function scheduleReconnect() { if (reconnectTimer) { return; } reconnectTimer = window.setTimeout(function() { reconnectTimer = 0; connectWebSocket(); }, 2000); } function connectWebSocket() { const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; socket = new WebSocket(protocol + "//" + window.location.host + "/ws"); socket.addEventListener("open", function() { loadInitialStatus().catch(function(error) { console.error("Failed to reload status after websocket reconnect:", error); }); }); socket.addEventListener("message", function(event) { const payload = JSON.parse(event.data); if (payload.type === "service_update" && payload.service) { upsertService(payload.service); } }); socket.addEventListener("close", function() { socket = null; scheduleReconnect(); }); socket.addEventListener("error", function() { socket.close(); }); } modalClose.addEventListener("click", closeModal); modalBackdrop.addEventListener("click", function(event) { if (event.target === modalBackdrop) { closeModal(); } }); document.addEventListener("keydown", function(event) { if (event.key === "Escape" && !modalBackdrop.classList.contains("hidden")) { closeModal(); } }); function loadInitialStatus() { return fetch("/api/status", { headers: { "Accept": "application/json" } }).then(function(response) { if (!response.ok) { throw new Error("Request failed with status " + response.status); } return response.json(); }).then(function(payload) { services.clear(); (payload.services || []).forEach(function(service) { services.set(service.name, service); }); renderSnapshot(); }); } loadInitialStatus().finally(function() { connectWebSocket(); });