diff --git a/README.md b/README.md index d4dbdd4..a1f421c 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,9 @@ docker build -t netwatch . docker run -p 8080:8080 netwatch ``` +`yarn dev` proxies `/api` to `http://127.0.0.1:8080`, so a locally running +`netwatch-server` (see `backend/`) receives the reports the page posts. + ## Entrypoints This repository adheres to the @@ -71,6 +74,18 @@ code lives in `src/main.js` with a class-based architecture: - **`tick()`**: Main loop — measures all hosts in parallel via `Promise.all`, pushes samples, redraws UI. When paused, pushes blank markers (no probes, no false outage) +- **`Reporter`**: Posts collected samples to the backend + +### Reporting + +Every `reportInterval` (default 60s) the page POSTs a JSON report to the +same-origin path `/api/v1/reports`: a random per-browser `clientId` kept in +`localStorage`, `geo` sent as null, and each host's unreported, non-paused +samples (timestamp, latency, error). A per-host high-water mark makes every +report a delta, so only new samples are sent; the mark advances only on a +delivered report, and while paused nothing is sent. Delivery failure is quiet — +one debug-log line per outage, retried at the next interval, never blocking +probing. The report-building step is a pure function of host state. ### Monitoring targets diff --git a/TODO.md b/TODO.md index b284278..69b1366 100644 --- a/TODO.md +++ b/TODO.md @@ -22,6 +22,11 @@ files, so merging it also closes most compliance gaps. # Completed Steps +- 2026-09-21: frontend reporting client — a `Reporter` class posts collected + samples to `/api/v1/reports` every `reportInterval` (default 60s) as a + per-host delta, with the report-building step a pure exported function of host + state; the per-browser client id works in insecure (plain-HTTP) contexts; + `vite.config.js` proxies `/api` to the local backend for `yarn dev` - 2026-09-21: backend HTTP hardening (issue #19): added `ReadHeaderTimeout` and `IdleTimeout` to the server, a `SecurityHeaders` middleware (HSTS, tight CSP, frame/sniff/referrer/permissions headers) registered before CORS, and diff --git a/src/main.js b/src/main.js index 135d36a..8a114d2 100644 --- a/src/main.js +++ b/src/main.js @@ -7,9 +7,11 @@ import "./styles.css"; // graphMaxLatency — values above it pin to the top of the chart but still // display their real value in the latency figure. The history buffer holds // maxHistoryPoints samples (historyDuration / updateInterval). +// reportInterval is how often collected samples are POSTed to the backend. const CONFIG = { updateInterval: 3000, maxHistoryPoints: 100, + reportInterval: 60000, get historyDuration() { return (this.maxHistoryPoints * this.updateInterval) / 1000; }, @@ -346,6 +348,147 @@ class AppState { } } +// --- Reporting --------------------------------------------------------------- + +// A random UUIDv4. `crypto.randomUUID` exists only in secure contexts +// (HTTPS or localhost); over plain HTTP to any other host — the normal LAN +// deployment — it is undefined, so feature-detect it and otherwise build the +// id from `crypto.getRandomValues`, which is available in insecure contexts. +function randomId() { + if (typeof crypto !== "undefined" && crypto.randomUUID) { + return crypto.randomUUID(); + } + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4 + bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 1 + const hex = [...bytes].map((b) => b.toString(16).padStart(2, "0")); + return ( + hex.slice(0, 4).join("") + + "-" + + hex.slice(4, 6).join("") + + "-" + + hex.slice(6, 8).join("") + + "-" + + hex.slice(8, 10).join("") + + "-" + + hex.slice(10, 16).join("") + ); +} + +// A random id identifying this browser across reports. Generated once and +// kept in localStorage; if storage is unavailable (e.g. private mode) a +// fresh id is used for this session only. +function getClientId() { + const key = "netwatch-client-id"; + try { + let id = localStorage.getItem(key); + if (!id) { + id = randomId(); + localStorage.setItem(key, id); + } + return id; + } catch { + return randomId(); + } +} + +// Build the delta report body the backend decodes, plus the new per-host +// high-water marks. Pure function of the passed state: `hosts` is an array +// of { name, url, status, history }, `since` maps a host url to the Unix-ms +// timestamp of the last sample already reported for it, and `now` is a Date. +// Only non-paused samples newer than the mark are included. Returns null +// when no host has an unreported sample. +export function buildReport(hosts, clientId, now, since) { + const reportHosts = []; + const marks = new Map(); + for (const host of hosts) { + const mark = since.get(host.url) ?? 0; + const samples = []; + let high = mark; + for (const p of host.history) { + if (p.paused) continue; + if (p.timestamp <= mark) continue; + samples.push({ + t: p.timestamp, + latency: p.latency, + error: p.error ?? null, + }); + if (p.timestamp > high) high = p.timestamp; + } + if (samples.length === 0) continue; + reportHosts.push({ + name: host.name, + url: host.url, + status: host.status, + history: samples, + }); + marks.set(host.url, high); + } + if (reportHosts.length === 0) return null; + return { + body: { + clientId, + geo: null, + hosts: reportHosts, + timestamp: now.toISOString(), + }, + marks, + }; +} + +// Periodically POSTs unreported samples to the same-origin backend. Holds +// the per-host high-water marks so each report is a delta; marks only +// advance on a delivered report, so a failed POST simply re-sends those +// samples next interval (bounded by the history window — whatever has since +// fallen out is dropped). Failure is quiet: one debug line per outage, one +// on recovery, never an alert, never a tight retry loop. +class Reporter { + constructor(state, clientId, intervalMs) { + this.state = state; + this.clientId = clientId; + this.intervalMs = intervalMs; + this.marks = new Map(); + this.failing = false; + this.timerId = null; + } + + start() { + if (this.timerId) return; + this.timerId = setInterval(() => this.flush(), this.intervalMs); + } + + async flush() { + if (this.state.paused) return; + const report = buildReport( + this.state.allHosts, + this.clientId, + new Date(), + this.marks, + ); + if (!report) return; + try { + const resp = await fetch("/api/v1/reports", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "omit", + body: JSON.stringify(report.body), + }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + for (const [url, t] of report.marks) this.marks.set(url, t); + if (this.failing) { + log.debug("Report delivery recovered"); + this.failing = false; + } + } catch (err) { + if (!this.failing) { + log.debug(`Report delivery failed: ${err.message}`); + this.failing = true; + } + } + } +} + // --- Latency Measurement ----------------------------------------------------- async function measureLatency(url) { @@ -1162,6 +1305,19 @@ async function init() { buildUI(state); log.info("UI built, starting tick loop"); + // Reporting is best-effort: any failure setting it up (e.g. no usable + // crypto for the client id) must never stop the monitor from probing. + try { + const reporter = new Reporter( + state, + getClientId(), + CONFIG.reportInterval, + ); + reporter.start(); + } catch (err) { + log.error(`Reporting disabled: ${err.message}`); + } + document .getElementById("pause-btn") .addEventListener("click", () => togglePause(state)); @@ -1274,8 +1430,13 @@ async function init() { setTimeout(() => handleResize(state), 100); } -if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", init); -} else { - init(); +// Bootstrap only when loaded as the page: a real DOM containing the #app +// mount point this module renders into. Importing the module in a unit test +// (which has no #app) runs nothing, so buildReport can be tested in isolation. +if (typeof document !== "undefined" && document.getElementById("app")) { + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } } diff --git a/vite.config.js b/vite.config.js index 92cd874..0f362bd 100644 --- a/vite.config.js +++ b/vite.config.js @@ -7,6 +7,13 @@ const commitFull = execSync("git rev-parse HEAD").toString().trim(); export default defineConfig({ plugins: [tailwindcss()], + server: { + // Proxy /api to a locally running netwatch-server so `yarn dev` + // exercises the real report-posting path. + proxy: { + "/api": "http://127.0.0.1:8080", + }, + }, define: { __COMMIT_HASH__: JSON.stringify(commitHash), __COMMIT_FULL__: JSON.stringify(commitFull),