Compare commits
2
Commits
c30e15c98f
...
2b2f035410
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b2f035410 | ||
|
|
0a714bc57f |
No files matched your search
@@ -107,25 +107,28 @@ Below it, a summary line covers the WAN hosts only:
|
||||
history
|
||||
- `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
|
||||
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; gray is no answer. The dot beside
|
||||
the host's name takes the same color. 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
|
||||
|
||||
@@ -363,7 +366,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
|
||||
page's state.
|
||||
- `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`
|
||||
and `updateHealthBox`: the page and its parts.
|
||||
- `Reporter`: builds and posts the reports. `buildReport` is a pure function of
|
||||
|
||||
+122
-3
@@ -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]);
|
||||
@@ -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 < and >. 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");
|
||||
});
|
||||
|
||||
// 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
|
||||
// the first time updateClocks looks it up and kept in elements under "#" and
|
||||
// its id.
|
||||
|
||||
+29
-21
@@ -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" },
|
||||
@@ -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>`;
|
||||
@@ -1126,11 +1128,6 @@ function updateHealthBox(state) {
|
||||
el.textContent = labels[health];
|
||||
el.className = textColors[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
|
||||
@@ -1301,12 +1298,13 @@ export async function tick(state, signal, onOffline) {
|
||||
|
||||
log.debug(`Tick #${state.tickCount + 1} started`);
|
||||
|
||||
// Each host's row shows its result as soon as its check ends. The
|
||||
// result is discarded if by then the user has paused or the next round
|
||||
// has given up this one's checks, and in the first tick (tickCount is
|
||||
// still 0), which is discarded as a whole below. The row is looked up
|
||||
// when the check ends, as a pin click may have re-sorted the rows since
|
||||
// the round started.
|
||||
// Each host's row shows its result as soon as its check ends, and a WAN
|
||||
// host's result goes into the summary and the health box at the same
|
||||
// time. The result is discarded if by then the user has paused or the
|
||||
// next round has given up this one's checks, and in the first tick
|
||||
// (tickCount is still 0), which is discarded as a whole below. The row
|
||||
// is looked up when the check ends, as a pin click may have re-sorted
|
||||
// the rows since the round started.
|
||||
await Promise.all(
|
||||
state.allHosts.map(async (host) => {
|
||||
const r = await measureLatency(host.url, signal, host.method);
|
||||
@@ -1316,6 +1314,10 @@ export async function tick(state, signal, onOffline) {
|
||||
logCheck(host, r);
|
||||
host.pushSample(ts, r);
|
||||
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"}`);
|
||||
}),
|
||||
);
|
||||
@@ -1342,9 +1344,19 @@ export async function tick(state, signal, onOffline) {
|
||||
sortAndRebuildWAN(state);
|
||||
}
|
||||
|
||||
// Redraw the summary and the health box: a pause greys them out too, and
|
||||
// Checks now counts this round.
|
||||
updateSummary(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(
|
||||
(h) => h.status === "online",
|
||||
).length;
|
||||
@@ -1420,12 +1432,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
|
||||
|
||||
Reference in new issue
Block a user