1 Commits
Author SHA1 Message Date
sneak 2b2f035410 Health box and summary update as each WAN check ends (closes #206)
check / check (push) Waiting to run
The health box and the summary were drawn only once every check in a
round had ended, so a target that never answers held them back until
its timeout, after the rows already showed the network was back. tick
now redraws both with each WAN host's row. The "Health:" debug log line
moved from updateHealthBox to the end of the round, so it is still
written once per round, and still only once a WAN host's check is
recorded.

Model: opus-5-5
2026-11-11 21:00:53 +00:00
7 changed files with 57 additions and 243 deletions

No files matched your search

+11 -26
View File
@@ -22,7 +22,7 @@ make setup
# Serve the page from the Vite dev server # Serve the page from the Vite dev server
make dev make dev
# Build the page and netwatch-server, then run only the server, on port 8080 # Build the page and netwatch-server, then run the server on port 8080
make run make run
# Run the tests, both linters and the format check # Run the tests, both linters and the format check
@@ -36,16 +36,9 @@ make docker
docker run -p 8080:8080 netwatch docker run -p 8080:8080 netwatch
``` ```
After `make dev`, open the page at `http://localhost:5173/`. From the image, it `make check` and `make docker` need Docker. `make run` starts `netwatch-server`
is at `http://localhost:8080/`. on port 8080 with its defaults, so the page's reports are stored in
`data/reports`. `make dev` passes the page's `/api` requests there.
`make run` serves no page: `http://localhost:8080/` answers
`404 page not found`. It starts `netwatch-server` on port 8080 with its
defaults, which stores reports in `data/reports`. `make dev` passes the page's
`/api` requests there, so run `make run` in a second terminal to store the
page's reports.
`make check` and `make docker` need Docker.
## The page ## The page
@@ -64,7 +57,7 @@ The page checks 26 hosts on the internet, the WAN hosts:
- the Anthropic API and the OpenAI API - the Anthropic API and the OpenAI API
- the AWS Console, the Google Cloud Console and Microsoft Azure - the AWS Console, the Google Cloud Console and Microsoft Azure
- Cloudflare, Fastly, Akamai, Google, GitHub and Backblaze B2 - Cloudflare, Fastly, Akamai, Google, GitHub and Backblaze B2
- 8 S3 regional endpoints: Cape Town, London, Mumbai, Tokyo, Singapore, Sydney, - 8 S3 regional endpoints: Cape Town, London, Bahrain, Tokyo, Singapore, Sydney,
Oregon and São Paulo Oregon and São Paulo
- 6 Hetzner speed-test servers: Nuremberg, Falkenstein, Helsinki, Ashburn, - 6 Hetzner speed-test servers: Nuremberg, Falkenstein, Helsinki, Ashburn,
Hillsboro and Singapore Hillsboro and Singapore
@@ -123,10 +116,9 @@ half second. As soon as one answers, a new round starts.
### Rows, colors and controls ### Rows, colors and controls
A figure and its line in the graph take the color of the legend in the footer, A figure and its line in the graph take the color of the legend in the footer,
from green under 50 ms to red from 500 ms up. The dot beside the host's name from green under 50 ms to red from 500 ms up; gray is no answer. The dot beside
takes the figure's color. A failed check has a gray figure, and a faint red band the host's name takes the same color. The graph's axis ends at 1000 ms; a higher
in the graph. The graph's axis ends at 1000 ms; a higher latency is drawn at the latency is drawn at the top, and the figure still shows its real value.
top, and the figure still shows its real value.
From time to time, the page sorts the WAN rows by their latest latency: fastest From time to time, the page sorts the WAN rows by their latest latency: fastest
first, failed last. The pin button at the left of a row keeps the row at the first, failed last. The pin button at the left of a row keeps the row at the
@@ -151,9 +143,7 @@ fields:
and `history`, its samples. A sample has the check's time `t` in Unix and `history`, its samples. A sample has the check's time `t` in Unix
milliseconds, its `latency` (`null` for a failed check) and its `error` milliseconds, its `latency` (`null` for a failed check) and its `error`
(`"timeout"`, `"unreachable"` or `null`). (`"timeout"`, `"unreachable"` or `null`).
- `timestamp`: the time of the report, as an ISO 8601 string such as - `timestamp`: the time of the report
`2026-11-11T20:01:35.685Z`. A report whose `timestamp` is a number is refused
with 400.
Each report carries only the samples no earlier report has delivered. A sample Each report carries only the samples no earlier report has delivered. A sample
waits as long as the history holds it: the last 100 rounds of each host. An waits as long as the history holds it: the last 100 rounds of each host. An
@@ -231,9 +221,8 @@ reason is in its log.
`netwatch-server` reads its settings from environment variables. It also reads a `netwatch-server` reads its settings from environment variables. It also reads a
`.env` file in the directory it starts in, and a YAML file named `.env` file in the directory it starts in, and a YAML file named
`netwatch-server.yaml` in `/etc/netwatch-server/` or `netwatch-server.yaml` in `/etc/netwatch-server/` or
`~/.config/netwatch-server/`, when one exists. In the YAML file, each key is a `~/.config/netwatch-server/`, when one exists. A variable in the environment
variable's name, in upper or lower case: `PORT: 8090` sets the port. A variable wins over a file.
in the environment wins over a file.
The server does not start when a variable holds a value it cannot use. Its error The server does not start when a variable holds a value it cannot use. Its error
names the variable. An empty variable counts as unset, except `TRUSTED_PROXIES`. names the variable. An empty variable counts as unset, except `TRUSTED_PROXIES`.
@@ -350,10 +339,6 @@ The backend logs JSON to stdout, or plain text when stdout is a terminal. Each
request is logged with its method, URL, status, client address and duration. request is logged with its method, URL, status, client address and duration.
Every value taken from a request is cut to 128 bytes before it is logged. Every value taken from a request is cut to 128 bytes before it is logged.
The steps `fx` takes to start and stop the backend are logged at debug level, so
they show only with `DEBUG` set. When the backend refuses a setting, its last
log line is one error that names the setting and the reason.
## Design ## Design
The repository holds the page, the backend and the image: The repository holds the page, the backend and the image:
+10 -126
View File
@@ -36,103 +36,16 @@ func TestMain(m *testing.M) {
os.Exit(m.Run()) os.Exit(m.Run())
} }
// TestOutputIsJSONWithoutFxLines: off a terminal, every line the server // TestOutputIsJSON: off a terminal, every line the server writes from
// writes from start to stop is JSON, and without DEBUG none of them is // start to stop is JSON, fx's own lines included.
// one of fx's. func TestOutputIsJSON(t *testing.T) {
func TestOutputIsJSONWithoutFxLines(t *testing.T) {
t.Parallel() t.Parallel()
stdout, stderr := runServerUntilStopped(t)
requireJSONLines(t, stdout, stderr)
if !strings.Contains(stdout.String(), `"msg":"starting"`) {
t.Fatalf("no startup line in stdout:\n%s", stdout)
}
if strings.Contains(stdout.String(), `"msg":"started"`) {
t.Fatalf("fx line in stdout without DEBUG:\n%s", stdout)
}
}
// TestDebugLogsFxLines: with DEBUG set, fx's lines are logged, as
// JSON, from the start to the signal that stops the server.
func TestDebugLogsFxLines(t *testing.T) {
t.Parallel()
stdout, stderr := runServerUntilStopped(t, "DEBUG=1")
requireJSONLines(t, stdout, stderr)
for _, msg := range []string{"started", "received signal"} {
if !strings.Contains(stdout.String(), `"msg":"`+msg+`"`) {
t.Fatalf("no fx line %q in stdout with DEBUG set:\n%s",
msg, stdout)
}
}
}
// TestRefusedSettingIsLastLine: a setting the server refuses stops the
// start, and the one error line logged is the last, naming the setting
// and why, without fx's text around it.
func TestRefusedSettingIsLastLine(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(t.Context(), childTimeout)
defer cancel()
child, stdout, stderr := startServer(ctx, t, t.TempDir(),
freePort(ctx, t), "REPORTS_PER_MINUTE=0")
err := child.Wait()
if child.ProcessState.ExitCode() != 1 {
t.Fatalf("server exit = %v, want exit status 1", err)
}
if stderr.Len() != 0 {
t.Fatalf("stderr = %q, want nothing", stderr)
}
lines := strings.Split(strings.TrimSpace(stdout.String()), "\n")
for _, line := range lines[:len(lines)-1] {
if strings.Contains(line, `"level":"ERROR"`) {
t.Fatalf("an error line before the last line:\n%s", stdout)
}
}
var last struct {
Level string `json:"level"`
Error string `json:"error"`
}
err = json.Unmarshal([]byte(lines[len(lines)-1]), &last)
if err != nil {
t.Fatalf("last line is not JSON: %v\n%s", err, stdout)
}
want := `REPORTS_PER_MINUTE "0": must be a positive whole number`
if last.Level != "ERROR" || last.Error != want {
t.Fatalf("last line has level %q, error %q; want ERROR, %q\n%s",
last.Level, last.Error, want, stdout)
}
}
// runServerUntilStopped runs main() in a child process with env added
// to its environment, waits until it answers its health check, stops it
// with SIGTERM and returns its stdout and stderr. It fails the test if
// the child does not exit cleanly.
func runServerUntilStopped(
t *testing.T,
env ...string,
) (*bytes.Buffer, *bytes.Buffer) {
t.Helper()
ctx, cancel := context.WithTimeout(t.Context(), childTimeout) ctx, cancel := context.WithTimeout(t.Context(), childTimeout)
defer cancel() defer cancel()
port := freePort(ctx, t) port := freePort(ctx, t)
child, stdout, stderr := startServer(ctx, t, t.TempDir(), port, env...) child, stdout, stderr := startServer(ctx, t, t.TempDir(), port)
waitForHealthcheck(ctx, t, port) waitForHealthcheck(ctx, t, port)
@@ -159,7 +72,11 @@ func runServerUntilStopped(
t.Fatalf("server exit = %v, want success", err) t.Fatalf("server exit = %v, want success", err)
} }
return stdout, stderr requireJSONLines(t, stdout, stderr)
if !strings.Contains(stdout.String(), `"msg":"starting"`) {
t.Fatalf("no startup line in stdout:\n%s", stdout)
}
} }
// TestMalformedConfigFileStopsTheStart: a config file the server finds // TestMalformedConfigFileStopsTheStart: a config file the server finds
@@ -220,40 +137,9 @@ func TestRefusedSentryDSNStopsTheStart(t *testing.T) {
} }
} }
// TestPortInUseExitsWithStatus1: a server whose port is already taken
// cannot listen, and stops with exit status 1.
func TestPortInUseExitsWithStatus1(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithTimeout(t.Context(), childTimeout)
defer cancel()
var lc net.ListenConfig
taken, err := lc.Listen(ctx, "tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer func() { _ = taken.Close() }()
_, port, err := net.SplitHostPort(taken.Addr().String())
if err != nil {
t.Fatal(err)
}
child, stdout, _ := startServer(ctx, t, t.TempDir(), port)
err = child.Wait()
if child.ProcessState.ExitCode() != 1 {
t.Fatalf("server exit = %v, want exit status 1\n%s", err, stdout)
}
}
// startServer runs main() in a child process listening on // startServer runs main() in a child process listening on
// 127.0.0.1:port, with home as its HOME and working directory and its // 127.0.0.1:port, with home as its HOME and working directory and its
// data directory in home, so it touches nothing outside home. env is // data directory in home, so it touches nothing outside home. Its
// added to its environment, as NAME=value strings. Its
// stdout and stderr go to the two buffers returned, which hold all of // stdout and stderr go to the two buffers returned, which hold all of
// it once child.Wait returns. The child is killed when ctx ends, and // it once child.Wait returns. The child is killed when ctx ends, and
// killed and reaped when the test ends if nothing waited for it. // killed and reaped when the test ends if nothing waited for it.
@@ -261,7 +147,6 @@ func startServer(
ctx context.Context, ctx context.Context,
t *testing.T, t *testing.T,
home, port string, home, port string,
env ...string,
) (*exec.Cmd, *bytes.Buffer, *bytes.Buffer) { ) (*exec.Cmd, *bytes.Buffer, *bytes.Buffer) {
t.Helper() t.Helper()
@@ -281,7 +166,6 @@ func startServer(
"BIND_ADDRESS=127.0.0.1", "BIND_ADDRESS=127.0.0.1",
"PORT="+port, "PORT="+port,
) )
child.Env = append(child.Env, env...)
child.Stdout = &stdout child.Stdout = &stdout
child.Stderr = &stderr child.Stderr = &stderr
+1 -1
View File
@@ -16,7 +16,6 @@ require (
github.com/prometheus/client_golang v1.24.1 github.com/prometheus/client_golang v1.24.1
github.com/slok/go-http-metrics v0.13.0 github.com/slok/go-http-metrics v0.13.0
github.com/spf13/viper v1.21.0 github.com/spf13/viper v1.21.0
go.uber.org/dig v1.19.0
go.uber.org/fx v1.24.0 go.uber.org/fx v1.24.0
golang.org/x/sys v0.47.0 golang.org/x/sys v0.47.0
) )
@@ -39,6 +38,7 @@ require (
github.com/spf13/pflag v1.0.10 // indirect github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect
go.uber.org/dig v1.19.0 // indirect
go.uber.org/multierr v1.10.0 // indirect go.uber.org/multierr v1.10.0 // indirect
go.uber.org/zap v1.26.0 // indirect go.uber.org/zap v1.26.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect
+24 -75
View File
@@ -5,11 +5,8 @@
package cli package cli
import ( import (
"context"
"fmt" "fmt"
"io" "io"
"log/slog"
"os"
"os/user" "os/user"
"sneak.berlin/go/netwatch/internal/config" "sneak.berlin/go/netwatch/internal/config"
@@ -21,7 +18,6 @@ import (
"sneak.berlin/go/netwatch/internal/reportbuf" "sneak.berlin/go/netwatch/internal/reportbuf"
"sneak.berlin/go/netwatch/internal/server" "sneak.berlin/go/netwatch/internal/server"
"go.uber.org/dig"
"go.uber.org/fx" "go.uber.org/fx"
"go.uber.org/fx/fxevent" "go.uber.org/fx/fxevent"
) )
@@ -33,17 +29,19 @@ const usageExitCode = 2
// Main runs netwatch-server with args, its command line after the // Main runs netwatch-server with args, its command line after the
// program's name, and returns the exit code. With no args, Main runs // program's name, and returns the exit code. With no args, Main runs
// the server by calling serve with appname and version, the name and // the server by calling serve with appname and version, the name and
// version the server logs and reports, and returns serve's exit code. // version the server logs and reports. With "check-cidr CIDR" or
// With "check-cidr CIDR" or "prepare-data-dir DATA_DIR" it runs that // "prepare-data-dir DATA_DIR" it runs that subcommand, which writes its
// subcommand, which writes its errors to stderr. Any other args get a // errors to stderr. Any other args get a one-line usage message on
// one-line usage message on stderr and exit code 2. main passes // stderr and exit code 2. main passes os.Stderr and RunServer; the
// os.Stderr and RunServer; the tests pass a buffer and a function of // tests pass a buffer and a function of their own, so that no test
// their own, so that no test starts the server. // starts the server. When serve is RunServer and the server fails to
// start, to listen or to stop, fx's Run exits the process with status 1
// and Main never returns.
func Main( func Main(
stderr io.Writer, stderr io.Writer,
appname, version string, appname, version string,
args []string, args []string,
serve func(appname, version string) int, serve func(appname, version string),
) int { ) int {
// "netwatch-server check-cidr CIDR" exits 1, with the error, if // "netwatch-server check-cidr CIDR" exits 1, with the error, if
// this server would refuse CIDR in its TRUSTED_PROXIES. // this server would refuse CIDR in its TRUSTED_PROXIES.
@@ -69,7 +67,9 @@ func Main(
return usageExitCode return usageExitCode
} }
return serve(appname, version) serve(appname, version)
return 0
} }
// checkCIDR returns 0 if the server would take cidr in its // checkCIDR returns 0 if the server would take cidr in its
@@ -109,30 +109,19 @@ func prepareDataDir(stderr io.Writer, volume, dataDir, username string) int {
} }
// RunServer runs the server until it is told to stop, with appname and // RunServer runs the server until it is told to stop, with appname and
// version as the name and version it logs and reports, and returns the // version as the name and version it logs and reports. If the server
// exit code. If the server fails to start, to listen or to stop, it // fails to start, to listen or to stop, fx's Run exits the process
// returns 1. A failed start logs one line with the error's cause, such // with status 1 itself.
// as the setting refused and why, as the server's last line. func RunServer(appname, version string) {
func RunServer(appname, version string) int {
globals.Appname = appname globals.Appname = appname
globals.Version = version globals.Version = version
var ( fx.New(
log *logger.Logger // fx logs each step of starting and stopping through the
fxLog *fxevent.SlogLogger // server's own logger, so off a terminal those lines are
) // JSON like every other line.
app := fx.New(
// fx logs each step of starting and stopping, errors included,
// at debug level through the server's own logger: they show
// only with DEBUG set, and off a terminal they are JSON like
// every other line.
fx.WithLogger(func(log *logger.Logger) fxevent.Logger { fx.WithLogger(func(log *logger.Logger) fxevent.Logger {
fxLog = &fxevent.SlogLogger{Logger: log.Get()} return &fxevent.SlogLogger{Logger: log.Get()}
fxLog.UseLogLevel(slog.LevelDebug)
fxLog.UseErrorLevel(slog.LevelDebug)
return fxLog
}), }),
fx.Provide( fx.Provide(
config.New, config.New,
@@ -144,51 +133,11 @@ func RunServer(appname, version string) int {
reportbuf.New, reportbuf.New,
server.New, server.New,
), ),
// First, so that log is set for the error below even when a
// setting stops the start.
fx.Populate(&log),
fx.Invoke( fx.Invoke(
// Before the server, so that the name and version are // First, so the name and version are logged even when
// logged even when a setting stops the start. // a setting stops the start.
func(log *logger.Logger) { log.Identify() }, func(log *logger.Logger) { log.Identify() },
func(*server.Server) {}, func(*server.Server) {},
), ),
) ).Run()
startCtx, cancelStart := context.WithTimeout(
context.Background(), app.StartTimeout())
defer cancelStart()
err := app.Start(startCtx)
if err != nil {
// fx wraps a constructor's error in its own text; the cause
// alone names the setting. log is nil only if fx failed
// before it built the logger.
cause := dig.RootCause(err)
if log == nil {
_, _ = fmt.Fprintln(os.Stderr, cause)
} else {
log.Get().Error("cannot start", "error", cause)
}
return 1
}
// A signal, or the server asking to stop after a listen error.
// fx's Run logs the signal here, so this does too.
shutdown := <-app.Wait()
fxLog.LogEvent(&fxevent.Stopping{Signal: shutdown.Signal})
stopCtx, cancelStop := context.WithTimeout(
context.Background(), app.StopTimeout())
defer cancelStop()
err = app.Stop(stopCtx)
if err != nil {
log.Get().Error("cannot stop cleanly", "error", err)
return 1
}
return shutdown.ExitCode
} }
+6 -10
View File
@@ -63,7 +63,7 @@ func TestMainRunsPrepareDataDir(t *testing.T) {
// TestMainRunsServerWithNoArguments: Main given no arguments starts the // TestMainRunsServerWithNoArguments: Main given no arguments starts the
// server with the name and version it was given, writes nothing and // server with the name and version it was given, writes nothing and
// returns the server's exit code. // returns 0.
func TestMainRunsServerWithNoArguments(t *testing.T) { func TestMainRunsServerWithNoArguments(t *testing.T) {
t.Parallel() t.Parallel()
@@ -73,13 +73,11 @@ func TestMainRunsServerWithNoArguments(t *testing.T) {
) )
code := cli.Main(&stderr, "netwatch", "test", []string{}, code := cli.Main(&stderr, "netwatch", "test", []string{},
func(appname, version string) int { func(appname, version string) {
gotAppname, gotVersion = appname, version gotAppname, gotVersion = appname, version
return 3
}) })
if code != 3 || stderr.Len() != 0 { if code != 0 || stderr.Len() != 0 {
t.Errorf("exit %d, stderr %q; want exit 3 and no output", t.Errorf("exit %d, stderr %q; want exit 0 and no output",
code, stderr.String()) code, stderr.String())
} }
@@ -212,12 +210,10 @@ func currentUsername(t *testing.T) string {
// failIfServerStarts is what the tests give Main in place of // failIfServerStarts is what the tests give Main in place of
// cli.RunServer: it fails the test instead of starting the server. // cli.RunServer: it fails the test instead of starting the server.
func failIfServerStarts(t *testing.T) func(appname, version string) int { func failIfServerStarts(t *testing.T) func(appname, version string) {
t.Helper() t.Helper()
return func(string, string) int { return func(string, string) {
t.Error("Main started the server") t.Error("Main started the server")
return 0
} }
} }
+2 -2
View File
@@ -221,8 +221,8 @@ test("a target whose home page redirects is checked at /robots.txt on its own ho
"https://s3.eu-west-2.amazonaws.com/robots.txt", "https://s3.eu-west-2.amazonaws.com/robots.txt",
], ],
[ [
"S3 ap-south-1 (Mumbai)", "S3 me-south-1 (Bahrain)",
"https://s3.ap-south-1.amazonaws.com/robots.txt", "https://s3.me-south-1.amazonaws.com/robots.txt",
], ],
[ [
"S3 ap-northeast-1 (Tokyo)", "S3 ap-northeast-1 (Tokyo)",
+3 -3
View File
@@ -99,8 +99,8 @@ const WAN_HOSTS = [
method: "HEAD", method: "HEAD",
}, },
{ {
name: "S3 ap-south-1 (Mumbai)", name: "S3 me-south-1 (Bahrain)",
url: "https://s3.ap-south-1.amazonaws.com/robots.txt", url: "https://s3.me-south-1.amazonaws.com/robots.txt",
method: "HEAD", method: "HEAD",
}, },
{ {
@@ -924,7 +924,7 @@ export function buildUI(state) {
</div> </div>
<footer class="mt-8 text-center text-gray-600 text-xs"> <footer class="mt-8 text-center text-gray-600 text-xs">
<p>Latency: the time until a target's response headers arrive</p> <p>Latency measured via GET requests | CORS restrictions may affect some measurements</p>
<p class="mt-2"> <p class="mt-2">
<span class="inline-block w-3 h-3 rounded-full bg-green-500 mr-1 align-middle"></span>&lt;50ms <span class="inline-block w-3 h-3 rounded-full bg-green-500 mr-1 align-middle"></span>&lt;50ms
<span class="inline-block w-3 h-3 rounded-full bg-lime-500 mr-1 ml-3 align-middle"></span>&lt;100ms <span class="inline-block w-3 h-3 rounded-full bg-lime-500 mr-1 ml-3 align-middle"></span>&lt;100ms