1 Commits
Author SHA1 Message Date
sneak bf21c87f8c README: page address, failed-check band, timestamp form, YAML keys (closes #210)
check / check (push) Waiting to run
Getting Started names the address to open for `make dev` and the image,
and says `make run` serves no page. Rows, colors and controls says a
failed check is a faint red band in the graph. Reporting gives the
`timestamp` form. Settings gives an example line of the YAML file.

Model: opus-5-5
2026-11-11 21:12:17 +00:00
7 changed files with 54 additions and 325 deletions

No files matched your search

+1 -9
View File
@@ -114,9 +114,6 @@ Below it, a summary line covers the WAN hosts only:
history history
- `Checks`: the rounds run since the page loaded or the interval changed - `Checks`: the rounds run since the page loaded or the interval changed
The health box and the summary take in a WAN host's result as soon as its row
shows it.
When no WAN host answers, the page checks 4 of them, picked at random, every When no WAN host answers, the page checks 4 of them, picked at random, every
half second. As soon as one answers, a new round starts. half second. As soon as one answers, a new round starts.
@@ -350,10 +347,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:
@@ -381,8 +374,7 @@ The page is built with Vite and Tailwind CSS v4. All its code is in
- `HostState` and `AppState`: one host's history and status, and the whole - `HostState` and `AppState`: one host's history and status, and the whole
page's state. page's state.
- `measureLatency`: one check. `tick`: one round, which checks every host in - `measureLatency`: one check. `tick`: one round, which checks every host in
parallel. It draws each row as soon as its check ends, and with a WAN host's parallel and draws each row as soon as its check ends.
row also the summary and the health box.
- `SparklineRenderer`: the graphs. `buildUI`, `updateHostRow`, `updateSummary` - `SparklineRenderer`: the graphs. `buildUI`, `updateHostRow`, `updateSummary`
and `updateHealthBox`: the page and its parts. and `updateHealthBox`: the page and its parts.
- `Reporter`: builds and posts the reports. `buildReport` is a pure function of - `Reporter`: builds and posts the reports. `buildReport` is a pure function of
+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
} }
} }
-82
View File
@@ -868,88 +868,6 @@ test("once every WAN target's check has failed, the summary's Reachable slot rea
assert.equal(elements["#summary-reachable"].className, "text-red-400"); assert.equal(elements["#summary-reachable"].className, "text-red-400");
}); });
// The network has just come back: every WAN target's last check failed, so
// the health box reads OFFLINE. In this round every WAN target but one
// answers after 10ms, and the one never answers. Each element of the summary
// and the health box is a plain object, as in the tests above; the debug
// log's panel is not there.
test("at a 30000ms interval, the summary and the health box take in each WAN target's answer while another WAN target's check is still waiting", async (t) => {
CONFIG.updateInterval = 30000;
t.mock.method(globalThis.document, "getElementById", (id) =>
id.startsWith("summary-") || id.startsWith("health-")
? (elements[`#${id}`] ??= {})
: null,
);
const state = new AppState([]);
for (const host of state.wan) {
host.pushSample(Date.now(), { latency: null, error: "timeout" });
}
const silent = state.wan[0];
mockTargets(t, (url) => (url === silent.url ? Infinity : 10));
// The third tick, as in the tests above.
state.tickCount = 2;
const round = tick(state, new AbortController().signal);
t.mock.timers.tick(10);
assert.equal(await settled(round), "still waiting");
assert.equal(elements["#health-text"].textContent, "HEALTHY");
assert.equal(
elements["#summary-reachable"].textContent,
`${state.wan.length - 1}/${state.wan.length}`,
);
assert.equal(elements["#summary-max"].textContent, "10");
// Checks counts finished rounds only.
assert.equal(elements["#summary-checks"].textContent, 2);
t.mock.timers.tick(CONFIG.requestTimeout - 10);
assert.notEqual(await settled(round), "still waiting");
assert.equal(elements["#summary-checks"].textContent, 3);
});
// The user pauses during the first recorded round after the page loads.
// Every WAN target's check ends during the pause. Silent never answers, and
// the user resumes before its check times out. Every element the page looks
// up by id is a plain object, except the debug log's panel, which is not
// there.
test(`at a 30000ms interval, after the user pauses and resumes during a round that records no WAN target's check, the debug log has no "Health:" line`, async (t) => {
CONFIG.updateInterval = 30000;
t.mock.method(globalThis.document, "getElementById", (id) =>
id === "debug-panel" ? null : (elements[`#${id}`] ??= {}),
);
t.mock.method(log, "notice");
t.mock.method(log, "warning");
const state = new AppState([
{ name: "Silent", url: "https://silent.test" },
]);
const silent = state.local[0];
mockTargets(t, (url) => (url === silent.url ? Infinity : 10));
// The second tick: the first that is recorded.
state.tickCount = 1;
const round = tick(state, new AbortController().signal);
// The user pauses, which greys out the summary and the health box, as
// togglePause does.
state.paused = true;
greyOutUI(state);
t.mock.timers.tick(10);
assert.equal(await settled(round), "still waiting");
// The user resumes, which leaves the page as it is.
state.paused = false;
t.mock.timers.tick(CONFIG.requestTimeout - 10);
assert.notEqual(await settled(round), "still waiting");
assert.equal(state.tickCount, 2);
const lines = [...log.notice.mock.calls, ...log.warning.mock.calls].map(
(call) => call.arguments[0],
);
assert.deepEqual(
lines.filter((line) => line.startsWith("Health:")),
[],
);
assert.equal(elements["#health-text"].textContent, "---");
assert.equal(elements["#summary-reachable"].textContent, "--/--");
});
// In the next two tests each of the header's clocks is a plain object, made // In the next two tests each of the header's clocks is a plain object, made
// the first time updateClocks looks it up and kept in elements under "#" and // the first time updateClocks looks it up and kept in elements under "#" and
// its id. // its id.
+12 -22
View File
@@ -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
@@ -1128,6 +1128,11 @@ function updateHealthBox(state) {
el.textContent = labels[health]; el.textContent = labels[health];
el.className = textColors[health]; el.className = textColors[health];
box.className = `mt-4 p-3 rounded-lg border font-mono text-sm text-center ${boxColors[health]}`; box.className = `mt-4 p-3 rounded-lg border font-mono text-sm text-center ${boxColors[health]}`;
if (health === "offline" || health === "degraded")
log.warning(`Health: ${labels[health]}`);
else if (health === "slow") log.warning(`Health: ${labels[health]}`);
else if (health === "healthy") log.notice(`Health: ${labels[health]}`);
} }
// Writes the local time, with its time zone's name, and the UTC time into // Writes the local time, with its time zone's name, and the UTC time into
@@ -1298,13 +1303,12 @@ export async function tick(state, signal, onOffline) {
log.debug(`Tick #${state.tickCount + 1} started`); log.debug(`Tick #${state.tickCount + 1} started`);
// Each host's row shows its result as soon as its check ends, and a WAN // Each host's row shows its result as soon as its check ends. The
// host's result goes into the summary and the health box at the same // result is discarded if by then the user has paused or the next round
// time. The result is discarded if by then the user has paused or the // has given up this one's checks, and in the first tick (tickCount is
// next round has given up this one's checks, and in the first tick // still 0), which is discarded as a whole below. The row is looked up
// (tickCount is still 0), which is discarded as a whole below. The row // when the check ends, as a pin click may have re-sorted the rows since
// is looked up when the check ends, as a pin click may have re-sorted // the round started.
// the rows since the round started.
await Promise.all( await Promise.all(
state.allHosts.map(async (host) => { state.allHosts.map(async (host) => {
const r = await measureLatency(host.url, signal, host.method); const r = await measureLatency(host.url, signal, host.method);
@@ -1314,10 +1318,6 @@ export async function tick(state, signal, onOffline) {
logCheck(host, r); logCheck(host, r);
host.pushSample(ts, r); host.pushSample(ts, r);
updateHostRow(host, state.allHosts.indexOf(host)); updateHostRow(host, state.allHosts.indexOf(host));
if (state.wan.includes(host)) {
updateSummary(state);
updateHealthBox(state);
}
log.debug(`${host.name}: ${r.error ? r.error : r.latency + "ms"}`); log.debug(`${host.name}: ${r.error ? r.error : r.latency + "ms"}`);
}), }),
); );
@@ -1344,19 +1344,9 @@ export async function tick(state, signal, onOffline) {
sortAndRebuildWAN(state); sortAndRebuildWAN(state);
} }
// Redraw the summary and the health box: a pause greys them out too, and
// Checks now counts this round.
updateSummary(state); updateSummary(state);
updateHealthBox(state); updateHealthBox(state);
// Like the health box, the log gives no health until a WAN host's check
// is recorded: a pause may have discarded every WAN check so far.
if (state.wan.some((h) => h.status !== "pending")) {
const health = state.healthStatus();
if (health === "healthy") log.notice("Health: HEALTHY");
else log.warning(`Health: ${health.toUpperCase()}`);
}
const reachable = state.allHosts.filter( const reachable = state.allHosts.filter(
(h) => h.status === "online", (h) => h.status === "online",
).length; ).length;