1 Commits
Author SHA1 Message Date
sneak 2b39d63814 Unset TRUSTED_PROXIES trusts RFC 1918, empty trusts none (closes #129)
check / check (push) Canceled after 0s
The backend's own TRUSTED_PROXIES now follows the ruling for every app:
unset, it trusts 10.0.0.0/8, 172.16.0.0/12 and 192.168.0.0/16, no longer
loopback; a list replaces that; set and empty, it trusts no proxy.

viper reads an empty environment variable as unset, so an empty value
got the default. config.New now checks os.LookupEnv for this one
setting. viper's AllowEmptyEnv would do it too, but for every setting,
so an empty PORT or DEBUG would stop the start instead of counting as
unset.

bin/entrypoint.sh still starts the backend with
TRUSTED_PROXIES=127.0.0.1/32.

Model: opus-5-5
2026-10-14 10:16:16 +00:00
10 changed files with 37 additions and 319 deletions

No files matched your search

+4 -12
View File
@@ -149,11 +149,9 @@ 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 `localStorage`, `geo` sent as null, and each host's unreported, non-paused
samples (timestamp, latency, error). A per-host high-water mark makes every 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 report a delta, so only new samples are sent; the mark advances only on a
delivered report, and while paused nothing is sent. Changing the interval clears delivered report, and while paused nothing is sent. Delivery failure is quiet —
each host's history on the page; the samples in it not yet reported still go in one debug-log line per outage, retried at the next interval, never blocking
the next report. Delivery failure is quiet — one debug-log line per outage, probing. The report-building step is a pure function of host state.
retried at the next interval, never blocking probing. The report-building step
is a pure function of host state.
### Backend ### Backend
@@ -233,15 +231,9 @@ Files are created with `O_EXCL` to prevent overwrites.
#### Report limits #### Report limits
`POST /api/v1/reports` takes reports from anyone who can reach it, without `POST /api/v1/reports` takes reports from anyone who can reach it, without
credentials, so it is bounded instead. Each refusal below answers with the same credentials, so it is bounded instead. Both refusals below answer with the same
`{"status":"error"}` body as any other error. `{"status":"error"}` body as any other error.
- **JSON only.** A report must be sent with `Content-Type: application/json`,
parameters such as `charset` allowed, as the page sends it; any other is
refused with 415, and nothing of it is stored. A page on another origin can
send a `text/plain` POST, or a form, without a CORS preflight; refusing them
keeps any site from making its visitors' browsers store reports (see
[CORS](#cors)).
- **Rate limit.** Each client address, resolved through `TRUSTED_PROXIES`, may - **Rate limit.** Each client address, resolved through `TRUSTED_PROXIES`, may
send `REPORTS_PER_MINUTE` reports a minute; past that it gets 429 with send `REPORTS_PER_MINUTE` reports a minute; past that it gets 429 with
`Retry-After: 60`. The minute slides: reports from the minute before still `Retry-After: 60`. The minute slides: reports from the minute before still
+6 -31
View File
@@ -21,37 +21,12 @@ Run `make frontend-viewport-test` in CI as its own step; it is not part of
# Completed Steps # Completed Steps
- 2026-10-14: unset, the backend's own `TRUSTED_PROXIES` trusts `10.0.0.0/8`, - 2026-10-14: the backend's own `TRUSTED_PROXIES`
`172.16.0.0/12` and `192.168.0.0/16`, and no longer loopback; a list replaces ([#129](https://git.eeqj.de/sneak/netwatch/issues/129)), unset, trusts
that; set and empty, it trusts no proxy, where it used to get the default, as `10.0.0.0/8`, `172.16.0.0/12` and `192.168.0.0/16`, no longer loopback; set
viper reads an empty environment variable as unset and empty, it trusts no proxy, where it got the default because viper reads an
([#129](https://git.eeqj.de/sneak/netwatch/issues/129)). A value it cannot empty environment variable as unset; a list replaces the default. The image
parse still stops the start, and the image still starts the backend with still starts the backend with `TRUSTED_PROXIES=127.0.0.1/32`
`TRUSTED_PROXIES=127.0.0.1/32`. Tests load the config with it unset, empty and
set to a list
- 2026-10-14: `POST /api/v1/reports` takes only a report sent with
`Content-Type: application/json`, parameters such as `charset` allowed, and
refuses any other with 415 and the usual error body, storing nothing
([#126](https://git.eeqj.de/sneak/netwatch/issues/126)). A page on another
origin can send a `text/plain` POST without a CORS preflight, so any site
could make its visitors' browsers store reports; a JSON POST waits for the
preflight, which only an origin in `CORS_ALLOWED_ORIGINS` passes. The page
already sent `application/json`. A test posts a report as `text/plain`, as
`application/x-www-form-urlencoded`, as `multipart/form-data`, with no
`Content-Type`, and as `application/json` with and without a charset, and
checks which are stored
- 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 resets each host with the new `HostState.reset`, which moves the
history's entries to `clearedHistory`, keeping the last 100 of all it has
cleared, and `buildReport` reads both, so the next report has the samples
recorded before the change that no report has delivered. Until its next check,
such a host is sent with the status of its last check, not `pending`. The
page's history still starts over, and the per-host marks still leave out of a
report the samples a delivered one had. Unit tests reset a host as the
interval menu does and find its unreported samples in the next report, and
only the last 100 after more were cleared; `web/src/main.js` now exports
`Reporter` for them
- 2026-10-14: the repo has the layout `REPO_POLICIES.md` gives - 2026-10-14: the repo has the layout `REPO_POLICIES.md` gives
([#30](https://git.eeqj.de/sneak/netwatch/issues/30)). The Go module is at the ([#30](https://git.eeqj.de/sneak/netwatch/issues/30)). The Go module is at the
root (`go.mod`, `go.sum`, `.golangci.yml`, `cmd/netwatch-server/`, root (`go.mod`, `go.sum`, `.golangci.yml`, `cmd/netwatch-server/`,
+5 -6
View File
@@ -134,12 +134,11 @@ func New(
viper.GetString("DEBUG"), errNotBool) viper.GetString("DEBUG"), errNotBool)
} }
// An empty TRUSTED_PROXIES trusts no proxy, but viper reads an // viper reads an empty environment variable as unset, which would
// empty environment variable as unset and would give the default, // give the default here; an empty TRUSTED_PROXIES trusts no proxy.
// so the environment is read first. trustedProxies := viper.GetString("TRUSTED_PROXIES")
trustedProxies, set := os.LookupEnv("TRUSTED_PROXIES") if value, set := os.LookupEnv("TRUSTED_PROXIES"); set && value == "" {
if !set { trustedProxies = ""
trustedProxies = viper.GetString("TRUSTED_PROXIES")
} }
s := &Config{ s := &Config{
+3 -5
View File
@@ -57,8 +57,7 @@ func requireTrustedProxies(t *testing.T, want []string) {
// TestTrustedProxiesUnsetTrustsRFC1918: unset, the server trusts the // TestTrustedProxiesUnsetTrustsRFC1918: unset, the server trusts the
// RFC 1918 ranges and nothing else, loopback included. // RFC 1918 ranges and nothing else, loopback included.
func TestTrustedProxiesUnsetTrustsRFC1918(t *testing.T) { func TestTrustedProxiesUnsetTrustsRFC1918(t *testing.T) {
// t.Setenv first, so the variable is put back as it was after the // t.Setenv puts the variable back as it was after the test.
// test.
t.Setenv("TRUSTED_PROXIES", "") t.Setenv("TRUSTED_PROXIES", "")
err := os.Unsetenv("TRUSTED_PROXIES") err := os.Unsetenv("TRUSTED_PROXIES")
@@ -70,9 +69,8 @@ func TestTrustedProxiesUnsetTrustsRFC1918(t *testing.T) {
[]string{"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"}) []string{"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"})
} }
// TestTrustedProxiesEmptyTrustsNothing: set and empty, the server // TestTrustedProxiesEmptyTrustsNothing: viper reads an empty variable
// trusts no proxy. viper alone would read the empty variable as unset // as unset, which would give the default.
// and give the default.
func TestTrustedProxiesEmptyTrustsNothing(t *testing.T) { func TestTrustedProxiesEmptyTrustsNothing(t *testing.T) {
t.Setenv("TRUSTED_PROXIES", "") t.Setenv("TRUSTED_PROXIES", "")
+4 -21
View File
@@ -5,9 +5,7 @@ package handlers
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"log/slog" "log/slog"
"mime"
"net/http" "net/http"
"sneak.berlin/go/netwatch/internal/globals" "sneak.berlin/go/netwatch/internal/globals"
@@ -20,10 +18,6 @@ import (
const jsonContentType = "application/json; charset=utf-8" const jsonContentType = "application/json; charset=utf-8"
// errNotJSON is what decodeJSON returns for a request whose
// Content-Type is not application/json.
var errNotJSON = errors.New("content type is not application/json")
// reportAppender is the subset of the report buffer the handlers // reportAppender is the subset of the report buffer the handlers
// depend on. Defining it here keeps the storage failure path // depend on. Defining it here keeps the storage failure path
// exercisable with a stub in tests. // exercisable with a stub in tests.
@@ -86,25 +80,14 @@ func (s *Handlers) respondJSON(
} }
} }
// decodeJSON decodes the request body into v. It returns errNotJSON, // decodeJSON decodes the request body into v. The body is
// reading nothing, unless the request's Content-Type is // expected to already be bounded by the body-size middleware, so
// application/json, with or without parameters such as charset. The // a caller can distinguish an over-limit body from malformed
// body is expected to already be bounded by the body-size middleware, // JSON by testing the returned error for *http.MaxBytesError.
// so a caller can distinguish an over-limit body from malformed JSON
// by testing the returned error for *http.MaxBytesError.
func (s *Handlers) decodeJSON( func (s *Handlers) decodeJSON(
_ http.ResponseWriter, _ http.ResponseWriter,
r *http.Request, r *http.Request,
v any, v any,
) error { ) error {
// A page on another origin can POST text/plain or a form without
// a CORS preflight, so its visitors' browsers would send it
// whatever the CORS settings. A JSON POST waits for the
// preflight, so only JSON is taken.
mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil || mediaType != "application/json" {
return errNotJSON
}
return json.NewDecoder(r.Body).Decode(v) return json.NewDecoder(r.Body).Decode(v)
} }
+3 -9
View File
@@ -30,7 +30,7 @@ type report struct {
} }
// HandleReport returns a handler that accepts telemetry // HandleReport returns a handler that accepts telemetry
// reports from NetWatch clients, sent as application/json. // reports from NetWatch clients.
func (s *Handlers) HandleReport() http.HandlerFunc { func (s *Handlers) HandleReport() http.HandlerFunc {
type response struct { type response struct {
Status string `json:"status"` Status string `json:"status"`
@@ -66,15 +66,9 @@ func (s *Handlers) HandleReport() http.HandlerFunc {
} }
// decodeErrorStatus logs a report decode failure and returns the // decodeErrorStatus logs a report decode failure and returns the
// status to send: 415 when the report was not sent as JSON, 413 when // status to send: 413 when the body exceeded the size limit,
// the body exceeded the size limit, otherwise 400 for malformed JSON. // otherwise 400 for malformed JSON.
func (s *Handlers) decodeErrorStatus(err error) int { func (s *Handlers) decodeErrorStatus(err error) int {
if errors.Is(err, errNotJSON) {
s.log.Warn("report refused: Content-Type is not application/json")
return http.StatusUnsupportedMediaType
}
var tooLarge *http.MaxBytesError var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) { if errors.As(err, &tooLarge) {
s.log.Warn("report body too large", "limit_bytes", tooLarge.Limit) s.log.Warn("report body too large", "limit_bytes", tooLarge.Limit)
-103
View File
@@ -27,17 +27,6 @@ type stubAppender struct {
func (s stubAppender) Append(any) error { return s.err } func (s stubAppender) Append(any) error { return s.err }
// recordingAppender keeps every report the handler stores.
type recordingAppender struct {
reports []any
}
func (a *recordingAppender) Append(v any) error {
a.reports = append(a.reports, v)
return nil
}
func newTestHandlers(buf stubAppender, out io.Writer) *handlers.Handlers { func newTestHandlers(buf stubAppender, out io.Writer) *handlers.Handlers {
return handlers.NewForTest(buf, slog.New(slog.NewJSONHandler(out, nil))) return handlers.NewForTest(buf, slog.New(slog.NewJSONHandler(out, nil)))
} }
@@ -94,7 +83,6 @@ func TestHandleReportAcceptsValidReports(t *testing.T) {
http.MethodPost, "/api/v1/reports", http.MethodPost, "/api/v1/reports",
strings.NewReader(tt.body), strings.NewReader(tt.body),
) )
req.Header.Set("Content-Type", "application/json")
h.HandleReport().ServeHTTP(rec, req) h.HandleReport().ServeHTTP(rec, req)
@@ -115,89 +103,6 @@ func TestHandleReportAcceptsValidReports(t *testing.T) {
} }
} }
// TestHandleReportStoresOnlyJSON checks that a report is stored only
// when sent as application/json, as the page sends it. One sent as
// text/plain or as a form, which a page on another origin can send
// without a CORS preflight, is refused with 415.
func TestHandleReportStoresOnlyJSON(t *testing.T) {
t.Parallel()
tests := []struct {
name string
contentType string
wantStatus int
wantStored int
}{
{
name: "text/plain",
contentType: "text/plain;charset=UTF-8",
wantStatus: http.StatusUnsupportedMediaType,
wantStored: 0,
},
{
name: "a urlencoded form",
contentType: "application/x-www-form-urlencoded",
wantStatus: http.StatusUnsupportedMediaType,
wantStored: 0,
},
{
name: "a multipart form",
contentType: "multipart/form-data; boundary=x",
wantStatus: http.StatusUnsupportedMediaType,
wantStored: 0,
},
{
name: "no Content-Type",
contentType: "",
wantStatus: http.StatusUnsupportedMediaType,
wantStored: 0,
},
{
name: "application/json",
contentType: "application/json",
wantStatus: http.StatusOK,
wantStored: 1,
},
{
name: "application/json with a charset",
contentType: "application/json;charset=UTF-8",
wantStatus: http.StatusOK,
wantStored: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
var stored recordingAppender
h := handlers.NewForTest(&stored, slog.New(slog.DiscardHandler))
rec := httptest.NewRecorder()
req := httptest.NewRequestWithContext(t.Context(),
http.MethodPost, "/api/v1/reports",
strings.NewReader(`{"clientId":"c1","hosts":[]}`),
)
if tt.contentType != "" {
req.Header.Set("Content-Type", tt.contentType)
}
h.HandleReport().ServeHTTP(rec, req)
if rec.Code != tt.wantStatus {
t.Errorf("status = %d, want %d", rec.Code, tt.wantStatus)
}
if len(stored.reports) != tt.wantStored {
t.Errorf("reports stored = %d, want %d",
len(stored.reports), tt.wantStored)
}
})
}
}
func TestHandleReportStorageFailureIsNon2xx(t *testing.T) { func TestHandleReportStorageFailureIsNon2xx(t *testing.T) {
t.Parallel() t.Parallel()
@@ -208,7 +113,6 @@ func TestHandleReportStorageFailureIsNon2xx(t *testing.T) {
http.MethodPost, "/api/v1/reports", http.MethodPost, "/api/v1/reports",
strings.NewReader(`{"clientId":"c1","hosts":[]}`), strings.NewReader(`{"clientId":"c1","hosts":[]}`),
) )
req.Header.Set("Content-Type", "application/json")
h.HandleReport().ServeHTTP(rec, req) h.HandleReport().ServeHTTP(rec, req)
@@ -234,7 +138,6 @@ func TestHandleReportFullIs507(t *testing.T) {
http.MethodPost, "/api/v1/reports", http.MethodPost, "/api/v1/reports",
strings.NewReader(`{"clientId":"c1","hosts":[]}`), strings.NewReader(`{"clientId":"c1","hosts":[]}`),
) )
req.Header.Set("Content-Type", "application/json")
h.HandleReport().ServeHTTP(rec, req) h.HandleReport().ServeHTTP(rec, req)
@@ -258,7 +161,6 @@ func TestHandleReportMalformedJSONIs400(t *testing.T) {
http.MethodPost, "/api/v1/reports", http.MethodPost, "/api/v1/reports",
strings.NewReader(`{not json`), strings.NewReader(`{not json`),
) )
req.Header.Set("Content-Type", "application/json")
h.HandleReport().ServeHTTP(rec, req) h.HandleReport().ServeHTTP(rec, req)
@@ -283,8 +185,6 @@ func TestHandleReportOversizeIs413(t *testing.T) {
http.MethodPost, "/api/v1/reports", http.MethodPost, "/api/v1/reports",
strings.NewReader(`{"clientId":"`+strings.Repeat("x", 200)+`"}`), strings.NewReader(`{"clientId":"`+strings.Repeat("x", 200)+`"}`),
) )
req.Header.Set("Content-Type", "application/json")
// No declared length, so only the middleware's read cap can // No declared length, so only the middleware's read cap can
// stop this body. // stop this body.
req.ContentLength = -1 req.ContentLength = -1
@@ -313,7 +213,6 @@ func TestHandleReportDoesNotLogRawGeo(t *testing.T) {
`{"clientId":"c1","geo":{"raw":"`+sentinel+`"},"hosts":[]}`, `{"clientId":"c1","geo":{"raw":"`+sentinel+`"},"hosts":[]}`,
), ),
) )
req.Header.Set("Content-Type", "application/json")
h.HandleReport().ServeHTTP(rec, req) h.HandleReport().ServeHTTP(rec, req)
@@ -346,7 +245,6 @@ func TestHandleReportLogsClientIDCutToBound(t *testing.T) {
`{"clientId":"`+long+`","timestamp":"`+long+`","hosts":[]}`, `{"clientId":"`+long+`","timestamp":"`+long+`","hosts":[]}`,
), ),
) )
req.Header.Set("Content-Type", "application/json")
h.HandleReport().ServeHTTP(rec, req) h.HandleReport().ServeHTTP(rec, req)
@@ -386,7 +284,6 @@ func TestHandleReportDecodeErrorLogIsBounded(t *testing.T) {
http.MethodPost, "/api/v1/reports", http.MethodPost, "/api/v1/reports",
strings.NewReader(`{"hosts":[{"history":[{"t":`+huge+`}]}]}`), strings.NewReader(`{"hosts":[{"history":[{"t":`+huge+`}]}]}`),
) )
req.Header.Set("Content-Type", "application/json")
h.HandleReport().ServeHTTP(rec, req) h.HandleReport().ServeHTTP(rec, req)
-1
View File
@@ -66,7 +66,6 @@ func TestReportsAreRateLimited(t *testing.T) {
http.MethodPost, "/api/v1/reports", http.MethodPost, "/api/v1/reports",
strings.NewReader(`{"clientId":"c1","hosts":[]}`), strings.NewReader(`{"clientId":"c1","hosts":[]}`),
) )
req.Header.Set("Content-Type", "application/json")
srv.ServeHTTP(rec, req) srv.ServeHTTP(rec, req)
return rec.Code return rec.Code
-93
View File
@@ -14,7 +14,6 @@ import {
latencyClass, latencyClass,
latencyHex, latencyHex,
measureLatency, measureLatency,
Reporter,
tick, tick,
} from "../../web/src/main.js"; } from "../../web/src/main.js";
@@ -379,98 +378,6 @@ 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.
function reportedHosts() {
return fetch.mock.calls.map(
(call) => JSON.parse(call.arguments[1].body).hosts,
);
}
// 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([
{ 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.reset();
await reporter.flush();
target.pushSample(4000, { latency: 40, error: null });
await reporter.flush();
await reporter.flush();
const name = "Target";
const url = "https://target.test";
assert.deepEqual(reportedHosts(), [
[
{
name,
url,
status: "online",
history: [{ t: 1000, latency: 10, error: null }],
},
],
[
{
name,
url,
status: "error",
history: [
{ t: 2000, latency: 20, error: null },
{ t: 3000, latency: null, error: "timeout" },
],
},
],
[
{
name,
url,
status: "online",
history: [{ t: 4000, latency: 40, error: null }],
},
],
]);
// The page's history starts over.
assert.deepEqual(target.history, [
{ timestamp: 4000, latency: 40, error: null },
]);
});
// 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`, () => {
+12 -38
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,10 +265,6 @@ 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
// has cleared from the history, which the reporter may not have
// sent yet.
this.clearedHistory = [];
this.lastLatency = null; this.lastLatency = null;
this.status = "pending"; // 'online' | 'offline' | 'error' | 'pending' this.status = "pending"; // 'online' | 'offline' | 'error' | 'pending'
this.pinned = pinned; this.pinned = pinned;
@@ -292,7 +280,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,18 +293,6 @@ export class HostState {
this._trim(); this._trim();
} }
// What the interval menu does to each host: the history starts over and
// the host waits for its next check. The history's entries move to
// clearedHistory, so the reporter still sends those it has not.
reset() {
this.clearedHistory = [...this.clearedHistory, ...this.history].slice(
-CONFIG.maxHistoryPoints,
);
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
// that got an answer. // that got an answer.
historyStats() { historyStats() {
@@ -451,12 +429,9 @@ function getClientId() {
// Build the delta report body the backend decodes, plus the new per-host // 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 // high-water marks. Pure function of the passed state: `hosts` is an array
// of { name, url, status, clearedHistory, history }, `since` maps a host url // of { name, url, status, history }, `since` maps a host url to the Unix-ms
// to the Unix-ms timestamp of the last sample already reported for it, and // timestamp of the last sample already reported for it, and `now` is a Date.
// `now` is a Date. Only non-paused samples newer than the mark are included, // Only non-paused samples newer than the mark are included. Returns null
// from clearedHistory as well as history. A host whose status is pending,
// as the interval menu leaves it until its next check, is sent with the
// status of its last check, which is the last sample sent. Returns null
// when no host has an unreported sample. // 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 = [];
@@ -465,7 +440,7 @@ export function buildReport(hosts, clientId, now, since) {
const mark = since.get(host.url) ?? 0; const mark = since.get(host.url) ?? 0;
const samples = []; const samples = [];
let high = mark; let high = mark;
for (const p of [...host.clearedHistory, ...host.history]) { for (const p of host.history) {
if (p.paused) continue; if (p.paused) continue;
if (p.timestamp <= mark) continue; if (p.timestamp <= mark) continue;
samples.push({ samples.push({
@@ -479,10 +454,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);
@@ -509,7 +481,7 @@ export function buildReport(hosts, clientId, now, since) {
// Only one report is ever in flight, and it is abandoned after half the // 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 // interval, so a slow POST can neither overlap the next report (which would
// carry the same samples) nor stall reporting for good. // carry the same samples) nor stall reporting for good.
export class Reporter { class Reporter {
constructor(state, clientId, intervalMs) { constructor(state, clientId, intervalMs) {
this.state = state; this.state = state;
this.clientId = clientId; this.clientId = clientId;
@@ -1530,7 +1502,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.history = [];
host.lastLatency = null;
host.status = "pending";
} }
state.tickCount = 0; state.tickCount = 0;