242 lines
11 KiB
JavaScript
242 lines
11 KiB
JavaScript
(() => {
|
|
"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<br><small>${formatTime(new Date(reading.recorded_at))}</small>` : "Awaiting first sample";
|
|
const icon = L.divIcon({className: "", html: '<div class="buoy-marker"></div>', iconSize: [13, 13], iconAnchor: [6, 6]});
|
|
return L.marker([buoy.latitude, buoy.longitude], {icon}).bindTooltip(`<strong>${escapeText(buoy.name)}</strong><br>${buoy.latitude.toFixed(2)}, ${buoy.longitude.toFixed(2)}<br>${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(); });
|
|
})();
|