/** * api-status.js — Live API health indicator * ------------------------------------------ * Polls the /readyz endpoint and updates any .status-dot + .nav-status * elements on the page. Green = reachable, red = unreachable. * * Usage: * Falls back to data-api attribute, then window.API_BASE, then a hardcoded default. */ (function () { 'use strict'; const POLL_INTERVAL_OK = 30000; // 30s when healthy const POLL_INTERVAL_FAIL = 10000; // 10s when down (recover faster) const TIMEOUT_MS = 5000; // 5s request timeout // Resolve API base from attribute → global → fallback const scriptEl = document.currentScript; const API_BASE = (scriptEl && scriptEl.dataset.api) || (typeof window.API_BASE !== 'undefined' ? window.API_BASE : null) || 'https://api.modulos.com.au'; const READYZ_URL = API_BASE.replace(/\/$/, '') + '/readyz'; // CSS injected once const STYLE = ` .status-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; transition: background 0.4s ease, box-shadow 0.4s ease; } .status-dot.api-ok { background: #2ddc8a; box-shadow: 0 0 6px rgba(45,220,138,0.5); animation: api-pulse 2.5s ease-in-out infinite; } .status-dot.api-fail { background: #f08080; box-shadow: 0 0 6px rgba(240,128,128,0.5); animation: api-pulse-fail 1.5s ease-in-out infinite; } .status-dot.api-checking { background: #888; box-shadow: none; animation: none; opacity: 0.5; } @keyframes api-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } } @keyframes api-pulse-fail { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } } `; function injectStyles() { if (document.getElementById('api-status-style')) return; const s = document.createElement('style'); s.id = 'api-status-style'; s.textContent = STYLE; document.head.appendChild(s); } function getDots() { return document.querySelectorAll('.status-dot'); } function getLabels() { return document.querySelectorAll('.nav-status-text'); } function setState(state) { // state: 'ok' | 'fail' | 'checking' const dots = getDots(); const labels = getLabels(); dots.forEach(dot => { dot.classList.remove('api-ok', 'api-fail', 'api-checking'); dot.classList.add('api-' + state); dot.setAttribute('title', state === 'ok' ? 'API reachable' : state === 'fail' ? 'API unreachable — check connection' : 'Checking API…' ); }); labels.forEach(label => { if (state === 'ok') { label.textContent = 'API live'; label.style.color = ''; } else if (state === 'fail') { label.textContent = 'API offline'; label.style.color = '#f08080'; } else { label.textContent = 'Checking…'; label.style.color = ''; } }); } let _lastOk = null; let _timer = null; async function check() { try { const controller = new AbortController(); const tid = setTimeout(() => controller.abort(), TIMEOUT_MS); const res = await fetch(READYZ_URL, { method: 'GET', credentials: 'omit', mode: 'cors', signal: controller.signal, cache: 'no-store' }); clearTimeout(tid); const ok = res.ok; if (ok !== _lastOk) { setState(ok ? 'ok' : 'fail'); _lastOk = ok; } schedule(ok ? POLL_INTERVAL_OK : POLL_INTERVAL_FAIL); } catch (_) { if (_lastOk !== false) { setState('fail'); _lastOk = false; } schedule(POLL_INTERVAL_FAIL); } } function schedule(ms) { clearTimeout(_timer); _timer = setTimeout(check, ms); } function init() { injectStyles(); // Add nav-status-text span inside every .nav-status if not already present document.querySelectorAll('.nav-status').forEach(el => { if (!el.querySelector('.nav-status-text')) { // Wrap any existing text node into a span const existing = [...el.childNodes].find(n => n.nodeType === 3 && n.textContent.trim()); if (existing) { const span = document.createElement('span'); span.className = 'nav-status-text'; span.textContent = existing.textContent.trim(); existing.replaceWith(span); } else { const span = document.createElement('span'); span.className = 'nav-status-text'; span.textContent = 'Checking…'; el.appendChild(span); } } }); setState('checking'); check(); // immediate first check } // Run after DOM is available if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } // Re-check immediately when tab becomes visible again after being hidden document.addEventListener('visibilitychange', () => { if (!document.hidden) { clearTimeout(_timer); check(); } }); })();