Compare commits
2
Commits
bb1f32d36b
...
4091a3b41a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4091a3b41a | ||
|
|
dc11beb6fe |
@@ -1,5 +1,5 @@
|
||||
.PHONY: bootstrap setup dev build test lint fmt fmt-check check \
|
||||
frontend-check frontend-viewport-test docker hooks
|
||||
add-dependency tidy frontend-check frontend-viewport-test docker hooks
|
||||
|
||||
# Standard targets are thin shims; the implementations live in script/
|
||||
# per the scripts-to-rule-them-all pattern (see the Entrypoints section
|
||||
@@ -34,6 +34,14 @@ fmt-check:
|
||||
check:
|
||||
@script/check
|
||||
|
||||
# make add-dependency PACKAGE=<name>@<version>. PACKAGE reaches the
|
||||
# script through the environment, so the shell never reads it as code.
|
||||
add-dependency:
|
||||
@script/add-dependency "$$PACKAGE"
|
||||
|
||||
tidy:
|
||||
@script/tidy
|
||||
|
||||
# The frontend tests and format check, for Dockerfile's frontend stage,
|
||||
# which has neither Go nor Docker. Use check everywhere else.
|
||||
frontend-check:
|
||||
|
||||
@@ -58,6 +58,13 @@ halves, so the root `make check` fails if either one is broken. We provide:
|
||||
- `script/fmt` — format all files (writes): prettier, then gofmt over `backend/`
|
||||
- `script/fmt-check` — check formatting (read-only): prettier, then gofmt
|
||||
- `script/check` — run test, lint, and fmt-check
|
||||
- `script/add-dependency` — add a frontend package, or move one to another
|
||||
version: `make add-dependency PACKAGE=<name>@<version>` runs `yarn add --dev`,
|
||||
which changes `package.json` and `yarn.lock` together, then
|
||||
`yarn install --frozen-lockfile`
|
||||
- `script/tidy` — run `go mod tidy` in `backend/`: to add a Go module, import it
|
||||
and run `make tidy`; to move one to another version, edit its `require` line
|
||||
in `backend/go.mod`, then run `make tidy`
|
||||
- `script/frontend-test` — run the unit tests in `test/unit/` with Node's
|
||||
built-in test runner, through the `test` script in `package.json`, and if any
|
||||
fails, run them again listing every test, and fail; then the production build.
|
||||
@@ -104,8 +111,10 @@ files are served, with no backend required.
|
||||
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:
|
||||
|
||||
- **`CONFIG`**: Frozen configuration object (update interval, timeouts, axis
|
||||
ticks, etc.)
|
||||
- **`CONFIG`**: Configuration object (update interval, timeouts, axis ticks,
|
||||
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,
|
||||
status transitions
|
||||
- **`AppState`**: Top-level state container — WAN hosts, local hosts, pause
|
||||
|
||||
@@ -23,6 +23,23 @@ latest run passes.
|
||||
|
||||
# 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: a dependency can be added without running yarn or go by hand
|
||||
(issue #45): `make add-dependency PACKAGE=<name>@<version>` shims to the new
|
||||
`script/add-dependency`, which runs `yarn add --dev`, so `package.json` and
|
||||
`yarn.lock` change together, then `yarn install --frozen-lockfile`; the same
|
||||
command moves a package to another version. `make tidy` shims to the new
|
||||
`script/tidy`, which runs `go mod tidy` in `backend/`: a Go module is added by
|
||||
importing it, or moved by editing its `require` line, then `make tidy`.
|
||||
`script/bootstrap` still installs with `--frozen-lockfile`
|
||||
- 2026-10-04: the backend serves Prometheus metrics (issue #94). With
|
||||
`METRICS_USERNAME` and `METRICS_PASSWORD` both set, it records request
|
||||
duration and response size through `go-http-metrics` and serves them, with
|
||||
|
||||
Executable
+30
@@ -0,0 +1,30 @@
|
||||
#!/bin/sh
|
||||
# script/add-dependency: add a frontend package, or move one to another
|
||||
# version, with yarn add, which changes package.json and yarn.lock
|
||||
# together; then install from yarn.lock with --frozen-lockfile, as
|
||||
# script/bootstrap does, to show it installs as written. --dev because
|
||||
# no frontend package is needed when the page runs: it ships as the
|
||||
# built dist/.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
|
||||
usage() {
|
||||
echo "usage: make add-dependency PACKAGE=<name>@<version>" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
main() {
|
||||
# Exactly one package. A value beginning with - would reach yarn as
|
||||
# an option; yarn would quietly drop all but the first of several
|
||||
# packages given in one value.
|
||||
[ "$#" -eq 1 ] || usage
|
||||
case "$1" in
|
||||
"" | -* | *[[:space:]]*) usage ;;
|
||||
esac
|
||||
cd "$ROOT"
|
||||
yarn add --dev "$1"
|
||||
yarn install --frozen-lockfile
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
#!/bin/sh
|
||||
# script/tidy: run go mod tidy in backend/, which adds the modules the
|
||||
# Go sources import, drops those they no longer do, and updates go.sum.
|
||||
set -eu
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||
|
||||
main() {
|
||||
cd "$ROOT/backend"
|
||||
go mod tidy
|
||||
}
|
||||
|
||||
main "$@"
|
||||
+36
-14
@@ -8,6 +8,8 @@
|
||||
// display their real value in the latency figure. The history buffer holds
|
||||
// maxHistoryPoints samples (historyDuration / updateInterval).
|
||||
// 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 = {
|
||||
updateInterval: 3000,
|
||||
maxHistoryPoints: 100,
|
||||
@@ -201,7 +203,10 @@ export class HostState {
|
||||
constructor(host, pinned = false) {
|
||||
this.name = host.name;
|
||||
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.status = "pending"; // 'online' | 'offline' | 'error' | 'pending'
|
||||
this.pinned = pinned;
|
||||
@@ -271,6 +276,10 @@ export class AppState {
|
||||
this.local = localHosts.map((h) => new HostState(h));
|
||||
this.paused = false;
|
||||
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() {
|
||||
@@ -580,8 +589,8 @@ class SparklineRenderer {
|
||||
const ch = h - m.top - m.bottom;
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
SparklineRenderer._drawYAxis(ctx, w, h, m, ch);
|
||||
SparklineRenderer._drawXAxis(ctx, w, h, m, cw);
|
||||
SparklineRenderer._drawYAxis(ctx, w, m, ch);
|
||||
SparklineRenderer._drawXAxis(ctx, h, m, cw);
|
||||
|
||||
const len = history.length;
|
||||
const pw = cw / (CONFIG.maxHistoryPoints - 1);
|
||||
@@ -597,7 +606,7 @@ class SparklineRenderer {
|
||||
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.textAlign = "right";
|
||||
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.textBaseline = "top";
|
||||
for (const tick of CONFIG.xAxisTicks) {
|
||||
@@ -702,7 +711,18 @@ class SparklineRenderer {
|
||||
// horizontally.
|
||||
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
|
||||
? "text-blue-500"
|
||||
: "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="flex items-center gap-2 min-w-[200px]">
|
||||
<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 class="latency-value text-4xl font-bold tabular-nums text-right mt-3" data-host="${index}">
|
||||
<span class="text-gray-500">---</span>
|
||||
</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>
|
||||
<div class="flex-grow sparkline-container rounded overflow-hidden border border-gray-700/30">
|
||||
@@ -1042,14 +1062,16 @@ function renderDebugLog() {
|
||||
info: "text-gray-300",
|
||||
debug: "text-gray-500",
|
||||
};
|
||||
el.innerHTML = debugLog
|
||||
.map((entry) => {
|
||||
el.replaceChildren(
|
||||
...debugLog.map((entry) => {
|
||||
const ts = formatUTCTimestamp(entry.timestamp);
|
||||
const cls = levelColors[entry.level] || "text-gray-400";
|
||||
const lvl = entry.level.toUpperCase().padEnd(7);
|
||||
return `<div class="${cls}">${ts} ${lvl} ${entry.message}</div>`;
|
||||
})
|
||||
.join("");
|
||||
const line = document.createElement("div");
|
||||
line.className = levelColors[entry.level] || "text-gray-400";
|
||||
line.textContent = `${ts} ${lvl} ${entry.message}`;
|
||||
return line;
|
||||
}),
|
||||
);
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
AppState,
|
||||
CONFIG,
|
||||
greyOutUI,
|
||||
hostRowHTML,
|
||||
HostState,
|
||||
humanDuration,
|
||||
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 [
|
||||
[0, "0s"],
|
||||
[1, "1s"],
|
||||
|
||||
Reference in New Issue
Block a user