6 Commits
Author SHA1 Message Date
clawbot dd119a0e46 Footer says what a latency figure is (closes #208)
check / check (push) Waiting to run
The old line said every check is a GET and that CORS may affect
figures; the S3 targets use HEAD and a no-cors check counts any answer.

Model: opus-5-5
2026-11-11 23:10:33 +01:00
clawbot 9b08e4ee6a README: page address, failed-check band, timestamp form, YAML keys (closes #210)
check / check (push) Canceled after 0s
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 22:55:34 +01:00
clawbot 7d1f4c6b13 Health box and summary update as each WAN check ends (closes #206)
check / check (push) Canceled after 0s
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 22:40:55 +01:00
clawbot cdabf019bb Log fx's startup steps at debug; a refused setting is one line (closes #209)
check / check (push) Canceled after 0s
fx's event lines, errors included, now log at debug level, so a normal
start shows none and DEBUG shows them. RunServer starts and stops the
fx app itself instead of calling Run, and logs fx's "received signal"
line as Run did. When the start fails, it logs the error's root cause
as one "cannot start" line, the backend's last, and returns 1. Main
returns the server's exit code.

Model: opus-5-5
2026-11-11 22:25:49 +01:00
clawbot d33328ec17 Replace the S3 Bahrain target with Mumbai (closes #205)
check / check (push) Canceled after 0s
s3.me-south-1.amazonaws.com accepts no connection, so its row timed out
on every check and a healthy network showed 25/26. S3 ap-south-1
(Mumbai) answers HEAD /robots.txt with 403 and no redirect.

Model: opus-5-5
2026-11-11 22:10:53 +01:00
clawbot 0a714bc57f Color the dot beside each host name by its latest latency (closes #207)
check / check (push) Canceled after 0s
updateHostRow gives the dot the color of the row's latest figure from
CONFIG.latencyColors, gray when the check failed or no result has come
yet. Pausing still sets it gray. The dot carries a status-dot class and
data-host, as the figure and status line do, so both functions find it
the same way.

Model: opus-5-5
2026-11-11 21:56:03 +01:00
5 changed files with 238 additions and 52 deletions

No files matched your search

+31 -16
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 the server on port 8080 # Build the page and netwatch-server, then run only 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,9 +36,16 @@ make docker
docker run -p 8080:8080 netwatch docker run -p 8080:8080 netwatch
``` ```
`make check` and `make docker` need Docker. `make run` starts `netwatch-server` After `make dev`, open the page at `http://localhost:5173/`. From the image, it
on port 8080 with its defaults, so the page's reports are stored in is at `http://localhost:8080/`.
`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
@@ -57,7 +64,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, Bahrain, Tokyo, Singapore, Sydney, - 8 S3 regional endpoints: Cape Town, London, Mumbai, 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
@@ -107,25 +114,29 @@ 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.
### 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; gray is no answer. The graph's from green under 50 ms to red from 500 ms up. The dot beside the host's name
axis ends at 1000 ms; a higher latency is drawn at the top, and the figure still takes the figure's color. A failed check has a gray figure, and a faint red band
shows its real value. in the graph. The graph's axis ends at 1000 ms; a higher latency is drawn at the
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
top; pinned rows are sorted by name. `datavi.be` is pinned at start. top; pinned rows are sorted by name. `datavi.be` is pinned at start.
`Pause` stops the checks: the graphs keep scrolling with a gap for each paused `Pause` stops the checks: the graphs keep scrolling with a gap for each paused
round, and the figures turn gray. The `Interval` menu sets the time between round, and the figures and dots turn gray. The `Interval` menu sets the time
rounds: 1, 2, 3, 5, 10, 15, 30 or 60 seconds. Changing it clears the history and between rounds: 1, 2, 3, 5, 10, 15, 30 or 60 seconds. Changing it clears the
starts a new round at once. The `Debug log` checkbox in the footer opens a panel history and starts a new round at once. The `Debug log` checkbox in the footer
with the page's last 1000 log lines. opens a panel with the page's last 1000 log lines.
## Reporting ## Reporting
@@ -140,7 +151,9 @@ 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 - `timestamp`: the time of the report, as an ISO 8601 string such as
`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
@@ -218,8 +231,9 @@ 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. A variable in the environment `~/.config/netwatch-server/`, when one exists. In the YAML file, each key is a
wins over a file. variable's name, in upper or lower case: `PORT: 8090` sets the port. A variable
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`.
@@ -367,7 +381,8 @@ 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 and draws each row as soon as its check ends. parallel. It draws each row as soon as its check ends, and with a WAN host's
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
+44 -5
View File
@@ -56,7 +56,7 @@ func TestOutputIsJSONWithoutFxLines(t *testing.T) {
} }
// TestDebugLogsFxLines: with DEBUG set, fx's lines are logged, as // TestDebugLogsFxLines: with DEBUG set, fx's lines are logged, as
// JSON. // JSON, from the start to the signal that stops the server.
func TestDebugLogsFxLines(t *testing.T) { func TestDebugLogsFxLines(t *testing.T) {
t.Parallel() t.Parallel()
@@ -64,14 +64,17 @@ func TestDebugLogsFxLines(t *testing.T) {
requireJSONLines(t, stdout, stderr) requireJSONLines(t, stdout, stderr)
if !strings.Contains(stdout.String(), `"msg":"started"`) { for _, msg := range []string{"started", "received signal"} {
t.Fatalf("no fx line in stdout with DEBUG set:\n%s", stdout) 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 // TestRefusedSettingIsLastLine: a setting the server refuses stops the
// start, and the last line logged is one error naming the setting and // start, and the one error line logged is the last, naming the setting
// why, without fx's text around it. // and why, without fx's text around it.
func TestRefusedSettingIsLastLine(t *testing.T) { func TestRefusedSettingIsLastLine(t *testing.T) {
t.Parallel() t.Parallel()
@@ -92,6 +95,12 @@ func TestRefusedSettingIsLastLine(t *testing.T) {
lines := strings.Split(strings.TrimSpace(stdout.String()), "\n") 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 { var last struct {
Level string `json:"level"` Level string `json:"level"`
Error string `json:"error"` Error string `json:"error"`
@@ -211,6 +220,36 @@ 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. env is
+7 -2
View File
@@ -117,7 +117,10 @@ func RunServer(appname, version string) int {
globals.Appname = appname globals.Appname = appname
globals.Version = version globals.Version = version
var log *logger.Logger var (
log *logger.Logger
fxLog *fxevent.SlogLogger
)
app := fx.New( app := fx.New(
// fx logs each step of starting and stopping, errors included, // fx logs each step of starting and stopping, errors included,
@@ -125,7 +128,7 @@ func RunServer(appname, version string) int {
// only with DEBUG set, and off a terminal they are JSON like // only with DEBUG set, and off a terminal they are JSON like
// every other line. // 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()} fxLog = &fxevent.SlogLogger{Logger: log.Get()}
fxLog.UseLogLevel(slog.LevelDebug) fxLog.UseLogLevel(slog.LevelDebug)
fxLog.UseErrorLevel(slog.LevelDebug) fxLog.UseErrorLevel(slog.LevelDebug)
@@ -172,7 +175,9 @@ func RunServer(appname, version string) int {
} }
// A signal, or the server asking to stop after a listen error. // 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() shutdown := <-app.Wait()
fxLog.LogEvent(&fxevent.Stopping{Signal: shutdown.Signal})
stopCtx, cancelStop := context.WithTimeout( stopCtx, cancelStop := context.WithTimeout(
context.Background(), app.StopTimeout()) context.Background(), app.StopTimeout())
+124 -5
View File
@@ -28,8 +28,8 @@ import {
// a host's row that tick or greyOutUI draws into is a plain object, made // a host's row that tick or greyOutUI draws into is a plain object, made
// the first time a test looks it up and kept in elements under its // the first time a test looks it up and kept in elements under its
// selector until the next test starts. As on a page, writing its text // selector until the next test starts. As on a page, writing its text
// replaces its markup; the status dot greyOutUI looks for in it is not // replaces its markup; setting a style keeps it in the element's style
// there. Drawing a sparkline does nothing; it looks for the pixel ratio on // object. Drawing a sparkline does nothing; it looks for the pixel ratio on
// window and finds none. The commit the page's footer names, which // window and finds none. The commit the page's footer names, which
// vite.config.js fills in when it builds the page, is blank. In each test, // vite.config.js fills in when it builds the page, is blank. In each test,
// console.error and console.info print nothing and keep what they are // console.error and console.info print nothing and keep what they are
@@ -63,7 +63,7 @@ globalThis.document = {
querySelector: (selector) => querySelector: (selector) =>
(elements[selector] ??= { (elements[selector] ??= {
getContext: () => canvasContext, getContext: () => canvasContext,
querySelector: () => null, style: {},
set textContent(text) { set textContent(text) {
this.innerHTML = text; this.innerHTML = text;
}, },
@@ -83,6 +83,12 @@ function statusText(state, host) {
return elements[`.status-text[data-host="${index}"]`]?.innerHTML; return elements[`.status-text[data-host="${index}"]`]?.innerHTML;
} }
// The color last given to the dot beside host's name.
function statusDot(state, host) {
const index = state.allHosts.indexOf(host);
return elements[`.status-dot[data-host="${index}"]`]?.style.backgroundColor;
}
// What this test wrote to the browser console with console[method]. // What this test wrote to the browser console with console[method].
function consoleLines(method) { function consoleLines(method) {
return console[method].mock.calls.map((call) => call.arguments[0]); return console[method].mock.calls.map((call) => call.arguments[0]);
@@ -215,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 me-south-1 (Bahrain)", "S3 ap-south-1 (Mumbai)",
"https://s3.me-south-1.amazonaws.com/robots.txt", "https://s3.ap-south-1.amazonaws.com/robots.txt",
], ],
[ [
"S3 ap-northeast-1 (Tokyo)", "S3 ap-northeast-1 (Tokyo)",
@@ -683,6 +689,37 @@ test("a check that timed out or found its target unreachable has a grey figure a
assert.equal(latencyHex(null), "#6b7280"); assert.equal(latencyHex(null), "#6b7280");
}); });
test("a check that answers after 488ms turns the dot beside its target's name orange", async (t) => {
const { state, target } = stateWithOneTarget();
mockTargets(t, () => 488);
const round = tick(state, new AbortController().signal);
t.mock.timers.tick(488);
assert.notEqual(await settled(round), "still waiting");
assert.equal(statusDot(state, target), "#f97316");
});
test("a check that fails after one that answered turns the dot beside its target's name gray", async (t) => {
const { state, target } = stateWithOneTarget();
target.pushSample(Date.now(), { latency: 8, error: null });
mockTargets(t, () => 23, target.url);
const round = tick(state, new AbortController().signal);
t.mock.timers.tick(23);
assert.notEqual(await settled(round), "still waiting");
assert.equal(statusDot(state, target), "#6b7280");
});
test("pausing turns the dot beside a target's name gray", async (t) => {
const { state, target } = stateWithOneTarget();
mockTargets(t, () => 8);
const round = tick(state, new AbortController().signal);
t.mock.timers.tick(8);
assert.notEqual(await settled(round), "still waiting");
assert.equal(statusDot(state, target), "#22c55e");
state.paused = true;
greyOutUI(state);
assert.equal(statusDot(state, target), "#6b7280");
});
// buildUI draws the page into #app, here a plain object. Each entry in the // buildUI draws the page into #app, here a plain object. Each entry in the
// footer's color legend is a colored dot and the latencies it stands for, // footer's color legend is a colored dot and the latencies it stands for,
// with < and > written as &lt; and &gt;. A latency takes the color of the // with < and > written as &lt; and &gt;. A latency takes the color of the
@@ -831,6 +868,88 @@ 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.
+32 -24
View File
@@ -30,8 +30,8 @@ export const CONFIG = {
return [0, 1, 2, 3, 4, 5].map((i) => Math.round((d * i) / 5)); return [0, 1, 2, 3, 4, 5].map((i) => Math.round((d * i) / 5));
}, },
canvasHeight: 96, canvasHeight: 96,
// A latency figure and its sparkline take the color of the first entry // A latency figure, its row's dot and its sparkline take the color of
// whose limit, in ms, the latency is below. // the first entry whose limit, in ms, the latency is below.
latencyColors: [ latencyColors: [
{ below: 50, hex: "#22c55e", className: "text-green-500" }, { below: 50, hex: "#22c55e", className: "text-green-500" },
{ below: 100, hex: "#84cc16", className: "text-lime-500" }, { below: 100, hex: "#84cc16", className: "text-lime-500" },
@@ -99,8 +99,8 @@ const WAN_HOSTS = [
method: "HEAD", method: "HEAD",
}, },
{ {
name: "S3 me-south-1 (Bahrain)", name: "S3 ap-south-1 (Mumbai)",
url: "https://s3.me-south-1.amazonaws.com/robots.txt", url: "https://s3.ap-south-1.amazonaws.com/robots.txt",
method: "HEAD", method: "HEAD",
}, },
{ {
@@ -819,7 +819,7 @@ export function hostRowHTML(host, index, showPin = true) {
${pinBtn} ${pinBtn}
<div class="w-[420px] flex-shrink-0 grid grid-cols-[minmax(0,1fr)_auto] items-center"> <div class="w-[420px] flex-shrink-0 grid grid-cols-[minmax(0,1fr)_auto] items-center">
<div class="flex items-center gap-2 min-w-[200px]"> <div class="flex items-center gap-2 min-w-[200px]">
<div class="w-3 h-3 rounded-full flex-shrink-0 bg-[#6b7280]"></div> <div class="status-dot w-3 h-3 rounded-full flex-shrink-0 bg-[#6b7280]" data-host="${index}"></div>
<span class="font-medium text-white truncate">${escapeHTML(host.name)}</span> <span class="font-medium text-white truncate">${escapeHTML(host.name)}</span>
</div> </div>
<div class="latency-value text-4xl font-bold tabular-nums text-right mt-3" data-host="${index}"> <div class="latency-value text-4xl font-bold tabular-nums text-right mt-3" data-host="${index}">
@@ -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 measured via GET requests | CORS restrictions may affect some measurements</p> <p>Latency: the time until a target's response headers arrive</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
@@ -982,8 +982,10 @@ function updateHostRow(host, index) {
const canvas = document.querySelector( const canvas = document.querySelector(
`.sparkline-canvas[data-host="${index}"]`, `.sparkline-canvas[data-host="${index}"]`,
); );
if (!latencyEl || !statusEl || !canvas) return; const dot = document.querySelector(`.status-dot[data-host="${index}"]`);
if (!latencyEl || !statusEl || !canvas || !dot) return;
dot.style.backgroundColor = latencyHex(host.lastLatency);
if (host.lastLatency !== null) { if (host.lastLatency !== null) {
const cls = latencyClass(host.lastLatency, host.status); const cls = latencyClass(host.lastLatency, host.status);
latencyEl.innerHTML = `<span class="${cls}">${host.lastLatency}<span class="text-lg">ms</span></span>`; latencyEl.innerHTML = `<span class="${cls}">${host.lastLatency}<span class="text-lg">ms</span></span>`;
@@ -1126,11 +1128,6 @@ 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
@@ -1301,12 +1298,13 @@ 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. The // Each host's row shows its result as soon as its check ends, and a WAN
// result is discarded if by then the user has paused or the next round // host's result goes into the summary and the health box at the same
// has given up this one's checks, and in the first tick (tickCount is // time. The result is discarded if by then the user has paused or the
// still 0), which is discarded as a whole below. The row is looked up // next round has given up this one's checks, and in the first tick
// when the check ends, as a pin click may have re-sorted the rows since // (tickCount is still 0), which is discarded as a whole below. The row
// the round started. // is looked up when the check ends, as a pin click may have re-sorted
// 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);
@@ -1316,6 +1314,10 @@ 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"}`);
}), }),
); );
@@ -1342,9 +1344,19 @@ 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;
@@ -1420,12 +1432,8 @@ export function greyOutUI(state) {
statusEl.textContent = "paused"; statusEl.textContent = "paused";
statusEl.className = `${STATUS_TEXT_CLASS} text-gray-500`; statusEl.className = `${STATUS_TEXT_CLASS} text-gray-500`;
} }
// Grey out the status dot const dot = document.querySelector(`.status-dot[data-host="${i}"]`);
const row = document.querySelector(`.host-row[data-index="${i}"]`); if (dot) dot.style.backgroundColor = latencyHex(null);
if (row) {
const dot = row.querySelector(".rounded-full");
if (dot) dot.style.backgroundColor = "#6b7280";
}
}); });
// Grey out summary current stats // Grey out summary current stats