From f279dda27a7e3e035218fe08a3a0c283c55cf0d0 Mon Sep 17 00:00:00 2001 From: sneak Date: Mon, 21 Sep 2026 12:45:23 +0000 Subject: [PATCH] feat(frontend): post collected samples to /api/v1/reports (closes #53) A Reporter class beside AppState POSTs a JSON report to the same-origin /api/v1/reports every reportInterval (default 60s, a new CONFIG value). buildReport is a pure function of host state: it emits each host's unreported, non-paused samples ({t, latency, error}) plus a random per-browser clientId from localStorage, geo null, and an ISO 8601 UTC timestamp. A per-host high-water mark keyed on sample time makes every report a delta and only advances on a delivered POST, so a failed send re-sends next interval, bounded by the existing history window. Failure is quiet: one debug-log line per outage, one on recovery, no alert, no tight retry loop, never blocking probing. While paused nothing is sent. vite.config.js proxies /api to 127.0.0.1:8080 so yarn dev exercises the real path against a local netwatch-server. Backend and schema unchanged. Model: opus-4-8 --- README.md | 15 +++++++ TODO.md | 4 ++ src/main.js | 120 +++++++++++++++++++++++++++++++++++++++++++++++++ vite.config.js | 7 +++ 4 files changed, 146 insertions(+) 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 1da8930..7278569 100644 --- a/TODO.md +++ b/TODO.md @@ -22,6 +22,10 @@ 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 function of host state; + `vite.config.js` proxies `/api` to the local backend for `yarn dev` - 2026-08-10: every interactive control now meets the 44x44 CSS px minimum tap target (`.pin-btn`, `#interval-select`, the debug-log label and, on narrow viewports, `#pause-btn`). The pin button's hit area grows via matching diff --git a/src/main.js b/src/main.js index 135d36a..8b0d3af 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,121 @@ class AppState { } } +// --- Reporting --------------------------------------------------------------- + +// 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 = crypto.randomUUID(); + localStorage.setItem(key, id); + } + return id; + } catch { + return crypto.randomUUID(); + } +} + +// 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. +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 +1279,9 @@ async function init() { buildUI(state); log.info("UI built, starting tick loop"); + const reporter = new Reporter(state, getClientId(), CONFIG.reportInterval); + reporter.start(); + document .getElementById("pause-btn") .addEventListener("click", () => togglePause(state)); 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),