| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178 |
- /**
- * 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: <script src="/js/api-status.js" data-api="https://api.modulos.com.au"></script>
- * 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();
- }
- });
- })();
|