1 Commits
Author SHA1 Message Date
sneak c65d14665c Stopping the server waits for report file writes under way (closes #122)
check / check (push) Canceled after 0s
The report buffer's stop now waits, through a sync.WaitGroup, for
flushLoop and for the write Append starts at 10 MiB before its final
flush. It used to return at once, so the process exiting cut such a
write off and its reports were lost.

A write under way that fails puts its reports back in the buffer, so
the final flush writes them; the stop fails only if that flush fails
too.

Tests hold the periodic write and the one at 10 MiB open in a synctest
bubble and find the stop waiting until each is released; a third makes
the held write fail and finds its report written by the final flush.

Model: opus-5-5
2026-10-14 11:59:00 +00:00
8 changed files with 66 additions and 237 deletions
+21 -29
View File
@@ -5,11 +5,11 @@
# #
# The lint and test phases are the gates: `make lint` (script/lint) # The lint and test phases are the gates: `make lint` (script/lint)
# builds the lint stage alone and `make test` (script/test) the test # builds the lint stage alone and `make test` (script/test) the test
# stage alone, and the two build stages, frontend-build and builder, # stage alone, and the builder stage depends on both, so the image
# depend on both, so the image cannot be built unless they pass. Each # cannot be built unless they pass. Each covers the frontend as well,
# phase covers the frontend as well, through a copy from a node stage. # through a copy from a node stage. Inside them each tool is invoked
# Inside them each tool is invoked directly, never through make or # directly, never through make or script/, whose lint and test are
# script/, whose lint and test are themselves docker builds. # themselves docker builds.
# Frontend lint stage: eslint with the rules in eslint.config.js. The # Frontend lint stage: eslint with the rules in eslint.config.js. The
# lint phase below runs it. # lint phase below runs it.
@@ -35,26 +35,30 @@ RUN golangci-lint run --config .golangci.yml ./...
# phase run it. # phase run it.
COPY --from=frontend-lint /app/yarn.lock /dev/null COPY --from=frontend-lint /app/yarn.lock /dev/null
# Frontend test stage: the unit tests in test/unit/. The test phase # Frontend stage: the unit tests in test/unit/, then the production
# below runs it, and the frontend-build stage starts from it. The tests # build into dist/, which the runtime stage serves. The test phase below
# print a dot each; if any fails, they run again with every test listed, # runs it. The tests print a dot each; if any fails, they run again with
# and the step fails even if that run passes. NODE_OPTIONS chooses the # every test listed, and the step fails even if that run passes.
# reporter because yarn adds its arguments after the test files, where # NODE_OPTIONS chooses the reporter because yarn adds its arguments
# node would take a reporter option for one more file. # after the test files, where node would take a reporter option for one
# more file.
# node:22-alpine as of 2026-02-22 # node:22-alpine as of 2026-02-22
FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34 AS frontend-test FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34 AS frontend
WORKDIR /app WORKDIR /app
COPY package.json yarn.lock ./ COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile RUN yarn install --frozen-lockfile
# vite.config.js reads the commit for the page's footer with git.
RUN apk add --no-cache git
COPY . . COPY . .
RUN NODE_OPTIONS=--test-reporter=dot timeout 90 yarn --silent run test || \ RUN NODE_OPTIONS=--test-reporter=dot timeout 90 yarn --silent run test || \
{ echo "--- Rerunning with every test listed for details ---"; \ { echo "--- Rerunning with every test listed for details ---"; \
NODE_OPTIONS=--test-reporter=spec timeout 90 yarn --silent run test; \ NODE_OPTIONS=--test-reporter=spec timeout 90 yarn --silent run test; \
exit 1; } exit 1; }
RUN yarn build
# Test phase: the backend's tests with the race detector and coverage, # Test phase: the backend's tests with the race detector and coverage,
# and the frontend's through the copy from the frontend-test stage at # and the frontend's through the copy from the frontend stage at the
# the end. -race needs cgo and so a C compiler, which the Debian Go image # end. -race needs cgo and so a C compiler, which the Debian Go image
# ships and the alpine one does not. -timeout 90s is a backstop above # ships and the alpine one does not. -timeout 90s is a backstop above
# the 60-second cap on the suite. The rerun with -v only shows details: # the 60-second cap on the suite. The rerun with -v only shows details:
# the step fails however it ends, because the first run already failed. # the step fails however it ends, because the first run already failed.
@@ -71,21 +75,9 @@ RUN --mount=type=tmpfs,target=/go/pkg/mod \
go test -timeout 90s -race -cover ./... || \ go test -timeout 90s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \ { echo "--- Rerunning with -v for details ---"; \
go test -timeout 90s -race -v ./...; exit 1; } go test -timeout 90s -race -v ./...; exit 1; }
# Nothing is wanted from the frontend-test stage; the copy is what makes # Nothing is wanted from the frontend stage; the copy is what makes
# this phase run its tests. # this phase run its tests.
COPY --from=frontend-test /app/yarn.lock /dev/null COPY --from=frontend /app/yarn.lock /dev/null
# Frontend build stage: the production build into dist/, which the
# runtime stage serves, on the yarn packages and source of the
# frontend-test stage. Nothing is wanted from the two phases; the copies
# are what make BuildKit build them first, so this stage cannot run
# unless lint and test passed.
FROM frontend-test AS frontend-build
COPY --from=lint /src/go.sum /dev/null
COPY --from=test /src/go.sum /dev/null
# vite.config.js reads the commit for the page's footer with git.
RUN apk add --no-cache git
RUN yarn build
# Backend build stage. Nothing is wanted from the two phases; the copies # Backend build stage. Nothing is wanted from the two phases; the copies
# are what make BuildKit build them first, so this stage cannot run # are what make BuildKit build them first, so this stage cannot run
@@ -139,7 +131,7 @@ RUN addgroup -g 1000 -S netwatch && \
RUN rm /etc/nginx/conf.d/default.conf RUN rm /etc/nginx/conf.d/default.conf
COPY configs/nginx.conf /etc/nginx/templates/netwatch.conf.template COPY configs/nginx.conf /etc/nginx/templates/netwatch.conf.template
COPY configs/security-headers.conf /etc/nginx/security-headers.conf COPY configs/security-headers.conf /etc/nginx/security-headers.conf
COPY --from=frontend-build /app/dist /usr/share/nginx/html COPY --from=frontend /app/dist /usr/share/nginx/html
COPY --from=builder /src/netwatch-server /usr/local/bin/netwatch-server COPY --from=builder /src/netwatch-server /usr/local/bin/netwatch-server
COPY bin/entrypoint.sh /usr/local/bin/entrypoint.sh COPY bin/entrypoint.sh /usr/local/bin/entrypoint.sh
+12 -15
View File
@@ -59,9 +59,8 @@ either one is broken. We provide:
- `script/clean` — remove what `script/build` writes - `script/clean` — remove what `script/build` writes
- `script/projectname` — print the project name (used for the Docker image tag) - `script/projectname` — print the project name (used for the Docker image tag)
- `script/test` — build the `test` phase of `Dockerfile` without the cache: the - `script/test` — build the `test` phase of `Dockerfile` without the cache: the
frontend's unit tests in its `frontend-test` stage, and the backend's Go tests frontend's unit tests and production build in its `frontend` stage, and the
with the race detector and coverage. The frontend's production build is left backend's Go tests with the race detector and coverage
to the image build, in the `frontend-build` stage
- `script/lint` — build the `lint` phase of `Dockerfile` without the cache: - `script/lint` — build the `lint` phase of `Dockerfile` without the cache:
eslint in its `frontend-lint` stage, and golangci-lint over the Go packages eslint in its `frontend-lint` stage, and golangci-lint over the Go packages
- `script/fmt` — format all files (writes): prettier over the JavaScript, CSS, - `script/fmt` — format all files (writes): prettier over the JavaScript, CSS,
@@ -79,8 +78,8 @@ either one is broken. We provide:
- `script/frontend-test` — run the unit tests in `test/unit/` on the host with - `script/frontend-test` — run the unit tests in `test/unit/` on the host with
Node's built-in test runner, through the `test` script in `package.json`, and Node's built-in test runner, through the `test` script in `package.json`, and
if any fails, run them again listing every test, and fail; then the production if any fails, run them again listing every test, and fail; then the production
build. Each test run has a 90-second timeout. `make test` runs the same tests build. Each test run has a 90-second timeout. `make test` runs the same in
in Docker, without the production build Docker
- `script/frontend-fmt` — format the JavaScript, CSS, HTML and Markdown with - `script/frontend-fmt` — format the JavaScript, CSS, HTML and Markdown with
prettier (writes) prettier (writes)
- `script/frontend-fmt-check` — check prettier formatting (read-only) - `script/frontend-fmt-check` — check prettier formatting (read-only)
@@ -200,25 +199,23 @@ image, `make run` builds it and runs it on port 8080.
| `DATA_DIR` | `./data/reports` | Directory for compressed reports | | `DATA_DIR` | `./data/reports` | Directory for compressed reports |
| `DATA_DIR_MAX_BYTES` | `1073741824` (1 GiB) | Most bytes of report files kept in `DATA_DIR`, oldest deleted first; see [Report limits](#report-limits) | | `DATA_DIR_MAX_BYTES` | `1073741824` (1 GiB) | Most bytes of report files kept in `DATA_DIR`, oldest deleted first; see [Report limits](#report-limits) |
| `DEBUG` | `false` | Enable debug logging | | `DEBUG` | `false` | Enable debug logging |
| `TRUSTED_PROXIES` | RFC 1918 ranges | Comma-separated CIDRs whose `X-Forwarded-For` / `X-Real-IP` headers are trusted for client IP resolution | | `TRUSTED_PROXIES` | loopback + RFC1918 | Comma-separated CIDRs whose `X-Forwarded-For` / `X-Real-IP` headers are trusted for client IP resolution |
| `REPORTS_PER_MINUTE` | `60` | Reports each client address may send a minute; see [Report limits](#report-limits) | | `REPORTS_PER_MINUTE` | `60` | Reports each client address may send a minute; see [Report limits](#report-limits) |
| `CORS_ALLOWED_ORIGINS` | empty | Comma-separated origins whose pages may call the API; see [CORS](#cors) | | `CORS_ALLOWED_ORIGINS` | empty | Comma-separated origins whose pages may call the API; see [CORS](#cors) |
| `METRICS_USERNAME` | empty | Basic auth user name for `/metrics`; see [Metrics](#metrics) | | `METRICS_USERNAME` | empty | Basic auth user name for `/metrics`; see [Metrics](#metrics) |
| `METRICS_PASSWORD` | empty | Basic auth password for `/metrics`; see [Metrics](#metrics) | | `METRICS_PASSWORD` | empty | Basic auth password for `/metrics`; see [Metrics](#metrics) |
| `SENTRY_DSN` | empty | DSN of the Sentry project to send errors to; see [Sentry](#sentry) | | `SENTRY_DSN` | empty | DSN of the Sentry project to send errors to; see [Sentry](#sentry) |
`TRUSTED_PROXIES` defaults to `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16`. A list `TRUSTED_PROXIES` defaults to
replaces the default; set and empty, the server trusts no proxy. Loopback is not `127.0.0.1/32,::1/128,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16`. The loopback
in the default, so a reverse proxy on the same host has to be named, as in entries cover a reverse proxy on the same host. A request whose direct peer is
`TRUSTED_PROXIES=127.0.0.1/32`. A request whose direct peer is outside this set outside this set has its forwarded headers ignored, and the direct peer is
has its forwarded headers ignored, and the direct peer is logged and logged and rate-limited instead. The container image does not use this default;
rate-limited instead. The container image does not use this default; see see [Deployment](#deployment).
[Deployment](#deployment).
A variable set to a value the server cannot use, such as `PORT=abc`, A variable set to a value the server cannot use, such as `PORT=abc`,
`DEBUG=maybe` or a `BIND_ADDRESS` that is not an IP address, stops it from `DEBUG=maybe` or a `BIND_ADDRESS` that is not an IP address, stops it from
starting, with an error naming the variable. An empty variable counts as unset, starting, with an error naming the variable. An empty variable counts as unset.
except `TRUSTED_PROXIES`.
#### Report storage #### Report storage
-31
View File
@@ -33,31 +33,6 @@ Run `make frontend-viewport-test` in CI as its own step; it is not part of
fail and finds its report written by the final flush. They run in a fail and finds its report written by the final flush. They run in a
`testing/synctest` bubble, so the periodic flush comes without waiting a `testing/synctest` bubble, so the periodic flush comes without waiting a
minute minute
- 2026-10-14: `make test` no longer runs the frontend's production build, which
the tests do not need ([#118](https://git.eeqj.de/sneak/netwatch/issues/118)).
`yarn build` moved out of the stage that runs the frontend's unit tests, now
named `frontend-test`, into a new `frontend-build` stage, which starts from it
and, like the `builder` stage, waits on the lint and test phases, so the image
still cannot be built unless they pass. The runtime stage copies `dist/` from
it, and the `git` that `vite.config.js` reads the commit with is installed
there. No build cache was added
- 2026-10-14: opening the debug log no longer raises the measured latencies
([#131](https://git.eeqj.de/sneak/netwatch/issues/131)). With the panel open,
each line written drew the panel again with every line before it, up to 1000,
and a check that ends while the page draws is timed only when the drawing is
done. Each line is now added at the end of the open panel, its first line is
dropped once it holds more than 1000, and it scrolls to its newest line once a
frame rather than once a line; the whole panel is drawn only when it is
opened. A unit test writes more lines than the panel keeps and checks that the
lines already shown are left as they are
- 2026-10-14: unset, the backend's own `TRUSTED_PROXIES` trusts `10.0.0.0/8`,
`172.16.0.0/12` and `192.168.0.0/16`, and no longer loopback; a list replaces
that; set and empty, it trusts no proxy, where it used to get the default, as
viper reads an empty environment variable as unset
([#129](https://git.eeqj.de/sneak/netwatch/issues/129)). A value it cannot
parse still stops the start, and the image still starts the backend with
`TRUSTED_PROXIES=127.0.0.1/32`. Tests load the config with it unset, empty and
set to a list
- 2026-10-14: while report files cannot be written, `POST /api/v1/reports` - 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 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 ([#123](https://git.eeqj.de/sneak/netwatch/issues/123)). A failed write puts
@@ -523,9 +498,3 @@ Run `make frontend-viewport-test` in CI as its own step; it is not part of
- A configurable host list (an environment variable or a config file) - A configurable host list (an environment variable or a config file)
- Export of the latency history (CSV or JSON) - Export of the latency history (CSV or JSON)
- A notification when the health status changes to DEGRADED - A notification when the health status changes to DEGRADED
- `make test` within the 20-second target
([#118](https://git.eeqj.de/sneak/netwatch/issues/118)): every run compiles
the Go standard library and every dependency with the race detector from
nothing. Whether test builds may keep Go's compiled packages between runs
waits on sneak's ruling in
[prompts#113](https://git.eeqj.de/sneak/prompts/issues/113)
+6 -13
View File
@@ -9,7 +9,6 @@ import (
"math" "math"
"net/netip" "net/netip"
"net/url" "net/url"
"os"
"strconv" "strconv"
"strings" "strings"
@@ -22,11 +21,13 @@ import (
) )
// defaultTrustedProxies lists the networks whose forwarded // defaultTrustedProxies lists the networks whose forwarded
// headers are honoured when TRUSTED_PROXIES is unset: the // headers are honoured by default: IPv4 and IPv6 loopback,
// RFC 1918 ranges. The container image does not use it: // for a reverse proxy on the same host, and the RFC1918
// ranges. The container image does not use it:
// bin/entrypoint.sh gives the server 127.0.0.1/32, since // bin/entrypoint.sh gives the server 127.0.0.1/32, since
// nginx is its only client there. // nginx is its only client there.
const defaultTrustedProxies = "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16" const defaultTrustedProxies = "127.0.0.1/32,::1/128," +
"10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
// Default limits on stored reports; README.md gives the reasons for // Default limits on stored reports; README.md gives the reasons for
// these values. // these values.
@@ -134,14 +135,6 @@ func New(
viper.GetString("DEBUG"), errNotBool) viper.GetString("DEBUG"), errNotBool)
} }
// An empty TRUSTED_PROXIES trusts no proxy, but viper reads an
// empty environment variable as unset and would give the default,
// so the environment is read first.
trustedProxies, set := os.LookupEnv("TRUSTED_PROXIES")
if !set {
trustedProxies = viper.GetString("TRUSTED_PROXIES")
}
s := &Config{ s := &Config{
BindAddress: viper.GetString("BIND_ADDRESS"), BindAddress: viper.GetString("BIND_ADDRESS"),
CORSAllowedOrigins: splitList(viper.GetString("CORS_ALLOWED_ORIGINS")), CORSAllowedOrigins: splitList(viper.GetString("CORS_ALLOWED_ORIGINS")),
@@ -153,7 +146,7 @@ func New(
Port: port, Port: port,
ReportsPerMinute: viper.GetInt("REPORTS_PER_MINUTE"), ReportsPerMinute: viper.GetInt("REPORTS_PER_MINUTE"),
SentryDSN: viper.GetString("SENTRY_DSN"), SentryDSN: viper.GetString("SENTRY_DSN"),
TrustedProxies: splitList(trustedProxies), TrustedProxies: splitList(viper.GetString("TRUSTED_PROXIES")),
log: log, log: log,
params: &params, params: &params,
} }
-58
View File
@@ -1,8 +1,6 @@
package config_test package config_test
import ( import (
"os"
"slices"
"strings" "strings"
"testing" "testing"
@@ -31,62 +29,6 @@ func requireConfigError(t *testing.T, setting string) {
} }
} }
// requireTrustedProxies builds the config as main does and fails the
// test unless its TrustedProxies are want.
func requireTrustedProxies(t *testing.T, want []string) {
t.Helper()
var cfg *config.Config
app := fx.New(
fx.NopLogger,
fx.Provide(globals.New, logger.New, config.New),
fx.Populate(&cfg),
)
err := app.Err()
if err != nil {
t.Fatalf("config error = %v", err)
}
if !slices.Equal(cfg.TrustedProxies, want) {
t.Fatalf("TrustedProxies = %q, want %q", cfg.TrustedProxies, want)
}
}
// TestTrustedProxiesUnsetTrustsRFC1918: unset, the server trusts the
// RFC 1918 ranges and nothing else, loopback included.
func TestTrustedProxiesUnsetTrustsRFC1918(t *testing.T) {
// t.Setenv first, so the variable is put back as it was after the
// test.
t.Setenv("TRUSTED_PROXIES", "")
err := os.Unsetenv("TRUSTED_PROXIES")
if err != nil {
t.Fatal(err)
}
requireTrustedProxies(t,
[]string{"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"})
}
// TestTrustedProxiesEmptyTrustsNothing: set and empty, the server
// trusts no proxy. viper alone would read the empty variable as unset
// and give the default.
func TestTrustedProxiesEmptyTrustsNothing(t *testing.T) {
t.Setenv("TRUSTED_PROXIES", "")
requireTrustedProxies(t, []string{})
}
// TestTrustedProxiesListReplacesDefault: a list is used as given, with
// none of the default ranges added.
func TestTrustedProxiesListReplacesDefault(t *testing.T) {
t.Setenv("TRUSTED_PROXIES", "172.17.0.1/32, 2001:db8::/32")
requireTrustedProxies(t, []string{"172.17.0.1/32", "2001:db8::/32"})
}
// TestSettingsLoadAsGiven: valid values pass the checks and are used // TestSettingsLoadAsGiven: valid values pass the checks and are used
// as given. bin/entrypoint.sh starts the server with these // as given. bin/entrypoint.sh starts the server with these
// BIND_ADDRESS and PORT values. // BIND_ADDRESS and PORT values.
+3 -4
View File
@@ -2,10 +2,9 @@
# script/frontend-test: run the frontend test suite on the host: the # script/frontend-test: run the frontend test suite on the host: the
# unit tests in test/unit/, through the test script in package.json, # unit tests in test/unit/, through the test script in package.json,
# then the production build, which fails on broken code. make test runs # then the production build, which fails on broken code. make test runs
# the same tests in the frontend-test stage of Dockerfile, and the image # the same in the frontend stage of Dockerfile. The tests print a dot
# build runs the production build in its frontend-build stage. The tests # each; if any fails, they run again with every test listed, and the
# print a dot each; if any fails, they run again with every test listed, # script fails even if that run passes. NODE_OPTIONS chooses the
# and the script fails even if that run passes. NODE_OPTIONS chooses the
# reporter because yarn adds its arguments after the test files, where # reporter because yarn adds its arguments after the test files, where
# node would take a reporter option for one more file. # node would take a reporter option for one more file.
set -eu set -eu
+8 -46
View File
@@ -13,22 +13,20 @@ import {
humanDuration, humanDuration,
latencyClass, latencyClass,
latencyHex, latencyHex,
log,
measureLatency, measureLatency,
Reporter, Reporter,
tick, tick,
} from "../../web/src/main.js"; } from "../../web/src/main.js";
// There is no page here, so the tests stand in for it. The debug log looks // There is no page here, so the tests stand in for it. The debug log looks
// for its panel by id and finds none, an element the page creates is a // for its panel by id and finds none. Each element of a host's row that
// plain object, and no frame the page asks for ever comes. Each element of // tick or greyOutUI draws into is a plain object, made the first time a
// a host's row that tick or greyOutUI draws into is a plain object, made // test looks it up and kept in elements under its selector until the next
// the first time a test looks it up and kept in elements under its // test starts. As on a page, writing its text replaces its markup; the
// selector until the next test starts. As on a page, writing its text // status dot greyOutUI looks for in it is not there. Drawing a sparkline
// replaces its markup; the status dot greyOutUI looks for in it is not // does nothing; it looks for the pixel ratio on window and finds none. In
// there. Drawing a sparkline does nothing; it looks for the pixel ratio on // each test, console.error and console.info print nothing and keep what
// window and finds none. In each test, console.error and console.info // they are given.
// print nothing and keep what they are given.
const doNothing = () => {}; const doNothing = () => {};
let elements; let elements;
beforeEach((t) => { beforeEach((t) => {
@@ -48,11 +46,8 @@ const canvasContext = {
arc: doNothing, arc: doNothing,
}; };
globalThis.window = {}; globalThis.window = {};
globalThis.requestAnimationFrame = doNothing;
globalThis.cancelAnimationFrame = doNothing;
globalThis.document = { globalThis.document = {
getElementById: () => null, getElementById: () => null,
createElement: () => ({}),
querySelector: (selector) => querySelector: (selector) =>
(elements[selector] ??= { (elements[selector] ??= {
getContext: () => canvasContext, getContext: () => canvasContext,
@@ -476,39 +471,6 @@ test("of the checks interval changes cleared before they were reported, the next
assert.equal(sent.at(-1).t, 120000); assert.equal(sent.at(-1).t, 120000);
}); });
// panel stands in for the open debug log panel, and for the element in it
// that holds its lines, whichever the page looks up by id; the lines are
// kept in order in lines. Drawing the whole panel, as opening it does,
// replaces every line with a new one.
test("with the debug log open, writing more lines than it keeps adds each to the panel and leaves the lines already shown as they are", (t) => {
const lines = [];
const panel = {
classList: { contains: () => false },
children: lines,
get firstElementChild() {
return lines[0];
},
append: (line) => lines.push(line),
removeChild: (line) => lines.splice(lines.indexOf(line), 1),
replaceChildren: (...newLines) =>
lines.splice(0, lines.length, ...newLines),
};
t.mock.method(globalThis.document, "getElementById", () => panel);
const kept = CONFIG.maxLogEntries;
for (let i = 1; i <= kept; i++) log.info(`line ${i}`);
const shown = [...lines];
for (let i = kept + 1; i <= kept + 5; i++) log.info(`line ${i}`);
assert.deepEqual(
lines.map((line) => line.textContent.match(/line \d+$/)?.[0]),
Array.from({ length: kept }, (_, i) => `line ${i + 6}`),
);
// The first five lines shown were dropped, and the rest are the same
// lines, not drawn again.
shown.slice(5).forEach((line, i) => assert.equal(lines[i], line));
});
// 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`, () => {
+10 -35
View File
@@ -144,15 +144,13 @@ const WAN_HOSTS = [
const debugLog = []; const debugLog = [];
export const log = (() => { const log = (() => {
function append(level, message) { function append(level, message) {
const entry = { timestamp: new Date(), level, message }; debugLog.push({ timestamp: new Date(), level, message });
debugLog.push(entry);
if (debugLog.length > CONFIG.maxLogEntries) if (debugLog.length > CONFIG.maxLogEntries)
debugLog.splice(0, debugLog.length - CONFIG.maxLogEntries); debugLog.splice(0, debugLog.length - CONFIG.maxLogEntries);
const panel = document.getElementById("debug-panel"); const panel = document.getElementById("debug-panel");
if (panel && !panel.classList.contains("hidden")) if (panel && !panel.classList.contains("hidden")) renderDebugLog();
appendDebugLogLine(entry);
} }
return { return {
error: (msg) => append("error", msg), error: (msg) => append("error", msg),
@@ -1109,8 +1107,9 @@ function updateHealthBox(state) {
// --- Debug Log Renderer ------------------------------------------------------ // --- Debug Log Renderer ------------------------------------------------------
// One line of the debug log panel, its message set as text. function renderDebugLog() {
function debugLogLine(entry) { const el = document.getElementById("debug-log");
if (!el) return;
const levelColors = { const levelColors = {
error: "text-red-400", error: "text-red-400",
warning: "text-orange-400", warning: "text-orange-400",
@@ -1118,43 +1117,19 @@ function debugLogLine(entry) {
info: "text-gray-300", info: "text-gray-300",
debug: "text-gray-500", debug: "text-gray-500",
}; };
el.replaceChildren(
...debugLog.map((entry) => {
const ts = formatUTCTimestamp(entry.timestamp); const ts = formatUTCTimestamp(entry.timestamp);
const lvl = entry.level.toUpperCase().padEnd(7); const lvl = entry.level.toUpperCase().padEnd(7);
const line = document.createElement("div"); const line = document.createElement("div");
line.className = levelColors[entry.level] || "text-gray-400"; line.className = levelColors[entry.level] || "text-gray-400";
line.textContent = `${ts} ${lvl} ${entry.message}`; line.textContent = `${ts} ${lvl} ${entry.message}`;
return line; return line;
} }),
);
// Draws every line of the debug log into the panel, when it is opened.
function renderDebugLog() {
const el = document.getElementById("debug-log");
if (!el) return;
el.replaceChildren(...debugLog.map((entry) => debugLogLine(entry)));
el.scrollTop = el.scrollHeight; el.scrollTop = el.scrollHeight;
} }
// The request that scrolls the open panel to its newest line in the next
// frame. One waits at most, also in a hidden tab, where frames do not run.
let debugLogScrollFrame = 0;
// Adds entry to the open panel as its last line, and drops the first line
// once the panel holds more than maxLogEntries. A check that ends while the
// page is busy is timed only when the page is done, so the lines already
// shown are left as they are, and the panel is scrolled once a frame
// rather than once a line: scrolling it lays out all of its lines again.
function appendDebugLogLine(entry) {
const el = document.getElementById("debug-log");
if (!el) return;
el.append(debugLogLine(entry));
if (el.children.length > CONFIG.maxLogEntries)
el.removeChild(el.firstElementChild);
cancelAnimationFrame(debugLogScrollFrame);
debugLogScrollFrame = requestAnimationFrame(() => {
el.scrollTop = el.scrollHeight;
});
}
// --- Sorting ----------------------------------------------------------------- // --- Sorting -----------------------------------------------------------------
// Sort WAN hosts: pinned hosts first (alphabetically), then unpinned hosts // Sort WAN hosts: pinned hosts first (alphabetically), then unpinned hosts