Compare commits
2
Commits
8b5ff49c10
...
f217d49c0e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f217d49c0e | ||
|
|
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
|
||||
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
|
||||
|
||||
@@ -226,6 +228,14 @@ the numbers: its file, if it was created, is removed. The file stays, counted
|
||||
toward `DATA_DIR_MAX_BYTES` from the next start, only if removing it fails too.
|
||||
Files are created with `O_EXCL` to prevent overwrites.
|
||||
|
||||
The reports of a failed write stay in memory, and the next flush, within a
|
||||
minute, tries again to write them. From the failed write until a write succeeds,
|
||||
`POST /api/v1/reports` refuses every report with 500 and keeps nothing of it, so
|
||||
the page keeps its samples and sends them again at its next report. Reports
|
||||
already answered 200 are lost only if the server stops before a write succeeds;
|
||||
the final flush at stop tries them too, and if it fails, the process exits
|
||||
non-zero.
|
||||
|
||||
#### Report limits
|
||||
|
||||
`POST /api/v1/reports` takes reports from anyone who can reach it, without
|
||||
@@ -244,16 +254,16 @@ credentials, so it is bounded instead. Both refusals below answer with the same
|
||||
`X-RateLimit-Reset` headers.
|
||||
- **Size cap.** The report files in `DATA_DIR` may total at most
|
||||
`DATA_DIR_MAX_BYTES`, counting the files already there at start. Reports
|
||||
waiting in memory count at their uncompressed size until they are written;
|
||||
those lost to a failed write stop counting, and the part of its file written
|
||||
is removed. When a report would take the total past the cap, the oldest report
|
||||
files are deleted to make room, and each deletion is logged with the file's
|
||||
name and size; a file still being written is never deleted. A report is
|
||||
refused with 507, and nothing of it is stored, only when the reports waiting
|
||||
to be written fill the cap on their own, and then no file is deleted. At
|
||||
start, report files past the cap, as after lowering it, are deleted the same
|
||||
way. So the cap is how much of the newest reports is kept: the default of 1
|
||||
GiB is small enough for any host; set it to the space you can give `DATA_DIR`.
|
||||
waiting in memory count at their uncompressed size until they are written,
|
||||
those of a failed write included; the part of its file written is removed.
|
||||
When a report would take the total past the cap, the oldest report files are
|
||||
deleted to make room, and each deletion is logged with the file's name and
|
||||
size; a file still being written is never deleted. A report is refused with
|
||||
507, and nothing of it is stored, only when the reports waiting to be written
|
||||
fill the cap on their own, and then no file is deleted. At start, report files
|
||||
past the cap, as after lowering it, are deleted the same way. So the cap is
|
||||
how much of the newest reports is kept: the default of 1 GiB is small enough
|
||||
for any host; set it to the space you can give `DATA_DIR`.
|
||||
|
||||
#### CORS
|
||||
|
||||
|
||||
@@ -21,6 +21,29 @@ Run `make frontend-viewport-test` in CI as its own step; it is not part of
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-10-14: while report files cannot be written, `POST /api/v1/reports`
|
||||
answers 500, and storing recovers once writing works again, with no restart
|
||||
([#123](https://git.eeqj.de/sneak/netwatch/issues/123)). A failed write puts
|
||||
its reports back in the report buffer, still counted against
|
||||
`DATA_DIR_MAX_BYTES`, where they used to be lost; the next flush, within a
|
||||
minute, tries them again. Until a write succeeds, every report is refused and
|
||||
nothing of it is kept, so the buffer does not grow while writes fail. A test
|
||||
posts reports through the report handler before, during and after a failure;
|
||||
others check that a report appended while a write runs is kept, behind that
|
||||
write's own, when the write fails, and that the cap still holds exactly once a
|
||||
failed write's reports are written
|
||||
- 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
|
||||
([#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/`,
|
||||
|
||||
@@ -87,7 +87,7 @@ func (s *Handlers) decodeErrorStatus(err error) int {
|
||||
|
||||
// appendErrorStatus logs a failure to store a report and returns
|
||||
// the status to send: 507 when the reports waiting to be written fill
|
||||
// the size cap, otherwise 500.
|
||||
// the size cap, otherwise 500, as when report files cannot be written.
|
||||
func (s *Handlers) appendErrorStatus(err error) int {
|
||||
if errors.Is(err, reportbuf.ErrFull) {
|
||||
s.log.Warn("report refused: " +
|
||||
|
||||
@@ -91,6 +91,11 @@ type Buffer struct {
|
||||
// of the report files in dataDir, plus the reports waiting to
|
||||
// be written to one at their uncompressed size.
|
||||
usedBytes int64
|
||||
// writeErr is the error of the last write of a report file to
|
||||
// finish, nil if it succeeded or none has. While it is set, Append
|
||||
// refuses reports, and the reports of that write wait in buf for
|
||||
// the next flush.
|
||||
writeErr error
|
||||
}
|
||||
|
||||
// New creates a Buffer and registers lifecycle hooks to
|
||||
@@ -167,11 +172,12 @@ func New(
|
||||
}
|
||||
|
||||
// Append marshals v as a single JSON line and appends it to
|
||||
// the buffer. If the line would take usedBytes past maxBytes, the
|
||||
// oldest report files are deleted to make room; it stores nothing
|
||||
// and returns ErrFull if that cannot make room. If the buffer
|
||||
// reaches the size threshold, it is drained and written to disk
|
||||
// asynchronously.
|
||||
// the buffer. It stores nothing and returns an error if the last
|
||||
// write of a report file failed, until a write succeeds again. If the
|
||||
// line would take usedBytes past maxBytes, the oldest report files are
|
||||
// deleted to make room; it stores nothing and returns ErrFull if that
|
||||
// cannot make room. If the buffer reaches the size threshold, it is
|
||||
// drained and written to disk asynchronously.
|
||||
func (b *Buffer) Append(v any) error {
|
||||
line, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
@@ -182,6 +188,13 @@ func (b *Buffer) Append(v any) error {
|
||||
|
||||
b.mu.Lock()
|
||||
|
||||
if b.writeErr != nil {
|
||||
err = fmt.Errorf("report files cannot be written: %w", b.writeErr)
|
||||
b.mu.Unlock()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
b.deleteOldestFiles(lineBytes)
|
||||
|
||||
if b.usedBytes+lineBytes > b.maxBytes {
|
||||
@@ -291,7 +304,8 @@ func (b *Buffer) drainBuf() []byte {
|
||||
}
|
||||
|
||||
// writeFile writes data, reports drained from the buffer, to a new
|
||||
// timestamped zstd-compressed JSONL file in the data directory.
|
||||
// timestamped zstd-compressed JSONL file in the data directory. If the
|
||||
// write fails, it puts the reports back in the buffer.
|
||||
func (b *Buffer) writeFile(data []byte) error {
|
||||
// The timestamp comes first, so the names sort by time; the number
|
||||
// after it tells apart files named in the same millisecond.
|
||||
@@ -300,19 +314,27 @@ func (b *Buffer) writeFile(data []byte) error {
|
||||
|
||||
size, err := b.createFile(filepath.Join(b.dataDir, name), data)
|
||||
|
||||
// The reports no longer wait to be written, so they stop counting
|
||||
// at their uncompressed size. If the write failed they are lost;
|
||||
// otherwise they count as the file, which from here on may be
|
||||
// deleted to make room.
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
|
||||
b.usedBytes -= int64(len(data))
|
||||
b.writeErr = err
|
||||
|
||||
if err != nil {
|
||||
// The reports go back in front of any appended since, for the
|
||||
// next flush to write, and still count at their uncompressed
|
||||
// size.
|
||||
appended := bytes.Clone(b.buf.Bytes())
|
||||
b.buf.Reset()
|
||||
b.buf.Write(data)
|
||||
b.buf.Write(appended)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// The reports no longer wait to be written, so they stop counting
|
||||
// at their uncompressed size and count as the file, which from here
|
||||
// on may be deleted to make room.
|
||||
b.usedBytes -= int64(len(data))
|
||||
b.usedBytes += size
|
||||
b.filesBytes += size
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
@@ -17,6 +19,8 @@ import (
|
||||
|
||||
"sneak.berlin/go/netwatch/internal/config"
|
||||
"sneak.berlin/go/netwatch/internal/globals"
|
||||
"sneak.berlin/go/netwatch/internal/handlers"
|
||||
"sneak.berlin/go/netwatch/internal/healthcheck"
|
||||
"sneak.berlin/go/netwatch/internal/logger"
|
||||
"sneak.berlin/go/netwatch/internal/reportbuf"
|
||||
|
||||
@@ -507,13 +511,17 @@ func TestFileBeingWrittenIsNeverDeleted(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestFailedWriteStopsCounting makes a write fail once its file is
|
||||
// created. Its reports are lost, so they stop counting, and the part of
|
||||
// the file written is removed, so it takes no room.
|
||||
func TestFailedWriteStopsCounting(t *testing.T) {
|
||||
report := map[string]string{"id": "failed"}
|
||||
// TestFailedWriteKeepsItsReports makes a write fail once its file is
|
||||
// created, under a cap with room for one report. The part of the file
|
||||
// written is removed, so it takes no room, and the report waits in
|
||||
// memory for the next flush, which writes it. Once written, it counts
|
||||
// as its file and no longer as a report waiting, so the cap still holds
|
||||
// exactly: one more report is taken and the one after it is refused.
|
||||
func TestFailedWriteKeepsItsReports(t *testing.T) {
|
||||
report := map[string]string{"id": "kept"}
|
||||
dir := t.TempDir()
|
||||
|
||||
t.Setenv("DATA_DIR", t.TempDir())
|
||||
t.Setenv("DATA_DIR", dir)
|
||||
t.Setenv("DATA_DIR_MAX_BYTES", strconv.Itoa(lineBytes(t, report)))
|
||||
|
||||
buf := startBuffer(t)
|
||||
@@ -544,10 +552,213 @@ func TestFailedWriteStopsCounting(t *testing.T) {
|
||||
// Writes from here on, the final one at stop included, succeed.
|
||||
buf.OnFileCreated(func(*os.File) {})
|
||||
|
||||
err = buf.Flush()
|
||||
if err != nil {
|
||||
t.Fatalf("flush after the failed write: %v", err)
|
||||
}
|
||||
|
||||
files, err := readReportFiles(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("read report files: %v", err)
|
||||
}
|
||||
|
||||
want := `{"id":"kept"}` + "\n"
|
||||
if len(files) != 1 || files[0] != want {
|
||||
t.Fatalf("report files = %q, want one holding %q", files, want)
|
||||
}
|
||||
|
||||
// The file is deleted to make room if need be.
|
||||
err = buf.Append(report)
|
||||
if err != nil {
|
||||
t.Fatalf("report after the failed write: %v", err)
|
||||
t.Fatalf("report after the written one: %v", err)
|
||||
}
|
||||
|
||||
err = buf.Append(report)
|
||||
if !errors.Is(err, reportbuf.ErrFull) {
|
||||
t.Fatalf("report past the cap: error = %v, want ErrFull", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFailedWriteKeepsReportsAppendedDuringIt holds a write open,
|
||||
// appends a report while it is held, then makes the held write fail.
|
||||
// Both reports are kept, and the next flush writes them in the order
|
||||
// they were appended, the held write's first.
|
||||
func TestFailedWriteKeepsReportsAppendedDuringIt(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("DATA_DIR", dir)
|
||||
|
||||
buf := startBuffer(t)
|
||||
|
||||
created := make(chan *os.File)
|
||||
release := make(chan struct{})
|
||||
|
||||
buf.OnFileCreated(func(f *os.File) {
|
||||
created <- f
|
||||
|
||||
<-release
|
||||
})
|
||||
|
||||
err := buf.Append(map[string]string{"id": "held"})
|
||||
if err != nil {
|
||||
t.Fatalf("report of the held write: %v", err)
|
||||
}
|
||||
|
||||
flushed := make(chan error)
|
||||
|
||||
go func() { flushed <- buf.Flush() }()
|
||||
|
||||
held := <-created
|
||||
|
||||
// Errorf, not Fatalf, until the held write is released, so that a
|
||||
// failure here does not leave it held.
|
||||
err = buf.Append(map[string]string{"id": "appended"})
|
||||
if err != nil {
|
||||
t.Errorf("report appended while the write was held: %v", err)
|
||||
}
|
||||
|
||||
// Closing the file under the write makes the write fail.
|
||||
_ = held.Close()
|
||||
|
||||
close(release)
|
||||
|
||||
err = <-flushed
|
||||
if err == nil {
|
||||
t.Fatal("held write succeeded, though its file was closed under it")
|
||||
}
|
||||
|
||||
// Writes from here on, the final one at stop included, succeed.
|
||||
buf.OnFileCreated(func(*os.File) {})
|
||||
|
||||
err = buf.Flush()
|
||||
if err != nil {
|
||||
t.Fatalf("flush after the failed write: %v", err)
|
||||
}
|
||||
|
||||
files, err := readReportFiles(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("read report files: %v", err)
|
||||
}
|
||||
|
||||
want := `{"id":"held"}` + "\n" + `{"id":"appended"}` + "\n"
|
||||
if len(files) != 1 || files[0] != want {
|
||||
t.Fatalf("report files = %q, want one holding %q", files, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReportsRefusedWhileWritesFail posts reports through the report
|
||||
// handler, as the page does. Once a write has failed, a report is
|
||||
// refused with 500 until a write succeeds again; the reports taken
|
||||
// before and after are stored, and the one refused is not.
|
||||
func TestReportsRefusedWhileWritesFail(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("DATA_DIR", dir)
|
||||
|
||||
buf, h := startBufferWithHandlers(t)
|
||||
|
||||
if code := postReport(t, h, "before"); code != http.StatusOK {
|
||||
t.Fatalf("report before the failed write: status = %d, want %d",
|
||||
code, http.StatusOK)
|
||||
}
|
||||
|
||||
// With the data directory gone, the write has nowhere to go.
|
||||
err := os.Remove(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("remove data dir: %v", err)
|
||||
}
|
||||
|
||||
err = buf.Flush()
|
||||
if err == nil {
|
||||
t.Fatal("flush succeeded with the data directory gone")
|
||||
}
|
||||
|
||||
if code := postReport(t, h, "refused"); code != http.StatusInternalServerError {
|
||||
t.Fatalf("report after the failed write: status = %d, want %d",
|
||||
code, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
err = os.Mkdir(dir, 0o750)
|
||||
if err != nil {
|
||||
t.Fatalf("make data dir: %v", err)
|
||||
}
|
||||
|
||||
err = buf.Flush()
|
||||
if err != nil {
|
||||
t.Fatalf("flush with the data directory back: %v", err)
|
||||
}
|
||||
|
||||
if code := postReport(t, h, "after"); code != http.StatusOK {
|
||||
t.Fatalf("report after a write succeeded: status = %d, want %d",
|
||||
code, http.StatusOK)
|
||||
}
|
||||
|
||||
err = buf.Flush()
|
||||
if err != nil {
|
||||
t.Fatalf("flush of the report after: %v", err)
|
||||
}
|
||||
|
||||
files, err := readReportFiles(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("read report files: %v", err)
|
||||
}
|
||||
|
||||
stored := strings.Join(files, "")
|
||||
|
||||
for _, clientID := range []string{"before", "after"} {
|
||||
if !strings.Contains(stored, `"clientId":"`+clientID+`"`) {
|
||||
t.Errorf("report %q not stored", clientID)
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(stored, `"clientId":"refused"`) {
|
||||
t.Error("refused report stored")
|
||||
}
|
||||
}
|
||||
|
||||
// startBufferWithHandlers starts a Buffer and the Handlers that store
|
||||
// reports in it through fx, as main does, with the DATA_DIR the calling
|
||||
// test has set.
|
||||
func startBufferWithHandlers(
|
||||
t *testing.T,
|
||||
) (*reportbuf.Buffer, *handlers.Handlers) {
|
||||
t.Helper()
|
||||
|
||||
var (
|
||||
buf *reportbuf.Buffer
|
||||
h *handlers.Handlers
|
||||
)
|
||||
|
||||
app := fxtest.New(t,
|
||||
fx.Provide(
|
||||
config.New,
|
||||
globals.New,
|
||||
handlers.New,
|
||||
healthcheck.New,
|
||||
logger.New,
|
||||
reportbuf.New,
|
||||
),
|
||||
fx.Populate(&buf, &h),
|
||||
)
|
||||
|
||||
app.RequireStart()
|
||||
t.Cleanup(app.RequireStop)
|
||||
|
||||
return buf, h
|
||||
}
|
||||
|
||||
// postReport posts a report from clientID to the report handler, as the
|
||||
// page does, and returns the status of the answer.
|
||||
func postReport(t *testing.T, h *handlers.Handlers, clientID string) int {
|
||||
t.Helper()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequestWithContext(t.Context(),
|
||||
http.MethodPost, "/api/v1/reports",
|
||||
strings.NewReader(`{"clientId":"`+clientID+`","hosts":[]}`),
|
||||
)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
h.HandleReport().ServeHTTP(rec, req)
|
||||
|
||||
return rec.Code
|
||||
}
|
||||
|
||||
// TestConcurrentAppendsStopAtCap appends from many goroutines at once
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
latencyClass,
|
||||
latencyHex,
|
||||
measureLatency,
|
||||
Reporter,
|
||||
tick,
|
||||
} 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"), []);
|
||||
});
|
||||
|
||||
// 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
|
||||
// < > " & and '.
|
||||
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 {
|
||||
constructor(host, pinned = false) {
|
||||
this.name = host.name;
|
||||
@@ -265,6 +273,10 @@ export class HostState {
|
||||
// error }, or a round skipped while paused, { timestamp,
|
||||
// latency: null, paused: true }.
|
||||
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.status = "pending"; // 'online' | 'offline' | 'error' | 'pending'
|
||||
this.pinned = pinned;
|
||||
@@ -280,9 +292,7 @@ export class HostState {
|
||||
});
|
||||
this._trim();
|
||||
this.lastLatency = result.latency;
|
||||
if (result.error === "timeout") this.status = "error";
|
||||
else if (result.error) this.status = "offline";
|
||||
else this.status = "online";
|
||||
this.status = statusFromError(result.error);
|
||||
this.consecutiveFailures = result.error
|
||||
? this.consecutiveFailures + 1
|
||||
: 0;
|
||||
@@ -293,6 +303,18 @@ export class HostState {
|
||||
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
|
||||
// that got an answer.
|
||||
historyStats() {
|
||||
@@ -429,9 +451,12 @@ 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
|
||||
// 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. 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.
|
||||
export function buildReport(hosts, clientId, now, since) {
|
||||
const reportHosts = [];
|
||||
@@ -440,7 +465,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({
|
||||
@@ -454,7 +479,10 @@ export function buildReport(hosts, clientId, now, since) {
|
||||
reportHosts.push({
|
||||
name: host.name,
|
||||
url: host.url,
|
||||
status: host.status,
|
||||
status:
|
||||
host.status === "pending"
|
||||
? statusFromError(samples.at(-1).error)
|
||||
: host.status,
|
||||
history: samples,
|
||||
});
|
||||
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
|
||||
// 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,9 +1530,7 @@ async function init() {
|
||||
|
||||
// Clear all host history and reset state
|
||||
for (const host of state.allHosts) {
|
||||
host.history = [];
|
||||
host.lastLatency = null;
|
||||
host.status = "pending";
|
||||
host.reset();
|
||||
}
|
||||
state.tickCount = 0;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user