Compare commits
3
Commits
2183bb224a
...
bf21c87f8c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf21c87f8c | ||
|
|
d33328ec17 | ||
|
|
0a714bc57f |
No files matched your search
@@ -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
|
||||||
@@ -113,19 +120,20 @@ 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 +148,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 +228,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`.
|
||||||
|
|||||||
+42
-5
@@ -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 < and >. A latency takes the color of the
|
// with < and > written as < and >. A latency takes the color of the
|
||||||
|
|||||||
+10
-12
@@ -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}">
|
||||||
@@ -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>`;
|
||||||
@@ -1420,12 +1422,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
|
||||||
|
|||||||
Reference in new issue
Block a user