3 Commits
Author SHA1 Message Date
sneak 58560a0f44 Clock errors no longer stop probing; legend reads >=500ms (closes #149, closes #152)
check / check (push) Canceled after 0s
The header's clocks start last in init, after the rounds, and an error
formatting them, as in a browser that rejects the "shortGeneric" time
zone name, leaves both clocks blank instead of stopping the page before
its first round. updateClocks moved out of init so a unit test can call
it.

The footer legend's red entry reads >=500ms, as README.md and
CONFIG.latencyColors have it. buildUI is exported so a unit test can draw
the page and check the legend's limits against CONFIG.latencyColors.

Model: opus-5-5
2026-10-18 22:34:25 +00:00
clawbot 2532da55e9 Client address is the right-most untrusted X-Forwarded-For entry (closes #144)
check / check (push) Canceled after 0s
From a peer in TRUSTED_PROXIES, clientIP took the left-most
X-Forwarded-For entry, which the client writes itself behind a proxy
that appends to the header, so a client could choose the address the
rate limit counts. It now walks the entries from the right past trusted
proxies and takes the first that is not one. An entry with a port is
read as its address; with only trusted entries, or one that is not an
address at all, it uses the peer. Every X-Forwarded-For header line is
read, and X-Real-IP only when there is no X-Forwarded-For entry.

Model: opus-5-5
2026-10-19 00:28:18 +02:00
clawbot e43a710114 Summary reads --/-- after an interval change, not 0/26 in red (closes #140)
check / check (push) Canceled after 0s
The interval menu resets every host to pending and updates the summary,
which counted the WAN hosts with a latency recorded, none, and showed
0/26 in red until the second round after the change ended. While every
WAN host still waits for its first check, the Reachable slot now reads
--/-- in white, as at page load. updateSummary is exported for a unit
test that resets the hosts as the interval menu does; a second test
checks that the slot still reads 0/26 in red once every check failed.

Model: opus-5-5
2026-10-18 23:00:35 +02:00
7 changed files with 347 additions and 49 deletions
+9 -2
View File
@@ -212,8 +212,15 @@ replaces the default; set and empty, the server trusts no proxy. Loopback is not
in the default, so a reverse proxy on the same host has to be named, as in in the default, so a reverse proxy on the same host has to be named, as in
`TRUSTED_PROXIES=127.0.0.1/32`. A request whose direct peer is outside this set `TRUSTED_PROXIES=127.0.0.1/32`. A request whose direct peer is outside this set
has its forwarded headers ignored, and the direct peer is logged and has its forwarded headers ignored, and the direct peer is logged and
rate-limited instead. The container image does not use this default; see rate-limited instead. From a direct peer inside the set, the client address is
[Deployment](#deployment). the right-most `X-Forwarded-For` entry that is not in the set: each proxy
appends the address it took the request from, so the entries further left were
written by the client and are not used. An entry written as an address with a
port, such as `198.51.100.7:5000` or `[2001:db8::1]:443`, is read as that
address. If the search from the right finds only addresses in the set, or meets
an entry that is not an address at all, such as `unknown`, the direct peer is
used. `X-Real-IP` is read only when `X-Forwarded-For` is missing or empty. The
container image does not use this default; see [Deployment](#deployment).
A variable set to a value the server cannot use, such as `PORT=abc`, A variable set to a value the server cannot use, such as `PORT=abc`,
`DEBUG=maybe` or a `BIND_ADDRESS` that is not an IP address, stops it from `DEBUG=maybe` or a `BIND_ADDRESS` that is not an IP address, stops it from
+36
View File
@@ -21,6 +21,42 @@ Run `make frontend-viewport-test` in CI as its own step; it is not part of
# Completed Steps # Completed Steps
- 2026-10-18: a browser that cannot format the header's clocks still probes
([#149](https://git.eeqj.de/sneak/netwatch/issues/149)). The clocks name the
local time zone through `Intl.DateTimeFormat`'s `shortGeneric` option, which
browsers from before 2022 reject with an error; it came before the first round
started, so such a browser never checked a target. The clocks now start last,
after the rounds, and an error formatting them leaves both blank. The footer's
color legend reads `>=500ms` for red, where it read `>500ms`, so 500 ms, which
is red, is in one of its colors
([#152](https://git.eeqj.de/sneak/netwatch/issues/152)). Unit tests check the
clocks with a working `Intl.DateTimeFormat` and with one that rejects
`shortGeneric`, and draw the page and check the legend's limits against
`CONFIG.latencyColors`
- 2026-10-18: from a trusted proxy, the backend takes the client address from
the right-most `X-Forwarded-For` entry that is not in `TRUSTED_PROXIES`
([#144](https://git.eeqj.de/sneak/netwatch/issues/144)). It took the left-most
entry, which the client writes itself behind a proxy that appends to the
header, so a client could choose the address the rate limit counts and the
request log records. An entry written as an address with a port is read as
that address. With only trusted addresses in the header, or an entry that is
not an address at all (such as `unknown`) met on the way, the direct peer is
used. `X-Real-IP` is read only without `X-Forwarded-For`, and every
`X-Forwarded-For` header line is read, not only the first. The image's nginx,
and the `TRUSTED_PROXIES=127.0.0.1/32` it starts the backend with, are
unchanged; the comment in `configs/nginx.conf` that said the backend takes the
first entry now says it takes the one address nginx passes on. Unit tests
resolve the address from chains ending in an untrusted address, in trusted
ones, of only trusted ones, with ports, with an entry that is not an address,
and split over two header lines
- 2026-10-18: after an interval change, the summary's Reachable slot reads
`--/--` in white, as at page load, until the second round since the change
ends ([#140](https://git.eeqj.de/sneak/netwatch/issues/140)). It used to read
0/26 in red from the change on, as no WAN host had a check recorded, for up to
two intervals with the network fine. The summary update now writes `--/--`
while every WAN host still waits for its first check. A unit test resets the
hosts as the interval menu does and checks the slot; another checks that it
reads 0/26 in red once every WAN host's check has failed
- 2026-10-14: stopping the server waits for a report file write already under - 2026-10-14: stopping the server waits for a report file write already under
way, the periodic flush's or the one `Append` starts when 10 MiB are waiting, way, the periodic flush's or the one `Append` starts when 10 MiB are waiting,
before the final flush before the final flush
+3 -1
View File
@@ -46,7 +46,9 @@ server {
# 127.0.0.1:8081. These headers go with every request passed to it. # 127.0.0.1:8081. These headers go with every request passed to it.
# X-Forwarded-For carries only the client address, as resolved by # X-Forwarded-For carries only the client address, as resolved by
# the real IP settings above, and not the chain the request came # the real IP settings above, and not the chain the request came
# with: the backend takes the first entry, which a client can write. # with. bin/entrypoint.sh starts the backend with
# TRUSTED_PROXIES=127.0.0.1/32, the address nginx connects from, so
# the backend takes that one entry as the client address.
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-For $remote_addr;
+44 -11
View File
@@ -11,6 +11,7 @@ import (
"net/http" "net/http"
"net/netip" "net/netip"
"runtime/debug" "runtime/debug"
"slices"
"strings" "strings"
"time" "time"
@@ -133,6 +134,17 @@ func ipFromHostPort(hostPort string) string {
// X-Real-IP are honoured only when the direct peer is a // X-Real-IP are honoured only when the direct peer is a
// trusted proxy; otherwise the direct peer is returned so a // trusted proxy; otherwise the direct peer is returned so a
// spoofed header cannot forge the logged address. // spoofed header cannot forge the logged address.
//
// Each proxy appends to X-Forwarded-For the address it took the
// request from, so the entries on the right are written by the
// trusted proxies and those further left by the client. The
// client's address is therefore the right-most entry that is not
// a trusted proxy. An entry written as an address with a port is
// read as that address, both for the trust check and as the
// result. When every entry is a trusted proxy, or the walk from
// the right meets an entry that is not an address at all (such as
// "unknown"), the peer is used. X-Real-IP is read only when there
// is no X-Forwarded-For entry at all.
func clientIP( func clientIP(
remoteAddr string, remoteAddr string,
header http.Header, header http.Header,
@@ -144,28 +156,49 @@ func clientIP(
return peer return peer
} }
if xff := firstForwardedFor(header.Get("X-Forwarded-For")); xff != "" { forwarded := forwardedFor(header)
return xff
if len(forwarded) == 0 {
if xr := strings.TrimSpace(header.Get("X-Real-IP")); validIP(xr) {
return xr
}
return peer
} }
if xr := strings.TrimSpace(header.Get("X-Real-IP")); validIP(xr) { for _, entry := range slices.Backward(forwarded) {
return xr if !validIP(entry) {
return peer
}
if !addrInAny(entry, trusted) {
return entry
}
} }
return peer return peer
} }
// firstForwardedFor returns the left-most valid address in an // forwardedFor returns the entries of X-Forwarded-For in order,
// X-Forwarded-For list (the original client), or "" if none. // leaving out blank ones. A proxy may add its entry as a header
func firstForwardedFor(value string) string { // line of its own rather than append it to the line the client
// sent, so every line is read, not only the first. Some proxies
// write an entry as an address with a port, such as
// 198.51.100.7:5000 or [2001:db8::1]:443; the port is removed so
// that the entry is read as its address.
func forwardedFor(header http.Header) []string {
value := strings.Join(header.Values("X-Forwarded-For"), ",")
var entries []string
for part := range strings.SplitSeq(value, ",") { for part := range strings.SplitSeq(value, ",") {
candidate := strings.TrimSpace(part) entry := strings.TrimSpace(part)
if validIP(candidate) { if entry != "" {
return candidate entries = append(entries, ipFromHostPort(entry))
} }
} }
return "" return entries
} }
func validIP(s string) bool { func validIP(s string) bool {
+77 -5
View File
@@ -25,10 +25,16 @@ import (
const ( const (
// loopbackPeer is a remote address inside the trusted-proxy allowlist. // loopbackPeer is a remote address inside the trusted-proxy allowlist.
loopbackPeer = "127.0.0.1:5000" loopbackPeer = "127.0.0.1:5000"
// loopbackIP is the address of loopbackPeer, which ClientIP returns
// when it uses the peer.
loopbackIP = "127.0.0.1"
// forwardedIP is the client address presented via X-Forwarded-For. // forwardedIP is the client address presented via X-Forwarded-For.
forwardedIP = "203.0.113.7" forwardedIP = "203.0.113.7"
// realIP is the client address presented via X-Real-IP. // realIP is the client address presented via X-Real-IP.
realIP = "203.0.113.9" realIP = "203.0.113.9"
// appendedIP is the X-Forwarded-For entry a trusted proxy appended
// after the entries the client wrote.
appendedIP = "198.51.100.7"
) )
func mustPrefixes(t *testing.T, cidrs ...string) []netip.Prefix { func mustPrefixes(t *testing.T, cidrs ...string) []netip.Prefix {
@@ -85,10 +91,35 @@ func clientIPCases() []clientIPCase {
want: forwardedIP, want: forwardedIP,
}, },
{ {
name: "trusted proxy uses left-most of chain", name: "trusted proxy uses right-most of chain",
remoteAddr: "10.1.2.3:5000", remoteAddr: loopbackPeer,
xff: forwardedIP + ", 10.1.2.3", xff: "203.0.113.9, " + appendedIP,
want: forwardedIP, want: appendedIP,
},
{
name: "trusted proxy entries are skipped from the right",
remoteAddr: loopbackPeer,
xff: "203.0.113.9, 10.0.0.5",
want: "203.0.113.9",
},
{
name: "chain of only trusted proxies uses peer",
remoteAddr: loopbackPeer,
xff: "10.0.0.5, 10.1.2.3",
want: loopbackIP,
},
{
name: "entry that is not an address uses peer",
remoteAddr: loopbackPeer,
xff: "203.0.113.9, unknown",
want: loopbackIP,
},
{
name: "x-real-ip ignored when forwarded-for has entries",
remoteAddr: loopbackPeer,
xff: "10.0.0.5",
xRealIP: realIP,
want: loopbackIP,
}, },
{ {
name: "trusted proxy falls back to x-real-ip", name: "trusted proxy falls back to x-real-ip",
@@ -117,7 +148,7 @@ func clientIPCases() []clientIPCase {
name: "trusted proxy with garbage header uses peer", name: "trusted proxy with garbage header uses peer",
remoteAddr: loopbackPeer, remoteAddr: loopbackPeer,
xff: "not-an-ip", xff: "not-an-ip",
want: "127.0.0.1", want: loopbackIP,
}, },
} }
} }
@@ -148,6 +179,47 @@ func TestClientIP(t *testing.T) {
} }
} }
// TestClientIPReadsEveryForwardedForLine checks that an entry a proxy
// adds as an X-Forwarded-For line of its own is read, and the line the
// client sent before it is not taken.
func TestClientIPReadsEveryForwardedForLine(t *testing.T) {
t.Parallel()
header := http.Header{}
header.Add("X-Forwarded-For", "203.0.113.9")
header.Add("X-Forwarded-For", appendedIP)
got := middleware.ClientIP(loopbackPeer, header,
mustPrefixes(t, "127.0.0.1/32"))
if got != appendedIP {
t.Errorf("ClientIP() = %q, want %q", got, appendedIP)
}
}
// TestClientIPReadsAddressWithPort checks that an X-Forwarded-For entry
// written as an address with a port is read as that address, both for
// the trust check and as the result.
func TestClientIPReadsAddressWithPort(t *testing.T) {
t.Parallel()
trusted := mustPrefixes(t, "127.0.0.1/32", "10.0.0.0/8")
for xff, want := range map[string]string{
"203.0.113.9, " + appendedIP + ":5000": appendedIP,
"203.0.113.9, [2001:db8::1]:443": "2001:db8::1",
"203.0.113.9, 10.0.0.5:5000": "203.0.113.9",
} {
header := http.Header{}
header.Set("X-Forwarded-For", xff)
got := middleware.ClientIP(loopbackPeer, header, trusted)
if got != want {
t.Errorf("X-Forwarded-For %q: ClientIP() = %q, want %q",
xff, got, want)
}
}
}
func TestSecurityHeaders(t *testing.T) { func TestSecurityHeaders(t *testing.T) {
t.Parallel() t.Parallel()
+129 -2
View File
@@ -6,6 +6,7 @@ import { beforeEach, test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { import {
AppState, AppState,
buildUI,
CONFIG, CONFIG,
greyOutUI, greyOutUI,
hostRowHTML, hostRowHTML,
@@ -17,6 +18,8 @@ import {
measureLatency, measureLatency,
Reporter, Reporter,
tick, tick,
updateClocks,
updateSummary,
} from "../../web/src/main.js"; } from "../../web/src/main.js";
// There is no page here, so the tests stand in for it. The debug log looks // There is no page here, so the tests stand in for it. The debug log looks
@@ -27,8 +30,10 @@ import {
// 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; the status dot greyOutUI looks for in it is not
// there. Drawing a sparkline does nothing; it looks for the pixel ratio on // there. Drawing a sparkline does nothing; it looks for the pixel ratio on
// window and finds none. In each test, console.error and console.info // window and finds none. The commit the page's footer names, which
// print nothing and keep what they are given. // 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
// given.
const doNothing = () => {}; const doNothing = () => {};
let elements; let elements;
beforeEach((t) => { beforeEach((t) => {
@@ -50,6 +55,8 @@ const canvasContext = {
globalThis.window = {}; globalThis.window = {};
globalThis.requestAnimationFrame = doNothing; globalThis.requestAnimationFrame = doNothing;
globalThis.cancelAnimationFrame = doNothing; globalThis.cancelAnimationFrame = doNothing;
globalThis.__COMMIT_HASH__ = "";
globalThis.__COMMIT_FULL__ = "";
globalThis.document = { globalThis.document = {
getElementById: () => null, getElementById: () => null,
createElement: () => ({}), createElement: () => ({}),
@@ -578,6 +585,30 @@ 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");
}); });
// 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
// first entry in CONFIG.latencyColors whose limit it is below, so red, the
// last, starts at orange's limit.
test("the footer's color legend gives the latency limits in CONFIG.latencyColors, each latency in one color", (t) => {
const app = {};
t.mock.method(globalThis.document, "getElementById", () => app);
buildUI(new AppState([]));
const legend = [
...app.innerHTML.matchAll(/(bg-\w+-500) [^"]*"><\/span>(\S+)/g),
].map(([, dot, latencies]) => [dot, latencies]);
const limits = CONFIG.latencyColors.map((color) => color.below);
assert.deepEqual(legend, [
["bg-green-500", `&lt;${limits[0]}ms`],
["bg-lime-500", `&lt;${limits[1]}ms`],
["bg-yellow-500", `&lt;${limits[2]}ms`],
["bg-orange-500", `&lt;${limits[3]}ms`],
["bg-red-500", `&gt;=${limits[3]}ms`],
["bg-gray-500", "offline"],
]);
});
// A target whose checks, in turn, answered after each of latencies ms, or, // A target whose checks, in turn, answered after each of latencies ms, or,
// for null, found it unreachable. // for null, found it unreachable.
function hostAfter(latencies) { function hostAfter(latencies) {
@@ -654,6 +685,102 @@ test("the summary's min, max, median and average latency over the WAN targets' l
}); });
}); });
// In the next two tests each element of the summary that updateSummary
// writes into is a plain object, made the first time it is looked up and
// kept in elements under "#" and its id.
// The interval menu resets each target and counts the rounds from 0 again,
// then updates the summary, before any check since the change is recorded.
test("after an interval change, the summary's Reachable slot reads --/-- in white, as at page load", (t) => {
t.mock.method(
globalThis.document,
"getElementById",
(id) => (elements[`#${id}`] ??= {}),
);
const state = new AppState([]);
for (const host of state.wan) {
host.pushSample(Date.now(), { latency: 30, error: null });
}
state.tickCount = 5;
for (const host of state.allHosts) host.reset();
state.tickCount = 0;
updateSummary(state);
assert.equal(elements["#summary-reachable"].textContent, "--/--");
assert.equal(elements["#summary-reachable"].className, "text-white");
});
test("once every WAN target's check has failed, the summary's Reachable slot reads 0 of them, in red", (t) => {
t.mock.method(
globalThis.document,
"getElementById",
(id) => (elements[`#${id}`] ??= {}),
);
const state = new AppState([]);
for (const host of state.wan) {
host.pushSample(Date.now(), { latency: null, error: "timeout" });
}
updateSummary(state);
assert.equal(
elements["#summary-reachable"].textContent,
`0/${state.wan.length}`,
);
assert.equal(elements["#summary-reachable"].className, "text-red-400");
});
// 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.
// The mocked clock reads 1970-01-01T00:00:00.000Z.
test("the header's clocks show the local time with its time zone's name, and the UTC time", (t) => {
t.mock.method(
globalThis.document,
"getElementById",
(id) => (elements[`#${id}`] ??= {}),
);
t.mock.timers.enable({ apis: ["Date"] });
updateClocks();
assert.match(
elements["#clock-local"].textContent,
/^Local: \d{4}-\d\d-\d\d \d\d:\d\d:\d\d \S/,
);
assert.equal(
elements["#clock-utc"].textContent,
"UTC: 1970-01-01 00:00:00 UTC",
);
});
// Here Intl.DateTimeFormat is as in Chrome before 95, Firefox before 91 or
// Safari before 15.4: it throws a RangeError when asked for the time zone's
// "shortGeneric" name.
test("in a browser that cannot name the time zone, updating the clocks throws nothing and leaves them blank", (t) => {
t.mock.method(
globalThis.document,
"getElementById",
(id) => (elements[`#${id}`] ??= {}),
);
const DateTimeFormat = Intl.DateTimeFormat;
t.mock.method(Intl, "DateTimeFormat", function (locales, options) {
if (options?.timeZoneName === "shortGeneric") {
throw new RangeError(
"Value shortGeneric out of range for Intl.DateTimeFormat options property timeZoneName",
);
}
return new DateTimeFormat(locales, options);
});
updateClocks();
assert.equal(elements["#clock-local"].textContent, "");
assert.equal(elements["#clock-utc"].textContent, "");
});
// An app state in which, of the WAN targets, the first timedOut timed out, // An app state in which, of the WAN targets, the first timedOut timed out,
// the next unreachable were found unreachable, the next answered answered // the next unreachable were found unreachable, the next answered answered
// after latency ms, and the rest have not been checked yet. // after latency ms, and the rest have not been checked yet.
+49 -28
View File
@@ -829,7 +829,7 @@ function wanRowsHTML(wanHosts) {
return rows.join(""); return rows.join("");
} }
function buildUI(state) { export function buildUI(state) {
const app = document.getElementById("app"); const app = document.getElementById("app");
app.innerHTML = ` app.innerHTML = `
<div class="mx-auto px-[5%] py-8"> <div class="mx-auto px-[5%] py-8">
@@ -911,7 +911,7 @@ function buildUI(state) {
<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
<span class="inline-block w-3 h-3 rounded-full bg-yellow-500 mr-1 ml-3 align-middle"></span>&lt;200ms <span class="inline-block w-3 h-3 rounded-full bg-yellow-500 mr-1 ml-3 align-middle"></span>&lt;200ms
<span class="inline-block w-3 h-3 rounded-full bg-orange-500 mr-1 ml-3 align-middle"></span>&lt;500ms <span class="inline-block w-3 h-3 rounded-full bg-orange-500 mr-1 ml-3 align-middle"></span>&lt;500ms
<span class="inline-block w-3 h-3 rounded-full bg-red-500 mr-1 ml-3 align-middle"></span>&gt;500ms <span class="inline-block w-3 h-3 rounded-full bg-red-500 mr-1 ml-3 align-middle"></span>&gt;=500ms
<span class="inline-block w-3 h-3 rounded-full bg-gray-500 mr-1 ml-3 align-middle"></span>offline <span class="inline-block w-3 h-3 rounded-full bg-gray-500 mr-1 ml-3 align-middle"></span>offline
</p> </p>
<p class="mt-2"><a href="https://git.eeqj.de/sneak/netwatch/commit/${__COMMIT_FULL__}" target="_blank" rel="noopener" class="text-gray-600 hover:text-gray-400">${__COMMIT_HASH__}</a></p> <p class="mt-2"><a href="https://git.eeqj.de/sneak/netwatch/commit/${__COMMIT_FULL__}" target="_blank" rel="noopener" class="text-gray-600 hover:text-gray-400">${__COMMIT_HASH__}</a></p>
@@ -995,7 +995,7 @@ function updateHostRow(host, index) {
SparklineRenderer.draw(canvas, host.history); SparklineRenderer.draw(canvas, host.history);
} }
function updateSummary(state) { export function updateSummary(state) {
const stats = state.wanStats(); const stats = state.wanStats();
const hstats = state.wanHistoryStats(); const hstats = state.wanHistoryStats();
@@ -1014,13 +1014,20 @@ function updateSummary(state) {
const hmaxUnitEl = document.getElementById("summary-hmax-unit"); const hmaxUnitEl = document.getElementById("summary-hmax-unit");
if (!reachableEl) return; if (!reachableEl) return;
reachableEl.textContent = `${stats.reachable}/${stats.total}`; // Before any WAN host's check is recorded, as after an interval change,
reachableEl.className = // no host has answered or failed, so the slot reads as at page load.
stats.reachable === stats.total if (state.wan.every((h) => h.status === "pending")) {
? "text-green-400" reachableEl.textContent = "--/--";
: stats.reachable === 0 reachableEl.className = "text-white";
? "text-red-400" } else {
: "text-yellow-400"; reachableEl.textContent = `${stats.reachable}/${stats.total}`;
reachableEl.className =
stats.reachable === stats.total
? "text-green-400"
: stats.reachable === 0
? "text-red-400"
: "text-yellow-400";
}
if (stats.min !== null) { if (stats.min !== null) {
minEl.textContent = `${stats.min}`; minEl.textContent = `${stats.min}`;
@@ -1107,6 +1114,33 @@ function updateHealthBox(state) {
else if (health === "healthy") log.notice(`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
// the header's clocks. The clocks only decorate the page: in a browser
// whose Intl.DateTimeFormat rejects the "shortGeneric" time zone name
// (Chrome before 95, Firefox before 91, Safari before 15.4) they are left
// blank.
export function updateClocks() {
let local = "";
let utc = "";
try {
const now = new Date();
const zone = new Intl.DateTimeFormat("en-US", {
timeZoneName: "shortGeneric",
})
.formatToParts(now)
.find((p) => p.type === "timeZoneName").value;
const pad = (n) => String(n).padStart(2, "0");
local = `Local: ${now.toLocaleString("sv-SE", { hour12: false })} ${zone}`;
utc =
`UTC: ${now.getUTCFullYear()}-${pad(now.getUTCMonth() + 1)}-${pad(now.getUTCDate())} ` +
`${pad(now.getUTCHours())}:${pad(now.getUTCMinutes())}:${pad(now.getUTCSeconds())} UTC`;
} catch {
// Both clocks are left blank.
}
document.getElementById("clock-local").textContent = local;
document.getElementById("clock-utc").textContent = utc;
}
// --- Debug Log Renderer ------------------------------------------------------ // --- Debug Log Renderer ------------------------------------------------------
// One line of the debug log panel, its message set as text. // One line of the debug log panel, its message set as text.
@@ -1502,24 +1536,6 @@ async function init() {
sortAndRebuildWAN(state); sortAndRebuildWAN(state);
}); });
function updateClocks() {
const now = new Date();
const pad = (n) => String(n).padStart(2, "0");
const utc =
`${now.getUTCFullYear()}-${pad(now.getUTCMonth() + 1)}-${pad(now.getUTCDate())} ` +
`${pad(now.getUTCHours())}:${pad(now.getUTCMinutes())}:${pad(now.getUTCSeconds())} UTC`;
const local =
now.toLocaleString("sv-SE", { hour12: false }) +
" " +
new Intl.DateTimeFormat("en-US", { timeZoneName: "shortGeneric" })
.formatToParts(now)
.find((p) => p.type === "timeZoneName").value;
document.getElementById("clock-local").textContent = "Local: " + local;
document.getElementById("clock-utc").textContent = "UTC: " + utc;
}
updateClocks();
setInterval(updateClocks, 1000);
// Rounds never overlap: a round first gives up the last round's checks // Rounds never overlap: a round first gives up the last round's checks
// if they are still waiting, and the last round then records nothing // if they are still waiting, and the last round then records nothing
// more. At a steady interval they never are, as they time out at 80% of // more. At a steady interval they never are, as they time out at 80% of
@@ -1599,6 +1615,11 @@ async function init() {
window.addEventListener("resize", () => handleResize(state)); window.addEventListener("resize", () => handleResize(state));
setTimeout(() => handleResize(state), CONFIG.resizeDelay); setTimeout(() => handleResize(state), CONFIG.resizeDelay);
// The clocks start last, after the rounds: they only decorate the page,
// so nothing in them may stop the monitor from probing.
updateClocks();
setInterval(updateClocks, 1000);
} }
// Bootstrap only when loaded as the page: a real DOM containing the #app // Bootstrap only when loaded as the page: a real DOM containing the #app