Nereus/apps/web/app.js

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(); });
})();