feat(frontend): post collected samples to /api/v1/reports (closes #53)
check / check (push) Successful in 12s
check / check (push) Successful in 12s
A Reporter beside AppState POSTs each host's unreported, non-paused samples to /api/v1/reports every reportInterval (default 60s). buildReport is an exported pure function of host state; init() runs only when #app exists, so a test can import the module. A per-host mark advances only on a delivered POST. At most one report POST is pending at a time and it is abandoned after half the interval, so a slow POST never overlaps the next report and a mark never moves backwards. The samples of an abandoned POST are sent again at the next interval, so a backend that stored them but answered late receives them twice. Failure is quiet and never blocks probing. vite.config.js proxies /api for yarn dev. Model: opus-5-5
This commit is contained in:
@@ -23,6 +23,9 @@ docker build -t netwatch .
|
|||||||
docker run -p 8080:8080 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
|
## Entrypoints
|
||||||
|
|
||||||
This repository adheres to the
|
This repository adheres to the
|
||||||
@@ -85,6 +88,18 @@ code lives in `src/main.js` with a class-based architecture:
|
|||||||
- **`tick()`**: Main loop — measures all hosts in parallel via `Promise.all`,
|
- **`tick()`**: Main loop — measures all hosts in parallel via `Promise.all`,
|
||||||
pushes samples, redraws UI. When paused, pushes blank markers (no probes, no
|
pushes samples, redraws UI. When paused, pushes blank markers (no probes, no
|
||||||
false outage)
|
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
|
### Monitoring targets
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,16 @@ latest run passes.
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-09-28: frontend reporting client (issue #53): 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; a per-host mark advances only on a delivered POST; at most one
|
||||||
|
report POST is pending at a time and it is abandoned after half the interval,
|
||||||
|
so a slow POST never overlaps the next report and a mark never moves
|
||||||
|
backwards; the samples of an abandoned POST are sent again at the next
|
||||||
|
interval, so a backend that stored them but answered late receives them twice;
|
||||||
|
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-28: report ingest correctness (issue #23): a storage failure now
|
- 2026-09-28: report ingest correctness (issue #23): a storage failure now
|
||||||
returns 500 instead of a false `ok`; oversize bodies return 413 (distinguished
|
returns 500 instead of a false `ok`; oversize bodies return 413 (distinguished
|
||||||
from malformed JSON, which stays 400); a `MaxBodyBytes` middleware caps every
|
from malformed JSON, which stays 400); a `MaxBodyBytes` middleware caps every
|
||||||
|
|||||||
+173
@@ -7,9 +7,11 @@ import "./styles.css";
|
|||||||
// graphMaxLatency — values above it pin to the top of the chart but still
|
// 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
|
// display their real value in the latency figure. The history buffer holds
|
||||||
// maxHistoryPoints samples (historyDuration / updateInterval).
|
// maxHistoryPoints samples (historyDuration / updateInterval).
|
||||||
|
// reportInterval is how often collected samples are POSTed to the backend.
|
||||||
const CONFIG = {
|
const CONFIG = {
|
||||||
updateInterval: 3000,
|
updateInterval: 3000,
|
||||||
maxHistoryPoints: 100,
|
maxHistoryPoints: 100,
|
||||||
|
reportInterval: 60000,
|
||||||
get historyDuration() {
|
get historyDuration() {
|
||||||
return (this.maxHistoryPoints * this.updateInterval) / 1000;
|
return (this.maxHistoryPoints * this.updateInterval) / 1000;
|
||||||
},
|
},
|
||||||
@@ -346,6 +348,159 @@ 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.
|
||||||
|
//
|
||||||
|
// Only one report is ever in flight, and it is abandoned after half the
|
||||||
|
// interval, so a slow POST can neither overlap the next report (which would
|
||||||
|
// carry the same samples) nor stall reporting for good.
|
||||||
|
class Reporter {
|
||||||
|
constructor(state, clientId, intervalMs) {
|
||||||
|
this.state = state;
|
||||||
|
this.clientId = clientId;
|
||||||
|
this.intervalMs = intervalMs;
|
||||||
|
this.marks = new Map();
|
||||||
|
this.failing = false;
|
||||||
|
this.sending = false;
|
||||||
|
this.timerId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
if (this.timerId) return;
|
||||||
|
this.timerId = setInterval(() => this.flush(), this.intervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
async flush() {
|
||||||
|
if (this.sending || this.state.paused) return;
|
||||||
|
const report = buildReport(
|
||||||
|
this.state.allHosts,
|
||||||
|
this.clientId,
|
||||||
|
new Date(),
|
||||||
|
this.marks,
|
||||||
|
);
|
||||||
|
if (!report) return;
|
||||||
|
this.sending = true;
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/api/v1/reports", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "omit",
|
||||||
|
body: JSON.stringify(report.body),
|
||||||
|
signal: AbortSignal.timeout(this.intervalMs / 2),
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
|
for (const [url, t] of report.marks) {
|
||||||
|
const current = this.marks.get(url) ?? 0;
|
||||||
|
this.marks.set(url, Math.max(current, 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;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.sending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Latency Measurement -----------------------------------------------------
|
// --- Latency Measurement -----------------------------------------------------
|
||||||
|
|
||||||
async function measureLatency(url) {
|
async function measureLatency(url) {
|
||||||
@@ -1162,6 +1317,19 @@ async function init() {
|
|||||||
buildUI(state);
|
buildUI(state);
|
||||||
log.info("UI built, starting tick loop");
|
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
|
document
|
||||||
.getElementById("pause-btn")
|
.getElementById("pause-btn")
|
||||||
.addEventListener("click", () => togglePause(state));
|
.addEventListener("click", () => togglePause(state));
|
||||||
@@ -1274,8 +1442,13 @@ async function init() {
|
|||||||
setTimeout(() => handleResize(state), 100);
|
setTimeout(() => handleResize(state), 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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") {
|
if (document.readyState === "loading") {
|
||||||
document.addEventListener("DOMContentLoaded", init);
|
document.addEventListener("DOMContentLoaded", init);
|
||||||
} else {
|
} else {
|
||||||
init();
|
init();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ const commitFull = execSync("git rev-parse HEAD").toString().trim();
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [tailwindcss()],
|
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: {
|
define: {
|
||||||
__COMMIT_HASH__: JSON.stringify(commitHash),
|
__COMMIT_HASH__: JSON.stringify(commitHash),
|
||||||
__COMMIT_FULL__: JSON.stringify(commitFull),
|
__COMMIT_FULL__: JSON.stringify(commitFull),
|
||||||
|
|||||||
Reference in New Issue
Block a user