Compare commits
3
Commits
02dbb41aa7
..
next
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd119a0e46 | ||
|
|
9b08e4ee6a | ||
|
|
7d1f4c6b13 |
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
|
||||||
|
|
||||||
@@ -107,15 +114,19 @@ 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 dot beside
|
from green under 50 ms to red from 500 ms up. The dot beside the host's name
|
||||||
the host's name takes the same color. The graph's axis ends at 1000 ms; a higher
|
takes the figure's color. A failed check has a gray figure, and a faint red band
|
||||||
latency is drawn at the top, and the figure still 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -868,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.
|
||||||
|
|||||||
+21
-11
@@ -1128,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
|
||||||
@@ -1303,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);
|
||||||
@@ -1318,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"}`);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -1344,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;
|
||||||
|
|||||||
Reference in new issue
Block a user