Compare commits
2
Commits
d3d3b47cf4
...
a14a0382a1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a14a0382a1 | ||
|
|
03bc282871 |
@@ -149,9 +149,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
|
`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. Delivery failure is quiet —
|
delivered report, and while paused nothing is sent. Changing the interval clears
|
||||||
one debug-log line per outage, retried at the next interval, never blocking
|
each host's history on the page; the samples in it not yet reported still go in
|
||||||
probing. The report-building step is a pure function of host state.
|
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
|
### Backend
|
||||||
|
|
||||||
@@ -229,9 +231,15 @@ 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. Both refusals below answer with the same
|
credentials, so it is bounded instead. Each refusal below answers 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
|
||||||
|
|||||||
@@ -21,6 +21,29 @@ Run `make frontend-viewport-test` in CI as its own step; it is not part of
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 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,7 +5,9 @@ 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"
|
||||||
@@ -18,6 +20,10 @@ 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.
|
||||||
@@ -80,14 +86,25 @@ func (s *Handlers) respondJSON(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// decodeJSON decodes the request body into v. The body is
|
// decodeJSON decodes the request body into v. It returns errNotJSON,
|
||||||
// expected to already be bounded by the body-size middleware, so
|
// reading nothing, unless the request's Content-Type is
|
||||||
// a caller can distinguish an over-limit body from malformed
|
// application/json, with or without parameters such as charset. The
|
||||||
// JSON by testing the returned error for *http.MaxBytesError.
|
// body is expected to already be bounded by the body-size middleware,
|
||||||
|
// 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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
// reports from NetWatch clients, sent as application/json.
|
||||||
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,9 +66,15 @@ 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: 413 when the body exceeded the size limit,
|
// status to send: 415 when the report was not sent as JSON, 413 when
|
||||||
// otherwise 400 for malformed JSON.
|
// the body exceeded the size limit, 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)
|
||||||
|
|||||||
@@ -27,6 +27,17 @@ 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)))
|
||||||
}
|
}
|
||||||
@@ -83,6 +94,7 @@ 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)
|
||||||
|
|
||||||
@@ -103,6 +115,89 @@ 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()
|
||||||
|
|
||||||
@@ -113,6 +208,7 @@ 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)
|
||||||
|
|
||||||
@@ -138,6 +234,7 @@ 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)
|
||||||
|
|
||||||
@@ -161,6 +258,7 @@ 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)
|
||||||
|
|
||||||
@@ -185,6 +283,8 @@ 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
|
||||||
@@ -213,6 +313,7 @@ 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)
|
||||||
|
|
||||||
@@ -245,6 +346,7 @@ 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)
|
||||||
|
|
||||||
@@ -284,6 +386,7 @@ 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)
|
||||||
|
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ 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
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
latencyClass,
|
latencyClass,
|
||||||
latencyHex,
|
latencyHex,
|
||||||
measureLatency,
|
measureLatency,
|
||||||
|
Reporter,
|
||||||
tick,
|
tick,
|
||||||
} from "../../web/src/main.js";
|
} from "../../web/src/main.js";
|
||||||
|
|
||||||
@@ -378,6 +379,98 @@ 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 < > " & and ' in a row's markup as
|
// The page shows < > " & and ' 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`, () => {
|
||||||
|
|||||||
+38
-12
@@ -257,6 +257,14 @@ 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;
|
||||||
@@ -265,6 +273,10 @@ 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;
|
||||||
@@ -280,9 +292,7 @@ export class HostState {
|
|||||||
});
|
});
|
||||||
this._trim();
|
this._trim();
|
||||||
this.lastLatency = result.latency;
|
this.lastLatency = result.latency;
|
||||||
if (result.error === "timeout") this.status = "error";
|
this.status = statusFromError(result.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;
|
||||||
@@ -293,6 +303,18 @@ 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() {
|
||||||
@@ -429,9 +451,12 @@ 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, history }, `since` maps a host url to the Unix-ms
|
// of { name, url, status, clearedHistory, history }, `since` maps a host url
|
||||||
// timestamp of the last sample already reported for it, and `now` is a Date.
|
// to the Unix-ms timestamp of the last sample already reported for it, and
|
||||||
// Only non-paused samples newer than the mark are included. Returns null
|
// `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,
|
||||||
|
// 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 = [];
|
||||||
@@ -440,7 +465,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.history) {
|
for (const p of [...host.clearedHistory, ...host.history]) {
|
||||||
if (p.paused) continue;
|
if (p.paused) continue;
|
||||||
if (p.timestamp <= mark) continue;
|
if (p.timestamp <= mark) continue;
|
||||||
samples.push({
|
samples.push({
|
||||||
@@ -454,7 +479,10 @@ export function buildReport(hosts, clientId, now, since) {
|
|||||||
reportHosts.push({
|
reportHosts.push({
|
||||||
name: host.name,
|
name: host.name,
|
||||||
url: host.url,
|
url: host.url,
|
||||||
status: host.status,
|
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);
|
||||||
@@ -481,7 +509,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.
|
||||||
class Reporter {
|
export class Reporter {
|
||||||
constructor(state, clientId, intervalMs) {
|
constructor(state, clientId, intervalMs) {
|
||||||
this.state = state;
|
this.state = state;
|
||||||
this.clientId = clientId;
|
this.clientId = clientId;
|
||||||
@@ -1502,9 +1530,7 @@ 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.history = [];
|
host.reset();
|
||||||
host.lastLatency = null;
|
|
||||||
host.status = "pending";
|
|
||||||
}
|
}
|
||||||
state.tickCount = 0;
|
state.tickCount = 0;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user