Interval change keeps unreported samples for the reporter (closes #124)
check / check (push) Waiting to run

The interval menu cleared every host's history, and the reporter builds
each report from that history, so the samples recorded since the last
delivered report were never sent. The menu now calls
HostState.clearHistory, which moves the history's entries to
clearedHistory, keeping the last maxHistoryPoints as the history does;
buildReport reads both. The per-host marks are unchanged, so a sample a
delivered report had is not sent again, and the page's history still
starts over. Reporter is exported for the new unit test.

Model: opus-5-5
This commit is contained in:
2026-10-14 09:08:33 +00:00
parent b1eefe0c29
commit c6f325cff5
4 changed files with 90 additions and 10 deletions
+5 -3
View File
@@ -142,9 +142,11 @@ 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.
delivered report, and while paused nothing is sent. Changing the interval clears
each host's history on the page; the samples in it not yet reported still go in
the next report. 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.
### Backend
+10
View File
@@ -22,6 +22,16 @@ Decide whether the repo moves to the layout `REPO_POLICIES.md` gives, with
# Completed Steps
- 2026-10-14: changing the interval no longer drops the samples not yet reported
to the backend ([#124](https://git.eeqj.de/sneak/netwatch/issues/124)). The
interval menu calls the new `HostState.clearHistory`, which moves the
history's entries to `clearedHistory`, keeping the last 100 as the history
does, and `buildReport` reads both, so the next report has the samples
recorded before the change that no report has delivered. The page's history
still starts over, and the per-host marks still leave out of a report the
samples a delivered one had. A unit test sends a report, records samples,
clears the history as the interval menu does and finds them in the next
report; `src/main.js` now exports `Reporter` for it
- 2026-10-07: the files shared from `sneak/prompts` are its copies at commit
`dd4027b` ([#113](https://git.eeqj.de/sneak/netwatch/issues/113)), with this
repository's own entries after the shared content in `.gitignore`,
+21 -7
View File
@@ -265,6 +265,9 @@ export class HostState {
// error }, or a round skipped while paused, { timestamp,
// latency: null, paused: true }.
this.history = [];
// The entries the history held before the interval menu last
// cleared it, which the reporter may not have sent yet.
this.clearedHistory = [];
this.lastLatency = null;
this.status = "pending"; // 'online' | 'offline' | 'error' | 'pending'
this.pinned = pinned;
@@ -293,6 +296,16 @@ export class HostState {
this._trim();
}
// Starts the history over, as the interval menu does. Its entries move
// to clearedHistory, which keeps the last maxHistoryPoints of them, as
// the history does, so the reporter still sends those it has not.
clearHistory() {
this.clearedHistory = [...this.clearedHistory, ...this.history].slice(
-CONFIG.maxHistoryPoints,
);
this.history = [];
}
// The min, max, median and average latency of the checks in the history
// that got an answer.
historyStats() {
@@ -429,10 +442,11 @@ function getClientId() {
// 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.
// of { name, url, status, clearedHistory, 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,
// from clearedHistory as well as history. Returns null when no host has an
// unreported sample.
export function buildReport(hosts, clientId, now, since) {
const reportHosts = [];
const marks = new Map();
@@ -440,7 +454,7 @@ export function buildReport(hosts, clientId, now, since) {
const mark = since.get(host.url) ?? 0;
const samples = [];
let high = mark;
for (const p of host.history) {
for (const p of [...host.clearedHistory, ...host.history]) {
if (p.paused) continue;
if (p.timestamp <= mark) continue;
samples.push({
@@ -481,7 +495,7 @@ export function buildReport(hosts, clientId, now, since) {
// 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 {
export class Reporter {
constructor(state, clientId, intervalMs) {
this.state = state;
this.clientId = clientId;
@@ -1502,7 +1516,7 @@ async function init() {
// Clear all host history and reset state
for (const host of state.allHosts) {
host.history = [];
host.clearHistory();
host.lastLatency = null;
host.status = "pending";
}
+54
View File
@@ -14,6 +14,7 @@ import {
latencyClass,
latencyHex,
measureLatency,
Reporter,
tick,
} from "../../src/main.js";
@@ -378,6 +379,59 @@ test("a check that fails in the first round, which is discarded, writes nothing
assert.deepEqual(consoleLines("info"), []);
});
// Every report is delivered here, and only Target has checks to report.
// Between the first report and the second the interval changes: the
// interval menu calls clearHistory on each target. The third report would
// hold no check the first two did not, so it is not sent.
test("checks recorded before an interval change and not yet reported go in the next report, and no check is reported twice", async (t) => {
const state = new AppState([
{ name: "Target", url: "https://target.test" },
]);
const target = state.local[0];
const reporter = new Reporter(state, "client", CONFIG.reportInterval);
t.mock.method(globalThis, "fetch", async () => ({ ok: true }));
target.pushSample(1000, { latency: 10, error: null });
await reporter.flush();
target.pushSample(2000, { latency: 20, error: null });
target.pushSample(3000, { latency: null, error: "timeout" });
target.clearHistory();
target.pushSample(4000, { latency: 40, error: null });
await reporter.flush();
await reporter.flush();
assert.equal(fetch.mock.calls.length, 2);
const [first, second] = fetch.mock.calls.map(
(call) => JSON.parse(call.arguments[1].body).hosts,
);
const name = "Target";
const url = "https://target.test";
assert.deepEqual(first, [
{
name,
url,
status: "online",
history: [{ t: 1000, latency: 10, error: null }],
},
]);
assert.deepEqual(second, [
{
name,
url,
status: "online",
history: [
{ t: 2000, latency: 20, error: null },
{ t: 3000, latency: null, error: "timeout" },
{ t: 4000, latency: 40, error: null },
],
},
]);
// The page's history starts over.
assert.deepEqual(target.history, [
{ timestamp: 4000, latency: 40, error: null },
]);
});
// The page shows &lt; &gt; &quot; &amp; and &#39; in a row's markup as
// < > " & and '.
test(`a target whose name and URL hold < > " & and ' shows those characters in its row`, () => {