1 Commits
Author SHA1 Message Date
sneak c6f325cff5 Interval change keeps unreported samples for the reporter (closes #124)
check / check (push) Successful in 2m9s
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
2026-10-14 09:08:33 +00:00
3 changed files with 52 additions and 105 deletions

No files matched your search

+8 -10
View File
@@ -24,16 +24,14 @@ Decide whether the repo moves to the layout `REPO_POLICIES.md` gives, with
- 2026-10-14: changing the interval no longer drops the samples not yet reported - 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 to the backend ([#124](https://git.eeqj.de/sneak/netwatch/issues/124)). The
interval menu resets each host with the new `HostState.reset`, which moves the interval menu calls the new `HostState.clearHistory`, which moves the
history's entries to `clearedHistory`, keeping the last 100 of all it has history's entries to `clearedHistory`, keeping the last 100 as the history
cleared, and `buildReport` reads both, so the next report has the samples does, and `buildReport` reads both, so the next report has the samples
recorded before the change that no report has delivered. Until its next check, recorded before the change that no report has delivered. The page's history
such a host is sent with the status of its last check, not `pending`. The still starts over, and the per-host marks still leave out of a report the
page's history still starts over, and the per-host marks still leave out of a samples a delivered one had. A unit test sends a report, records samples,
report the samples a delivered one had. Unit tests reset a host as the clears the history as the interval menu does and finds them in the next
interval menu does and find its unreported samples in the next report, and report; `src/main.js` now exports `Reporter` for it
only the last 100 after more were cleared; `src/main.js` now exports
`Reporter` for them
- 2026-10-07: the files shared from `sneak/prompts` are its copies at commit - 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 `dd4027b` ([#113](https://git.eeqj.de/sneak/netwatch/issues/113)), with this
repository's own entries after the shared content in `.gitignore`, repository's own entries after the shared content in `.gitignore`,
+15 -27
View File
@@ -257,14 +257,6 @@ function latencyStats(latencies) {
}; };
} }
// The status a check's error gives its host: "error" for a timeout,
// "offline" for any other error, and "online" for none.
function statusFromError(error) {
if (error === "timeout") return "error";
if (error) return "offline";
return "online";
}
export class HostState { export class HostState {
constructor(host, pinned = false) { constructor(host, pinned = false) {
this.name = host.name; this.name = host.name;
@@ -273,9 +265,8 @@ export class HostState {
// error }, or a round skipped while paused, { timestamp, // error }, or a round skipped while paused, { timestamp,
// latency: null, paused: true }. // latency: null, paused: true }.
this.history = []; this.history = [];
// The last 100 (CONFIG.maxHistoryPoints) entries the interval menu // The entries the history held before the interval menu last
// has cleared from the history, which the reporter may not have // cleared it, which the reporter may not have sent yet.
// sent yet.
this.clearedHistory = []; this.clearedHistory = [];
this.lastLatency = null; this.lastLatency = null;
this.status = "pending"; // 'online' | 'offline' | 'error' | 'pending' this.status = "pending"; // 'online' | 'offline' | 'error' | 'pending'
@@ -292,7 +283,9 @@ export class HostState {
}); });
this._trim(); this._trim();
this.lastLatency = result.latency; this.lastLatency = result.latency;
this.status = statusFromError(result.error); if (result.error === "timeout") this.status = "error";
else if (result.error) this.status = "offline";
else this.status = "online";
this.consecutiveFailures = result.error this.consecutiveFailures = result.error
? this.consecutiveFailures + 1 ? this.consecutiveFailures + 1
: 0; : 0;
@@ -303,16 +296,14 @@ export class HostState {
this._trim(); this._trim();
} }
// What the interval menu does to each host: the history starts over and // Starts the history over, as the interval menu does. Its entries move
// the host waits for its next check. The history's entries move to // to clearedHistory, which keeps the last maxHistoryPoints of them, as
// clearedHistory, so the reporter still sends those it has not. // the history does, so the reporter still sends those it has not.
reset() { clearHistory() {
this.clearedHistory = [...this.clearedHistory, ...this.history].slice( this.clearedHistory = [...this.clearedHistory, ...this.history].slice(
-CONFIG.maxHistoryPoints, -CONFIG.maxHistoryPoints,
); );
this.history = []; this.history = [];
this.lastLatency = null;
this.status = "pending";
} }
// The min, max, median and average latency of the checks in the history // The min, max, median and average latency of the checks in the history
@@ -454,10 +445,8 @@ function getClientId() {
// of { name, url, status, clearedHistory, history }, `since` maps a host url // 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 // 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, // `now` is a Date. Only non-paused samples newer than the mark are included,
// from clearedHistory as well as history. A host whose status is pending, // from clearedHistory as well as history. Returns null when no host has an
// as the interval menu leaves it until its next check, is sent with the // unreported sample.
// status of its last check, which is the last sample sent. Returns null
// when no host has an unreported sample.
export function buildReport(hosts, clientId, now, since) { export function buildReport(hosts, clientId, now, since) {
const reportHosts = []; const reportHosts = [];
const marks = new Map(); const marks = new Map();
@@ -479,10 +468,7 @@ export function buildReport(hosts, clientId, now, since) {
reportHosts.push({ reportHosts.push({
name: host.name, name: host.name,
url: host.url, url: host.url,
status: status: host.status,
host.status === "pending"
? statusFromError(samples.at(-1).error)
: host.status,
history: samples, history: samples,
}); });
marks.set(host.url, high); marks.set(host.url, high);
@@ -1530,7 +1516,9 @@ async function init() {
// Clear all host history and reset state // Clear all host history and reset state
for (const host of state.allHosts) { for (const host of state.allHosts) {
host.reset(); host.clearHistory();
host.lastLatency = null;
host.status = "pending";
} }
state.tickCount = 0; state.tickCount = 0;
+29 -68
View File
@@ -379,21 +379,11 @@ test("a check that fails in the first round, which is discarded, writes nothing
assert.deepEqual(consoleLines("info"), []); assert.deepEqual(consoleLines("info"), []);
}); });
// The reports Reporter sent in this test, each as the hosts it sent. // Every report is delivered here, and only Target has checks to report.
function reportedHosts() { // Between the first report and the second the interval changes: the
return fetch.mock.calls.map( // interval menu calls clearHistory on each target. The third report would
(call) => JSON.parse(call.arguments[1].body).hosts, // 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) => {
}
// In the next two tests every report is delivered, and only Target has
// checks to report. target.reset() does to Target what the interval menu
// does to each target.
// The interval changes after the first report. The second report is sent
// before Target's first check since then, and the third after it. A fourth
// would hold no check the first three did not, so it is not sent.
test("checks recorded before an interval change and not yet reported go in the next report, with the status of the last check, and no check is reported twice", async (t) => {
const state = new AppState([ const state = new AppState([
{ name: "Target", url: "https://target.test" }, { name: "Target", url: "https://target.test" },
]); ]);
@@ -405,42 +395,36 @@ test("checks recorded before an interval change and not yet reported go in the n
await reporter.flush(); await reporter.flush();
target.pushSample(2000, { latency: 20, error: null }); target.pushSample(2000, { latency: 20, error: null });
target.pushSample(3000, { latency: null, error: "timeout" }); target.pushSample(3000, { latency: null, error: "timeout" });
target.reset(); target.clearHistory();
await reporter.flush();
target.pushSample(4000, { latency: 40, error: null }); target.pushSample(4000, { latency: 40, error: null });
await reporter.flush(); await reporter.flush();
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 name = "Target";
const url = "https://target.test"; const url = "https://target.test";
assert.deepEqual(reportedHosts(), [ assert.deepEqual(first, [
[ {
{ name,
name, url,
url, status: "online",
status: "online", history: [{ t: 1000, latency: 10, error: null }],
history: [{ t: 1000, latency: 10, error: null }], },
}, ]);
], assert.deepEqual(second, [
[ {
{ name,
name, url,
url, status: "online",
status: "error", history: [
history: [ { t: 2000, latency: 20, error: null },
{ t: 2000, latency: 20, error: null }, { t: 3000, latency: null, error: "timeout" },
{ t: 3000, latency: null, error: "timeout" }, { t: 4000, latency: 40, error: null },
], ],
}, },
],
[
{
name,
url,
status: "online",
history: [{ t: 4000, latency: 40, error: null }],
},
],
]); ]);
// The page's history starts over. // The page's history starts over.
assert.deepEqual(target.history, [ assert.deepEqual(target.history, [
@@ -448,29 +432,6 @@ test("checks recorded before an interval change and not yet reported go in the n
]); ]);
}); });
// The interval changes twice, after 60 checks each time, before the first
// report. Of the 120 checks the interval menu cleared, the report has the
// last 100 (CONFIG.maxHistoryPoints).
test("of the checks interval changes cleared before they were reported, the next report has the last 100", 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 }));
for (let n = 1; n <= 120; n++) {
target.pushSample(n * 1000, { latency: 10, error: null });
if (n % 60 === 0) target.reset();
}
await reporter.flush();
const sent = reportedHosts()[0][0].history;
assert.equal(sent.length, 100);
assert.equal(sent[0].t, 21000);
assert.equal(sent.at(-1).t, 120000);
});
// The page shows &lt; &gt; &quot; &amp; and &#39; in a row's markup as // The page shows &lt; &gt; &quot; &amp; and &#39; in a row's markup as
// < > " & and '. // < > " & and '.
test(`a target whose name and URL hold < > " & and ' shows those characters in its row`, () => { test(`a target whose name and URL hold < > " & and ' shows those characters in its row`, () => {