api-status.js 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
  1. /**
  2. * api-status.js — Live API health indicator
  3. * ------------------------------------------
  4. * Polls the /readyz endpoint and updates any .status-dot + .nav-status
  5. * elements on the page. Green = reachable, red = unreachable.
  6. *
  7. * Usage: <script src="/js/api-status.js" data-api="https://api.modulos.com.au"></script>
  8. * Falls back to data-api attribute, then window.API_BASE, then a hardcoded default.
  9. */
  10. (function () {
  11. 'use strict';
  12. const POLL_INTERVAL_OK = 30000; // 30s when healthy
  13. const POLL_INTERVAL_FAIL = 10000; // 10s when down (recover faster)
  14. const TIMEOUT_MS = 5000; // 5s request timeout
  15. // Resolve API base from attribute → global → fallback
  16. const scriptEl = document.currentScript;
  17. const API_BASE = (scriptEl && scriptEl.dataset.api)
  18. || (typeof window.API_BASE !== 'undefined' ? window.API_BASE : null)
  19. || 'https://api.modulos.com.au';
  20. const READYZ_URL = API_BASE.replace(/\/$/, '') + '/readyz';
  21. // CSS injected once
  22. const STYLE = `
  23. .status-dot {
  24. display: inline-block;
  25. width: 7px; height: 7px; border-radius: 50%;
  26. flex-shrink: 0;
  27. transition: background 0.4s ease, box-shadow 0.4s ease;
  28. }
  29. .status-dot.api-ok {
  30. background: #2ddc8a;
  31. box-shadow: 0 0 6px rgba(45,220,138,0.5);
  32. animation: api-pulse 2.5s ease-in-out infinite;
  33. }
  34. .status-dot.api-fail {
  35. background: #f08080;
  36. box-shadow: 0 0 6px rgba(240,128,128,0.5);
  37. animation: api-pulse-fail 1.5s ease-in-out infinite;
  38. }
  39. .status-dot.api-checking {
  40. background: #888;
  41. box-shadow: none;
  42. animation: none;
  43. opacity: 0.5;
  44. }
  45. @keyframes api-pulse {
  46. 0%, 100% { opacity: 1; }
  47. 50% { opacity: 0.4; }
  48. }
  49. @keyframes api-pulse-fail {
  50. 0%, 100% { opacity: 1; }
  51. 50% { opacity: 0.3; }
  52. }
  53. `;
  54. function injectStyles() {
  55. if (document.getElementById('api-status-style')) return;
  56. const s = document.createElement('style');
  57. s.id = 'api-status-style';
  58. s.textContent = STYLE;
  59. document.head.appendChild(s);
  60. }
  61. function getDots() { return document.querySelectorAll('.status-dot'); }
  62. function getLabels() { return document.querySelectorAll('.nav-status-text'); }
  63. function setState(state) {
  64. // state: 'ok' | 'fail' | 'checking'
  65. const dots = getDots();
  66. const labels = getLabels();
  67. dots.forEach(dot => {
  68. dot.classList.remove('api-ok', 'api-fail', 'api-checking');
  69. dot.classList.add('api-' + state);
  70. dot.setAttribute('title', state === 'ok'
  71. ? 'API reachable'
  72. : state === 'fail'
  73. ? 'API unreachable — check connection'
  74. : 'Checking API…'
  75. );
  76. });
  77. labels.forEach(label => {
  78. if (state === 'ok') {
  79. label.textContent = 'API live';
  80. label.style.color = '';
  81. } else if (state === 'fail') {
  82. label.textContent = 'API offline';
  83. label.style.color = '#f08080';
  84. } else {
  85. label.textContent = 'Checking…';
  86. label.style.color = '';
  87. }
  88. });
  89. }
  90. let _lastOk = null;
  91. let _timer = null;
  92. async function check() {
  93. try {
  94. const controller = new AbortController();
  95. const tid = setTimeout(() => controller.abort(), TIMEOUT_MS);
  96. const res = await fetch(READYZ_URL, {
  97. method: 'GET',
  98. credentials: 'omit',
  99. mode: 'cors',
  100. signal: controller.signal,
  101. cache: 'no-store'
  102. });
  103. clearTimeout(tid);
  104. const ok = res.ok;
  105. if (ok !== _lastOk) {
  106. setState(ok ? 'ok' : 'fail');
  107. _lastOk = ok;
  108. }
  109. schedule(ok ? POLL_INTERVAL_OK : POLL_INTERVAL_FAIL);
  110. } catch (_) {
  111. if (_lastOk !== false) {
  112. setState('fail');
  113. _lastOk = false;
  114. }
  115. schedule(POLL_INTERVAL_FAIL);
  116. }
  117. }
  118. function schedule(ms) {
  119. clearTimeout(_timer);
  120. _timer = setTimeout(check, ms);
  121. }
  122. function init() {
  123. injectStyles();
  124. // Add nav-status-text span inside every .nav-status if not already present
  125. document.querySelectorAll('.nav-status').forEach(el => {
  126. if (!el.querySelector('.nav-status-text')) {
  127. // Wrap any existing text node into a span
  128. const existing = [...el.childNodes].find(n => n.nodeType === 3 && n.textContent.trim());
  129. if (existing) {
  130. const span = document.createElement('span');
  131. span.className = 'nav-status-text';
  132. span.textContent = existing.textContent.trim();
  133. existing.replaceWith(span);
  134. } else {
  135. const span = document.createElement('span');
  136. span.className = 'nav-status-text';
  137. span.textContent = 'Checking…';
  138. el.appendChild(span);
  139. }
  140. }
  141. });
  142. setState('checking');
  143. check(); // immediate first check
  144. }
  145. // Run after DOM is available
  146. if (document.readyState === 'loading') {
  147. document.addEventListener('DOMContentLoaded', init);
  148. } else {
  149. init();
  150. }
  151. // Re-check immediately when tab becomes visible again after being hidden
  152. document.addEventListener('visibilitychange', () => {
  153. if (!document.hidden) {
  154. clearTimeout(_timer);
  155. check();
  156. }
  157. });
  158. })();