Compare commits
2
Commits
87a2305048
...
50059f2fcc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50059f2fcc | ||
|
|
d412815953 |
+2
-1
@@ -1,6 +1,7 @@
|
|||||||
backend/
|
|
||||||
dist/
|
dist/
|
||||||
node_modules/
|
node_modules/
|
||||||
tmp/
|
tmp/
|
||||||
yarn.lock
|
yarn.lock
|
||||||
.claude/
|
.claude/
|
||||||
|
# The org standard file, copied verbatim; backend/script/lint checks its sha256.
|
||||||
|
backend/.golangci.yml
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
.PHONY: bootstrap setup dev test lint fmt fmt-check check frontend-check \
|
.PHONY: bootstrap setup dev build test lint fmt fmt-check check \
|
||||||
frontend-viewport-test docker hooks
|
frontend-check frontend-viewport-test docker hooks
|
||||||
|
|
||||||
# Standard targets are thin shims; the implementations live in script/
|
# Standard targets are thin shims; the implementations live in script/
|
||||||
# per the scripts-to-rule-them-all pattern (see the Entrypoints section
|
# per the scripts-to-rule-them-all pattern (see the Entrypoints section
|
||||||
@@ -13,7 +13,11 @@ setup:
|
|||||||
@script/setup
|
@script/setup
|
||||||
|
|
||||||
dev:
|
dev:
|
||||||
yarn dev
|
@script/dev
|
||||||
|
|
||||||
|
# The frontend only; backend/Makefile's build target builds the Go server.
|
||||||
|
build:
|
||||||
|
@script/build
|
||||||
|
|
||||||
test:
|
test:
|
||||||
@script/test
|
@script/test
|
||||||
|
|||||||
@@ -46,6 +46,10 @@ halves, so the root `make check` fails if either one is broken. We provide:
|
|||||||
both linters in Docker
|
both linters in Docker
|
||||||
- `script/setup` — make a fresh clone ready for development: bootstrap plus the
|
- `script/setup` — make a fresh clone ready for development: bootstrap plus the
|
||||||
git pre-commit hook
|
git pre-commit hook
|
||||||
|
- `script/dev` — run the Vite dev server, which proxies `/api` to a locally
|
||||||
|
running `netwatch-server`
|
||||||
|
- `script/build` — build the frontend for production into `dist/`;
|
||||||
|
`backend/script/build` builds the Go server
|
||||||
- `script/projectname` — print the project name (used for the Docker image tag)
|
- `script/projectname` — print the project name (used for the Docker image tag)
|
||||||
- `script/test` — run `script/frontend-test`, then `backend/script/test`, the
|
- `script/test` — run `script/frontend-test`, then `backend/script/test`, the
|
||||||
backend's Go tests with the race detector and coverage
|
backend's Go tests with the race detector and coverage
|
||||||
@@ -61,7 +65,8 @@ halves, so the root `make check` fails if either one is broken. We provide:
|
|||||||
- `script/frontend-lint` — run eslint with the rules in `eslint.config.js`; it
|
- `script/frontend-lint` — run eslint with the rules in `eslint.config.js`; it
|
||||||
runs inside the `frontend-lint` stage of `Dockerfile`, which `make lint`
|
runs inside the `frontend-lint` stage of `Dockerfile`, which `make lint`
|
||||||
builds
|
builds
|
||||||
- `script/frontend-fmt` — format everything prettier understands (writes)
|
- `script/frontend-fmt` — format everything prettier understands (writes), the
|
||||||
|
markdown in `backend/` included
|
||||||
- `script/frontend-fmt-check` — check prettier formatting (read-only)
|
- `script/frontend-fmt-check` — check prettier formatting (read-only)
|
||||||
- `script/frontend-check` — run `script/frontend-test` and
|
- `script/frontend-check` — run `script/frontend-test` and
|
||||||
`script/frontend-fmt-check`, for the frontend stage of `Dockerfile`, which has
|
`script/frontend-fmt-check`, for the frontend stage of `Dockerfile`, which has
|
||||||
@@ -99,8 +104,10 @@ files are served, with no backend required.
|
|||||||
The application is a single-page app built with Vite and Tailwind CSS v4. All
|
The application is a single-page app built with Vite and Tailwind CSS v4. All
|
||||||
code lives in `src/main.js` with a class-based architecture:
|
code lives in `src/main.js` with a class-based architecture:
|
||||||
|
|
||||||
- **`CONFIG`**: Frozen configuration object (update interval, timeouts, axis
|
- **`CONFIG`**: Configuration object (update interval, timeouts, axis ticks,
|
||||||
ticks, etc.)
|
etc.). The interval menu sets `updateInterval`, the one value the page writes
|
||||||
|
into `CONFIG`; the timeouts, the time the history spans and the x-axis ticks
|
||||||
|
are computed from it
|
||||||
- **`HostState`**: Per-host state management — history buffer, latency tracking,
|
- **`HostState`**: Per-host state management — history buffer, latency tracking,
|
||||||
status transitions
|
status transitions
|
||||||
- **`AppState`**: Top-level state container — WAN hosts, local hosts, pause
|
- **`AppState`**: Top-level state container — WAN hosts, local hosts, pause
|
||||||
|
|||||||
@@ -23,6 +23,25 @@ latest run passes.
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-10-04: a target's name and URL and a debug log message show as the
|
||||||
|
characters they are and are never read as HTML (issue #29): a host row escapes
|
||||||
|
the name and URL it writes into its markup, and the debug log sets each line
|
||||||
|
as text. A unit test checks a target whose name and URL hold `<`, `>`, `"`,
|
||||||
|
`&` and `'`. `README.md` no longer calls `CONFIG` frozen: the interval menu
|
||||||
|
sets its `updateInterval`, and the values computed from it follow. `AppState`
|
||||||
|
declares the recovery probe's two properties, the sparkline axis functions
|
||||||
|
lose the parameters they did not use, and the comment on a target's history
|
||||||
|
names both kinds of entry it holds. Nothing the page does changed
|
||||||
|
- 2026-10-04: `script/` and `Makefile` follow the org models (issue #28):
|
||||||
|
`make dev` shims to the new `script/dev`, the Vite dev server, and the new
|
||||||
|
`make build` to `script/build`, the frontend production build.
|
||||||
|
`.prettierignore` no longer leaves out `backend/`, so `make fmt` and
|
||||||
|
`make fmt-check` cover `backend/README.md`; it leaves out
|
||||||
|
`backend/.golangci.yml` by name, the org standard file whose sha256
|
||||||
|
`backend/script/lint` checks. `script/install-precommit` and the date on
|
||||||
|
`script/bootstrap`'s pins are the org model again; `script/bootstrap`,
|
||||||
|
`script/fmt` and `script/fmt-check` each say in a comment why they differ from
|
||||||
|
it
|
||||||
- 2026-10-04: a frontend build on Node 26 or newer, such as `make test` on a
|
- 2026-10-04: a frontend build on Node 26 or newer, such as `make test` on a
|
||||||
host with Node 26, no longer prints Node's warning that `module.register()` is
|
host with Node 26, no longer prints Node's warning that `module.register()` is
|
||||||
deprecated (issue #32); the build in `Dockerfile` runs on Node 22, which never
|
deprecated (issue #32); the build in `Dockerfile` runs on Node 22, which never
|
||||||
|
|||||||
+18
-18
@@ -32,17 +32,16 @@ pattern as the repo root: the targets in `backend/Makefile` are thin shims over
|
|||||||
`test`, `fmt` and `fmt-check`:
|
`test`, `fmt` and `fmt-check`:
|
||||||
|
|
||||||
- `script/build` — compile the static `netwatch-server` binary with its version
|
- `script/build` — compile the static `netwatch-server` binary with its version
|
||||||
stamped in. The version is `VERSION` from the environment;
|
stamped in. The version is `VERSION` from the environment; when that is unset
|
||||||
when that is unset or empty, it falls back to `git describe` inside a git
|
or empty, it falls back to `git describe` inside a git checkout, then to `dev`
|
||||||
checkout, then to `dev`
|
|
||||||
- `script/test` — run the Go tests with the race detector and coverage. Go's
|
- `script/test` — run the Go tests with the race detector and coverage. Go's
|
||||||
`-timeout 30s` bounds the tests, not their compile. If they fail, they run
|
`-timeout 30s` bounds the tests, not their compile. If they fail, they run
|
||||||
again with `-v` for the details, and the script fails. The race detector needs
|
again with `-v` for the details, and the script fails. The race detector needs
|
||||||
a C compiler
|
a C compiler
|
||||||
- `script/lint` — check `.golangci.yml` against its pinned sha256, then run
|
- `script/lint` — check `.golangci.yml` against its pinned sha256, then run
|
||||||
golangci-lint. It runs inside the golangci-lint image of the lint stage of
|
golangci-lint. It runs inside the golangci-lint image of the lint stage of the
|
||||||
the root `Dockerfile`; from a checkout, run `make lint` at the repo root,
|
root `Dockerfile`; from a checkout, run `make lint` at the repo root, which
|
||||||
which builds that stage
|
builds that stage
|
||||||
- `script/fmt` — format the Go sources (writes)
|
- `script/fmt` — format the Go sources (writes)
|
||||||
- `script/fmt-check` — check Go formatting (read-only)
|
- `script/fmt-check` — check Go formatting (read-only)
|
||||||
- `script/run` — build and run the server locally
|
- `script/run` — build and run the server locally
|
||||||
@@ -61,8 +60,9 @@ flushes them to compressed files on disk for later analysis.
|
|||||||
|
|
||||||
## Design
|
## Design
|
||||||
|
|
||||||
The server is structured as an `fx`-wired Go application under `cmd/netwatch-server/`.
|
The server is structured as an `fx`-wired Go application under
|
||||||
Internal packages in `internal/` follow standard Go project layout:
|
`cmd/netwatch-server/`. Internal packages in `internal/` follow standard Go
|
||||||
|
project layout:
|
||||||
|
|
||||||
- **`config`**: Loads configuration from environment variables and config files
|
- **`config`**: Loads configuration from environment variables and config files
|
||||||
via Viper.
|
via Viper.
|
||||||
@@ -89,11 +89,12 @@ Internal packages in `internal/` follow standard Go project layout:
|
|||||||
| `REPORTS_PER_MINUTE` | `60` | Reports each client address may send a minute; see [Report limits](#report-limits) |
|
| `REPORTS_PER_MINUTE` | `60` | Reports each client address may send a minute; see [Report limits](#report-limits) |
|
||||||
| `CORS_ALLOWED_ORIGINS` | empty | Comma-separated origins whose pages may call the API; see [CORS](#cors) |
|
| `CORS_ALLOWED_ORIGINS` | empty | Comma-separated origins whose pages may call the API; see [CORS](#cors) |
|
||||||
|
|
||||||
`TRUSTED_PROXIES` defaults to `127.0.0.1/32,::1/128,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16`.
|
`TRUSTED_PROXIES` defaults to
|
||||||
The loopback entries cover a reverse proxy on the same host. A request whose
|
`127.0.0.1/32,::1/128,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16`. The loopback
|
||||||
direct peer is outside this set has its forwarded headers ignored, and the
|
entries cover a reverse proxy on the same host. A request whose direct peer is
|
||||||
direct peer is logged and rate-limited instead. The container image does not use
|
outside this set has its forwarded headers ignored, and the direct peer is
|
||||||
this default; see [Container image](#container-image).
|
logged and rate-limited instead. The container image does not use this default;
|
||||||
|
see [Container image](#container-image).
|
||||||
|
|
||||||
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
|
||||||
@@ -109,9 +110,9 @@ only nginx reaches it, and with `TRUSTED_PROXIES=127.0.0.1/32`, so it takes the
|
|||||||
client address nginx passes on and no other. `DATA_DIR` is `/data/reports`, on
|
client address nginx passes on and no other. `DATA_DIR` is `/data/reports`, on
|
||||||
the `/data` volume; before starting the server, the entrypoint creates it and
|
the `/data` volume; before starting the server, the entrypoint creates it and
|
||||||
gives it and `/data` to `netwatch` with `netwatch-server prepare-data-dir`,
|
gives it and `/data` to `netwatch` with `netwatch-server prepare-data-dir`,
|
||||||
which acts on nothing outside `/data`. nginx replaces the security headers
|
which acts on nothing outside `/data`. nginx replaces the security headers this
|
||||||
this server sets with those in the root `security-headers.conf`, so those are
|
server sets with those in the root `security-headers.conf`, so those are what
|
||||||
what clients of the image see.
|
clients of the image see.
|
||||||
|
|
||||||
The container's own `TRUSTED_PROXIES` goes to nginx instead: IP addresses or
|
The container's own `TRUSTED_PROXIES` goes to nginx instead: IP addresses or
|
||||||
CIDRs, separated by commas, of the reverse proxies in front of the container.
|
CIDRs, separated by commas, of the reverse proxies in front of the container.
|
||||||
@@ -162,8 +163,7 @@ credentials, so it is bounded instead. Both refusals below answer with the same
|
|||||||
to be written fill the cap on their own, and then no file is deleted. At
|
to be written fill the cap on their own, and then no file is deleted. At
|
||||||
start, report files past the cap, as after lowering it, are deleted the same
|
start, report files past the cap, as after lowering it, are deleted the same
|
||||||
way. So the cap is how much of the newest reports is kept: the default of 1
|
way. So the cap is how much of the newest reports is kept: the default of 1
|
||||||
GiB is small enough for any host; set it to the space you can give
|
GiB is small enough for any host; set it to the space you can give `DATA_DIR`.
|
||||||
`DATA_DIR`.
|
|
||||||
|
|
||||||
### CORS
|
### CORS
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -17,11 +17,13 @@
|
|||||||
#
|
#
|
||||||
# golangci-lint is not installed: make lint runs it in Docker, which
|
# golangci-lint is not installed: make lint runs it in Docker, which
|
||||||
# this script does not install either.
|
# this script does not install either.
|
||||||
|
#
|
||||||
|
# Unlike the org model: Go and gcc for backend/, a newer node for eslint.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
# Pinned versions, 2026-07-07
|
# Pinned versions, 2026-07-06
|
||||||
NODE_VERSION="22.17.0"
|
NODE_VERSION="22.17.0"
|
||||||
# The oldest node the frontend's dependencies accept: the "engines"
|
# The oldest node the frontend's dependencies accept: the "engines"
|
||||||
# field of eslint 10.12.0, the most demanding of them, asks for 22.13.0
|
# field of eslint 10.12.0, the most demanding of them, asks for 22.13.0
|
||||||
|
|||||||
Executable
+13
@@ -0,0 +1,13 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# script/build: build the frontend for production into dist/. The Go
|
||||||
|
# backend is built by backend/script/build.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
|
main() {
|
||||||
|
cd "$ROOT"
|
||||||
|
yarn build
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
Executable
+13
@@ -0,0 +1,13 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# script/dev: run the frontend's Vite dev server. It proxies /api to a
|
||||||
|
# netwatch-server running locally (see vite.config.js).
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
|
main() {
|
||||||
|
cd "$ROOT"
|
||||||
|
yarn dev
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/fmt: format the whole repo (writes): prettier over everything
|
# script/fmt: format the whole repo (writes): prettier over everything
|
||||||
# it understands, then gofmt over the Go backend.
|
# it understands, then gofmt over the Go backend.
|
||||||
|
# The org model formats only markdown; this repo also has JS and Go.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/fmt-check: check formatting across the whole repo (read-only).
|
# script/fmt-check: check formatting across the whole repo (read-only).
|
||||||
# Same scope as script/fmt, but fails instead of writing.
|
# Same scope as script/fmt, but fails instead of writing.
|
||||||
|
# The org model checks only markdown; this repo also has JS and Go.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/frontend-fmt: format the frontend and every other file prettier
|
# script/frontend-fmt: format the frontend and every other file prettier
|
||||||
# understands, repo-wide (writes). backend/ is in .prettierignore; Go
|
# understands, repo-wide (writes), the markdown in backend/ included.
|
||||||
# sources are formatted by backend/script/fmt.
|
# Prettier does not read Go; backend/script/fmt formats the Go sources.
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
|||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
hook=".git/hooks/pre-commit"
|
hook=".git/hooks/pre-commit"
|
||||||
printf '#!/bin/sh\nset -e\nscript/precommit\n' > "$hook"
|
printf '#!/bin/sh\nset -e\nscript/precommit\n' > .git/hooks/pre-commit
|
||||||
chmod +x "$hook"
|
chmod +x .git/hooks/pre-commit
|
||||||
echo "pre-commit hook installed: runs script/precommit"
|
echo "pre-commit hook installed: runs script/precommit"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+36
-14
@@ -8,6 +8,8 @@
|
|||||||
// display their real value in the latency figure. The history buffer holds
|
// display their real value in the latency figure. The history buffer holds
|
||||||
// maxHistoryPoints samples (historyDuration / updateInterval).
|
// maxHistoryPoints samples (historyDuration / updateInterval).
|
||||||
// reportInterval is how often collected samples are POSTed to the backend.
|
// reportInterval is how often collected samples are POSTed to the backend.
|
||||||
|
// The interval menu changes updateInterval while the page runs; the
|
||||||
|
// getters compute their values from it each time they are read.
|
||||||
export const CONFIG = {
|
export const CONFIG = {
|
||||||
updateInterval: 3000,
|
updateInterval: 3000,
|
||||||
maxHistoryPoints: 100,
|
maxHistoryPoints: 100,
|
||||||
@@ -201,7 +203,10 @@ export class HostState {
|
|||||||
constructor(host, pinned = false) {
|
constructor(host, pinned = false) {
|
||||||
this.name = host.name;
|
this.name = host.name;
|
||||||
this.url = host.url;
|
this.url = host.url;
|
||||||
this.history = []; // { timestamp, latency, paused }
|
// Each entry is either a check's result, { timestamp, latency,
|
||||||
|
// error }, or a round skipped while paused, { timestamp,
|
||||||
|
// latency: null, paused: true }.
|
||||||
|
this.history = [];
|
||||||
this.lastLatency = null;
|
this.lastLatency = null;
|
||||||
this.status = "pending"; // 'online' | 'offline' | 'error' | 'pending'
|
this.status = "pending"; // 'online' | 'offline' | 'error' | 'pending'
|
||||||
this.pinned = pinned;
|
this.pinned = pinned;
|
||||||
@@ -271,6 +276,10 @@ export class AppState {
|
|||||||
this.local = localHosts.map((h) => new HostState(h));
|
this.local = localHosts.map((h) => new HostState(h));
|
||||||
this.paused = false;
|
this.paused = false;
|
||||||
this.tickCount = 0;
|
this.tickCount = 0;
|
||||||
|
// The recovery probe's timer, null while it is not running, and the
|
||||||
|
// checks it started last.
|
||||||
|
this._recoveryProbeId = null;
|
||||||
|
this._recoveryProbeChecks = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
get allHosts() {
|
get allHosts() {
|
||||||
@@ -580,8 +589,8 @@ class SparklineRenderer {
|
|||||||
const ch = h - m.top - m.bottom;
|
const ch = h - m.top - m.bottom;
|
||||||
|
|
||||||
ctx.clearRect(0, 0, w, h);
|
ctx.clearRect(0, 0, w, h);
|
||||||
SparklineRenderer._drawYAxis(ctx, w, h, m, ch);
|
SparklineRenderer._drawYAxis(ctx, w, m, ch);
|
||||||
SparklineRenderer._drawXAxis(ctx, w, h, m, cw);
|
SparklineRenderer._drawXAxis(ctx, h, m, cw);
|
||||||
|
|
||||||
const len = history.length;
|
const len = history.length;
|
||||||
const pw = cw / (CONFIG.maxHistoryPoints - 1);
|
const pw = cw / (CONFIG.maxHistoryPoints - 1);
|
||||||
@@ -597,7 +606,7 @@ class SparklineRenderer {
|
|||||||
SparklineRenderer._drawTip(ctx, history, getX, getY);
|
SparklineRenderer._drawTip(ctx, history, getX, getY);
|
||||||
}
|
}
|
||||||
|
|
||||||
static _drawYAxis(ctx, w, h, m, ch) {
|
static _drawYAxis(ctx, w, m, ch) {
|
||||||
ctx.font = "300 12px monospace";
|
ctx.font = "300 12px monospace";
|
||||||
ctx.textAlign = "right";
|
ctx.textAlign = "right";
|
||||||
ctx.textBaseline = "middle";
|
ctx.textBaseline = "middle";
|
||||||
@@ -614,7 +623,7 @@ class SparklineRenderer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static _drawXAxis(ctx, w, h, m, cw) {
|
static _drawXAxis(ctx, h, m, cw) {
|
||||||
ctx.textAlign = "center";
|
ctx.textAlign = "center";
|
||||||
ctx.textBaseline = "top";
|
ctx.textBaseline = "top";
|
||||||
for (const tick of CONFIG.xAxisTicks) {
|
for (const tick of CONFIG.xAxisTicks) {
|
||||||
@@ -702,7 +711,18 @@ class SparklineRenderer {
|
|||||||
// horizontally.
|
// horizontally.
|
||||||
const STATUS_TEXT_CLASS = "status-text text-xs text-right col-span-2 mt-5";
|
const STATUS_TEXT_CLASS = "status-text text-xs text-right col-span-2 mt-5";
|
||||||
|
|
||||||
function hostRowHTML(host, index, showPin = true) {
|
// Escapes text for HTML, so it shows as written inside an element or a
|
||||||
|
// quoted attribute and is never read as markup.
|
||||||
|
function escapeHTML(text) {
|
||||||
|
return text
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll('"', """)
|
||||||
|
.replaceAll("'", "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hostRowHTML(host, index, showPin = true) {
|
||||||
const pinColor = host.pinned
|
const pinColor = host.pinned
|
||||||
? "text-blue-500"
|
? "text-blue-500"
|
||||||
: "text-gray-600 hover:text-gray-400";
|
: "text-gray-600 hover:text-gray-400";
|
||||||
@@ -721,12 +741,12 @@ function hostRowHTML(host, index, showPin = true) {
|
|||||||
<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="w-3 h-3 rounded-full flex-shrink-0 bg-[#6b7280]"></div>
|
||||||
<span class="font-medium text-white truncate">${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}">
|
||||||
<span class="text-gray-500">---</span>
|
<span class="text-gray-500">---</span>
|
||||||
</div>
|
</div>
|
||||||
<a href="${host.url}" target="_blank" rel="noopener" class="text-xs text-gray-500 truncate block col-span-2 -mt-2">${host.url}</a>
|
<a href="${escapeHTML(host.url)}" target="_blank" rel="noopener" class="text-xs text-gray-500 truncate block col-span-2 -mt-2">${escapeHTML(host.url)}</a>
|
||||||
<div class="${STATUS_TEXT_CLASS} text-gray-500" data-host="${index}">waiting...</div>
|
<div class="${STATUS_TEXT_CLASS} text-gray-500" data-host="${index}">waiting...</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow sparkline-container rounded overflow-hidden border border-gray-700/30">
|
<div class="flex-grow sparkline-container rounded overflow-hidden border border-gray-700/30">
|
||||||
@@ -1042,14 +1062,16 @@ function renderDebugLog() {
|
|||||||
info: "text-gray-300",
|
info: "text-gray-300",
|
||||||
debug: "text-gray-500",
|
debug: "text-gray-500",
|
||||||
};
|
};
|
||||||
el.innerHTML = debugLog
|
el.replaceChildren(
|
||||||
.map((entry) => {
|
...debugLog.map((entry) => {
|
||||||
const ts = formatUTCTimestamp(entry.timestamp);
|
const ts = formatUTCTimestamp(entry.timestamp);
|
||||||
const cls = levelColors[entry.level] || "text-gray-400";
|
|
||||||
const lvl = entry.level.toUpperCase().padEnd(7);
|
const lvl = entry.level.toUpperCase().padEnd(7);
|
||||||
return `<div class="${cls}">${ts} ${lvl} ${entry.message}</div>`;
|
const line = document.createElement("div");
|
||||||
})
|
line.className = levelColors[entry.level] || "text-gray-400";
|
||||||
.join("");
|
line.textContent = `${ts} ${lvl} ${entry.message}`;
|
||||||
|
return line;
|
||||||
|
}),
|
||||||
|
);
|
||||||
el.scrollTop = el.scrollHeight;
|
el.scrollTop = el.scrollHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
AppState,
|
AppState,
|
||||||
CONFIG,
|
CONFIG,
|
||||||
greyOutUI,
|
greyOutUI,
|
||||||
|
hostRowHTML,
|
||||||
HostState,
|
HostState,
|
||||||
humanDuration,
|
humanDuration,
|
||||||
latencyClass,
|
latencyClass,
|
||||||
@@ -230,6 +231,32 @@ test("at a 30000ms interval, after the user pauses and resumes during a round, n
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The page shows < > " & and ' in a row's markup as
|
||||||
|
// < > " & and '.
|
||||||
|
test(`a target whose name and URL hold < > " & and ' shows those characters in its row`, () => {
|
||||||
|
const host = new HostState({
|
||||||
|
name: `<b>"x" & 'y'</b>`,
|
||||||
|
url: `https://x.test/<b>?a="x"&b='y'`,
|
||||||
|
});
|
||||||
|
const row = hostRowHTML(host, 0);
|
||||||
|
assert.doesNotMatch(row, /<b>/);
|
||||||
|
assert.ok(
|
||||||
|
row.includes(
|
||||||
|
"><b>"x" & 'y'</b></span>",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
row.includes(
|
||||||
|
'href="https://x.test/<b>?a="x"&b='y'"',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
row.includes(
|
||||||
|
">https://x.test/<b>?a="x"&b='y'</a>",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
for (const [seconds, text] of [
|
for (const [seconds, text] of [
|
||||||
[0, "0s"],
|
[0, "0s"],
|
||||||
[1, "1s"],
|
[1, "1s"],
|
||||||
|
|||||||
Reference in New Issue
Block a user