diff --git a/README.md b/README.md index a963e6a..e8d492f 100644 --- a/README.md +++ b/README.md @@ -111,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 diff --git a/TODO.md b/TODO.md index 12cf93b..1113703 100644 --- a/TODO.md +++ b/TODO.md @@ -23,6 +23,15 @@ 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=@` shims to the new `script/add-dependency`, which runs `yarn add --dev`, so `package.json` and diff --git a/src/main.js b/src/main.js index 2793022..0483ed7 100644 --- a/src/main.js +++ b/src/main.js @@ -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) {
- ${host.name} + ${escapeHTML(host.name)}
---
- ${host.url} + ${escapeHTML(host.url)}
waiting...
@@ -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 `
${ts} ${lvl} ${entry.message}
`; - }) - .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; } diff --git a/test/unit/main.test.js b/test/unit/main.test.js index 0aea619..a5b9510 100644 --- a/test/unit/main.test.js +++ b/test/unit/main.test.js @@ -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: `"x" & 'y'`, + url: `https://x.test/?a="x"&b='y'`, + }); + const row = hostRowHTML(host, 0); + assert.doesNotMatch(row, //); + assert.ok( + row.includes( + "><b>"x" & 'y'</b>", + ), + ); + 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'", + ), + ); +}); + for (const [seconds, text] of [ [0, "0s"], [1, "1s"],