(() => {
"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: '