(() => { "use strict"; const CACHE_KEY = "nereus.telemetry.v1"; const state = { buoys: [], readings: [], markers: [], responses: [], totalErrors: 0, connectivity: "offline", version: null, lastDataAt: null, healthPolling: false, dataPolling: false, probes: { liveness: {ok: null, changedAt: null}, readiness: {ok: null, changedAt: null} } }; const elements = Object.fromEntries([ "clock", "version-value", "version-note", "liveness-dot", "liveness-time", "readiness-dot", "readiness-time", "error-total", "error-rate", "error-state", "buoy-count", "data-age", "map-state", "telemetry-state", "latest-temp", "latest-wave", "latest-salinity", "readings-chart" ].map((id) => [id, document.getElementById(id)])); const map = L.map("map", {zoomControl: false, attributionControl: true, scrollWheelZoom: false}).setView([39.3, -4.2], 5); map.attributionControl.addAttribution("Natural Earth"); if (window.NEREUS_LAND) { L.geoJSON(window.NEREUS_LAND, { interactive: false, style: {className: "landmass", fillColor: "#183640", fillOpacity: .72, color: "#41636a", weight: .7} }).addTo(map); } function restoreCache() { try { const cached = JSON.parse(localStorage.getItem(CACHE_KEY)); if (!cached || !Array.isArray(cached.buoys) || !Array.isArray(cached.readings)) return; state.buoys = cached.buoys; state.readings = cached.readings; state.lastDataAt = cached.savedAt || null; renderTelemetry(true); } catch (_) { // A damaged cache is equivalent to no cache and should stay invisible. } } function saveCache() { try { localStorage.setItem(CACHE_KEY, JSON.stringify({buoys: state.buoys, readings: state.readings, savedAt: state.lastDataAt})); } catch (_) { // Private browsing and storage quotas must not affect the dashboard. } } async function apiFetch(path) { const startedAt = Date.now(); try { const response = await fetch(path, {headers: {Accept: "application/json"}, cache: "no-store"}); state.responses.push({at: startedAt, error: !response.ok}); if (!response.ok) { state.totalErrors += 1; throw Object.assign(new Error("API response was not successful"), {httpFailure: true}); } return response.json(); } catch (error) { if (!error.httpFailure) state.connectivity = "offline"; throw error; } finally { renderErrors(); } } async function probe(path, includeVersion = false) { try { const response = await fetch(path, {cache: "no-store"}); return {ok: response.status === 200, version: includeVersion ? response.headers.get("X-Nereus-Version") : null}; } catch (_) { return {ok: false, version: null}; } } function updateProbe(name, ok) { const probeState = state.probes[name]; if (probeState.ok !== ok) { probeState.ok = ok; probeState.changedAt = new Date(); } elements[`${name}-dot`].className = `indicator ${ok ? "ok" : "bad"}`; elements[`${name}-time`].textContent = probeState.changedAt ? `Changed ${formatTime(probeState.changedAt)}` : "No transition yet"; } async function pollHealth() { if (state.healthPolling) return; state.healthPolling = true; try { const [live, ready] = await Promise.all([probe("/healthz", true), probe("/readyz")]); if (live.version) state.version = live.version; updateProbe("liveness", live.ok); updateProbe("readiness", ready.ok); if (!live.ok) state.connectivity = "offline"; renderConnection(); } finally { state.healthPolling = false; } } async function pollData() { if (state.dataPolling) return; state.dataPolling = true; try { const [buoys, readings] = await Promise.all([ apiFetch("/api/v1/buoys"), apiFetch("/api/v1/readings?limit=500") ]); state.buoys = Array.isArray(buoys) ? buoys : []; state.readings = Array.isArray(readings) ? readings : []; state.lastDataAt = new Date().toISOString(); state.connectivity = "online"; saveCache(); renderTelemetry(false); } catch (error) { if (error.httpFailure) state.connectivity = "degraded"; renderTelemetry(true); } finally { state.dataPolling = false; renderConnection(); } } function renderConnection() { const online = state.connectivity === "online"; const reachable = state.connectivity !== "offline"; elements["telemetry-state"].className = `connection-pill ${state.connectivity}`; elements["telemetry-state"].textContent = state.connectivity === "online" ? "Live" : state.connectivity === "degraded" ? "API errors" : "Offline"; elements["version-value"].textContent = reachable ? state.version || "unavailable" : "offline"; elements["version-note"].textContent = reachable ? (state.version ? "Reported by API" : "Version unavailable") : "Cluster unavailable"; document.querySelector(".map-card").classList.toggle("stale", !online); document.querySelector(".telemetry-card").classList.toggle("stale", !online); renderErrors(); } function renderErrors() { const cutoff = Date.now() - 60000; state.responses = state.responses.filter((event) => event.at >= cutoff); const failed = state.responses.filter((event) => event.error).length; const rate = state.responses.length ? failed / state.responses.length : 0; elements["error-total"].textContent = state.totalErrors.toLocaleString(); elements["error-rate"].textContent = `${(rate * 100).toFixed(1)}%`; elements["error-state"].textContent = state.connectivity === "offline" ? "API unreachable" : failed > 0 ? "API responding with errors" : "API responses healthy"; } function renderTelemetry(stale) { renderMap(stale); drawChart(); const latest = [...state.readings].sort((a, b) => new Date(b.recorded_at) - new Date(a.recorded_at))[0]; elements["latest-temp"].textContent = numberOrDash(latest?.water_temp, 1); elements["latest-wave"].textContent = numberOrDash(latest?.wave_height, 1); elements["latest-salinity"].textContent = numberOrDash(latest?.salinity, 1); elements["buoy-count"].textContent = `${state.buoys.length} ${state.buoys.length === 1 ? "buoy" : "buoys"}`; elements["data-age"].textContent = state.lastDataAt ? `Updated ${formatTime(new Date(state.lastDataAt))}` : "No telemetry"; elements["map-state"].textContent = state.buoys.length ? (stale ? "Showing last known positions" : "Live positions") : "Waiting for buoy positions"; } function renderMap() { state.markers.forEach((marker) => marker.remove()); const latest = new Map(); state.readings.forEach((reading) => { const previous = latest.get(reading.buoy_id); if (!previous || new Date(reading.recorded_at) > new Date(previous.recorded_at)) latest.set(reading.buoy_id, reading); }); state.markers = state.buoys.filter((buoy) => Number.isFinite(buoy.latitude) && Number.isFinite(buoy.longitude)).map((buoy) => { const reading = latest.get(buoy.id); const details = reading ? `${numberOrDash(reading.water_temp, 1)} °C · ${numberOrDash(reading.wave_height, 1)} m · ${numberOrDash(reading.salinity, 1)} PSU
${formatTime(new Date(reading.recorded_at))}` : "Awaiting first sample"; const icon = L.divIcon({className: "", html: '
', iconSize: [13, 13], iconAnchor: [6, 6]}); return L.marker([buoy.latitude, buoy.longitude], {icon}).bindTooltip(`${escapeText(buoy.name)}
${buoy.latitude.toFixed(2)}, ${buoy.longitude.toFixed(2)}
${details}`, {direction: "top"}).addTo(map); }); if (state.markers.length) { const bounds = L.featureGroup(state.markers).getBounds(); if (bounds.isValid()) map.fitBounds(bounds.pad(.22), {maxZoom: 7, animate: false}); } } function drawChart() { const canvas = elements["readings-chart"]; const rect = canvas.getBoundingClientRect(); const ratio = window.devicePixelRatio || 1; canvas.width = Math.max(1, Math.floor(rect.width * ratio)); canvas.height = Math.max(1, Math.floor(rect.height * ratio)); const context = canvas.getContext("2d"); context.scale(ratio, ratio); const width = rect.width, height = rect.height, pad = {top: 13, right: 13, bottom: 23, left: 34}; const values = [...state.readings].sort((a, b) => new Date(a.recorded_at) - new Date(b.recorded_at)).slice(-45); context.clearRect(0, 0, width, height); context.strokeStyle = "rgba(121, 173, 180, .12)"; context.lineWidth = 1; for (let i = 0; i < 5; i += 1) { const y = pad.top + ((height - pad.top - pad.bottom) * i / 4); context.beginPath(); context.moveTo(pad.left, y); context.lineTo(width - pad.right, y); context.stroke(); } if (values.length < 2) { context.fillStyle = "#789aa1"; context.font = "11px system-ui"; context.textAlign = "center"; context.fillText("Telemetry will appear when the cluster is online", width / 2, height / 2); return; } const plot = (field, color, min, max) => { const spread = max - min; context.beginPath(); context.strokeStyle = color; context.lineWidth = 2; context.lineJoin = "round"; values.forEach((item, index) => { const x = pad.left + ((width - pad.left - pad.right) * index / (values.length - 1)); const y = pad.top + (height - pad.top - pad.bottom) * (1 - (Number(item[field]) - min) / spread); if (index === 0) context.moveTo(x, y); else context.lineTo(x, y); }); context.stroke(); }; plot("water_temp", "#55e4d7", 10, 30); plot("wave_height", "#55a9ff", 0, 6); context.fillStyle = "#668d95"; context.font = "9px system-ui"; context.textAlign = "left"; context.fillText(formatTime(new Date(values[0].recorded_at)), pad.left, height - 5); context.textAlign = "right"; context.fillText(formatTime(new Date(values.at(-1).recorded_at)), width - pad.right, height - 5); } function formatTime(date) { return new Intl.DateTimeFormat(undefined, {hour: "2-digit", minute: "2-digit", second: "2-digit"}).format(date); } function numberOrDash(value, digits) { return Number.isFinite(Number(value)) ? Number(value).toFixed(digits) : "--"; } function escapeText(value) { const node = document.createElement("span"); node.textContent = String(value || "Unnamed buoy"); return node.innerHTML; } restoreCache(); renderConnection(); pollHealth(); pollData(); setInterval(pollHealth, 2000); setInterval(pollData, 5000); setInterval(renderErrors, 1000); setInterval(() => { elements.clock.textContent = formatTime(new Date()); }, 1000); elements.clock.textContent = formatTime(new Date()); window.addEventListener("resize", () => { map.invalidateSize(false); drawChart(); }); })();