3 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
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
3 changed files with 78 additions and 32 deletions

No files matched your search

+26 -15
View File
@@ -22,7 +22,7 @@ make setup
# Serve the page from the Vite dev server
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
# Run the tests, both linters and the format check
@@ -36,9 +36,16 @@ make docker
docker run -p 8080:8080 netwatch
```
`make check` and `make docker` need Docker. `make run` starts `netwatch-server`
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.
After `make dev`, open the page at `http://localhost:5173/`. From the image, it
is at `http://localhost:8080/`.
`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
@@ -57,7 +64,7 @@ The page checks 26 hosts on the internet, the WAN hosts:
- the Anthropic API and the OpenAI API
- the AWS Console, the Google Cloud Console and Microsoft Azure
- 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
- 6 Hetzner speed-test servers: Nuremberg, Falkenstein, Helsinki, Ashburn,
Hillsboro and Singapore
@@ -113,19 +120,20 @@ half second. As soon as one answers, a new round starts.
### Rows, colors and controls
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
axis ends at 1000 ms; a higher latency is drawn at the top, and the figure still
shows its real value.
from green under 50 ms to red from 500 ms up. The dot beside the host's name
takes the figure's color. A failed check has a gray figure, and a faint red band
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
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.
`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
rounds: 1, 2, 3, 5, 10, 15, 30 or 60 seconds. Changing it clears the history and
starts a new round at once. The `Debug log` checkbox in the footer opens a panel
with the page's last 1000 log lines.
round, and the figures and dots turn gray. The `Interval` menu sets the time
between rounds: 1, 2, 3, 5, 10, 15, 30 or 60 seconds. Changing it clears the
history and starts a new round at once. The `Debug log` checkbox in the footer
opens a panel with the page's last 1000 log lines.
## Reporting
@@ -140,7 +148,9 @@ fields:
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`
(`"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
waits as long as the history holds it: the last 100 rounds of each host. An
@@ -218,8 +228,9 @@ reason is in its log.
`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
`netwatch-server.yaml` in `/etc/netwatch-server/` or
`~/.config/netwatch-server/`, when one exists. A variable in the environment
wins over a file.
`~/.config/netwatch-server/`, when one exists. In the YAML file, each key is a
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
names the variable. An empty variable counts as unset, except `TRUSTED_PROXIES`.
+42 -5
View File
@@ -28,8 +28,8 @@ import {
// 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
// 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
// there. Drawing a sparkline does nothing; it looks for the pixel ratio on
// replaces its markup; setting a style keeps it in the element's style
// 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
// 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
@@ -63,7 +63,7 @@ globalThis.document = {
querySelector: (selector) =>
(elements[selector] ??= {
getContext: () => canvasContext,
querySelector: () => null,
style: {},
set textContent(text) {
this.innerHTML = text;
},
@@ -83,6 +83,12 @@ function statusText(state, host) {
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].
function consoleLines(method) {
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",
],
[
"S3 me-south-1 (Bahrain)",
"https://s3.me-south-1.amazonaws.com/robots.txt",
"S3 ap-south-1 (Mumbai)",
"https://s3.ap-south-1.amazonaws.com/robots.txt",
],
[
"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");
});
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
// 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
+10 -12
View File
@@ -30,8 +30,8 @@ export const CONFIG = {
return [0, 1, 2, 3, 4, 5].map((i) => Math.round((d * i) / 5));
},
canvasHeight: 96,
// A latency figure and its sparkline take the color of the first entry
// whose limit, in ms, the latency is below.
// A latency figure, its row's dot and its sparkline take the color of
// the first entry whose limit, in ms, the latency is below.
latencyColors: [
{ below: 50, hex: "#22c55e", className: "text-green-500" },
{ below: 100, hex: "#84cc16", className: "text-lime-500" },
@@ -99,8 +99,8 @@ const WAN_HOSTS = [
method: "HEAD",
},
{
name: "S3 me-south-1 (Bahrain)",
url: "https://s3.me-south-1.amazonaws.com/robots.txt",
name: "S3 ap-south-1 (Mumbai)",
url: "https://s3.ap-south-1.amazonaws.com/robots.txt",
method: "HEAD",
},
{
@@ -819,7 +819,7 @@ export function hostRowHTML(host, index, showPin = true) {
${pinBtn}
<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="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>
</div>
<div class="latency-value text-4xl font-bold tabular-nums text-right mt-3" data-host="${index}">
@@ -982,8 +982,10 @@ function updateHostRow(host, index) {
const canvas = document.querySelector(
`.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) {
const cls = latencyClass(host.lastLatency, host.status);
latencyEl.innerHTML = `<span class="${cls}">${host.lastLatency}<span class="text-lg">ms</span></span>`;
@@ -1420,12 +1422,8 @@ export function greyOutUI(state) {
statusEl.textContent = "paused";
statusEl.className = `${STATUS_TEXT_CLASS} text-gray-500`;
}
// Grey out the status dot
const row = document.querySelector(`.host-row[data-index="${i}"]`);
if (row) {
const dot = row.querySelector(".rounded-full");
if (dot) dot.style.backgroundColor = "#6b7280";
}
const dot = document.querySelector(`.status-dot[data-host="${i}"]`);
if (dot) dot.style.backgroundColor = latencyHex(null);
});
// Grey out summary current stats