5 Commits
Author SHA1 Message Date
sneak f279dda27a feat(frontend): post collected samples to /api/v1/reports (closes #53)
check / check (push) Successful in 13s
A Reporter class beside AppState POSTs a JSON report to the same-origin
/api/v1/reports every reportInterval (default 60s, a new CONFIG value).
buildReport is a pure function of host state: it emits each host's
unreported, non-paused samples ({t, latency, error}) plus a random
per-browser clientId from localStorage, geo null, and an ISO 8601 UTC
timestamp. A per-host high-water mark keyed on sample time makes every
report a delta and only advances on a delivered POST, so a failed send
re-sends next interval, bounded by the existing history window. Failure
is quiet: one debug-log line per outage, one on recovery, no alert, no
tight retry loop, never blocking probing. While paused nothing is sent.

vite.config.js proxies /api to 127.0.0.1:8080 so yarn dev exercises the
real path against a local netwatch-server. Backend and schema unchanged.

Model: opus-4-8
2026-09-21 12:45:23 +00:00
clawbot f7c7f92e27 fix(frontend): meet the 44x44 minimum tap target on every control (closes #43)
check / check (push) Successful in 10s
2026-08-10 16:12:00 +02:00
clawbot 852a11eec2 fix: wrap per-host status line so 320px viewport does not scroll (closes #42)
check / check (push) Has been cancelled
2026-08-10 16:07:28 +02:00
clawbot 25a852d35c build: Dockerfile.backend multistage lint stage (closes #17)
check / check (push) Has been cancelled
2026-08-10 16:04:48 +02:00
clawbot a644efe9ff chore: root .editorconfig and hardened .gitignore (closes #15)
check / check (push) Successful in 45s
2026-08-10 15:47:49 +02:00
9 changed files with 303 additions and 43 deletions
+25 -2
View File
@@ -1,4 +1,27 @@
node_modules/ # OS
dist/
.DS_Store .DS_Store
Thumbs.db
# Editors
*.swp
*.swo
*~
*.bak
.idea/
.vscode/
*.sublime-*
# Node
node_modules/
# Environment / secrets
.env
.env.*
*.pem
*.key
# Build output
dist/
# Logs
*.log *.log
+33 -8
View File
@@ -1,25 +1,50 @@
# Lint stage — fast feedback on formatting and lint issues. The
# golangci/golangci-lint image ships Go, gofmt, make and the linter, so
# nothing is installed here.
# golangci/golangci-lint:v2.7.2 (2026-08-09)
FROM golangci/golangci-lint@sha256:5d6d5c70a61f1356adfd9dd6316ce286799fefc9d743421356ff1b00842368ba AS lint
WORKDIR /src
COPY backend/go.mod backend/go.sum ./
RUN go mod download
COPY backend/ .
RUN make fmt-check
RUN make lint
# Build stage
# golang:1.25-alpine (2026-02-27) # golang:1.25-alpine (2026-02-27)
FROM golang:1.25-alpine@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced AS builder FROM golang:1.25-alpine@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced AS builder
RUN apk add --no-cache git make gcc musl-dev RUN apk add --no-cache make
# golangci-lint v2.7.2 (2026-02-27) WORKDIR /src
RUN CGO_ENABLED=0 go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@9f61b0f53f80672872fced07b6874397c3ed197b
# Force BuildKit to run the lint stage before proceeding. BuildKit runs
# stages in parallel by default; without this no-op copy a lint failure
# would not gate compilation.
COPY --from=lint /src/go.sum /dev/null
WORKDIR /repo/backend
COPY backend/go.mod backend/go.sum ./ COPY backend/go.mod backend/go.sum ./
RUN go mod download RUN go mod download
COPY .git /repo/.git
COPY backend/ . COPY backend/ .
RUN make check RUN make test
RUN make build
# The build is driven through the Makefile so there is exactly one
# definition of the build command; it expands to
# CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.Version=... -X main.Buildarch=..."
# VERSION is handed over in the environment rather than as a make
# variable so it still reaches the build if the target is ever turned
# into a shim around a script.
ARG VERSION=dev
RUN VERSION="${VERSION}" make build
# Runtime stage
# alpine:3.23 (2026-02-27) # alpine:3.23 (2026-02-27)
FROM alpine:3.23@sha256:25109184c71bdad752c8312a8623239686a9a2071e8825f20acb8f2198c3f659 FROM alpine:3.23@sha256:25109184c71bdad752c8312a8623239686a9a2071e8825f20acb8f2198c3f659
RUN apk add --no-cache ca-certificates RUN apk add --no-cache ca-certificates
COPY --from=builder /repo/backend/netwatch-server /usr/local/bin/netwatch-server COPY --from=builder /src/netwatch-server /usr/local/bin/netwatch-server
EXPOSE 8080 EXPOSE 8080
ENTRYPOINT ["netwatch-server"] ENTRYPOINT ["netwatch-server"]
+15
View File
@@ -23,6 +23,9 @@ docker build -t netwatch .
docker run -p 8080:8080 netwatch docker run -p 8080:8080 netwatch
``` ```
`yarn dev` proxies `/api` to `http://127.0.0.1:8080`, so a locally running
`netwatch-server` (see `backend/`) receives the reports the page posts.
## Entrypoints ## Entrypoints
This repository adheres to the This repository adheres to the
@@ -71,6 +74,18 @@ code lives in `src/main.js` with a class-based architecture:
- **`tick()`**: Main loop — measures all hosts in parallel via `Promise.all`, - **`tick()`**: Main loop — measures all hosts in parallel via `Promise.all`,
pushes samples, redraws UI. When paused, pushes blank markers (no probes, no pushes samples, redraws UI. When paused, pushes blank markers (no probes, no
false outage) false outage)
- **`Reporter`**: Posts collected samples to the backend
### Reporting
Every `reportInterval` (default 60s) the page POSTs a JSON report to the
same-origin path `/api/v1/reports`: a random per-browser `clientId` kept in
`localStorage`, `geo` sent as null, and each host's unreported, non-paused
samples (timestamp, latency, error). A per-host high-water mark makes every
report a delta, so only new samples are sent; the mark advances only on a
delivered report, and while paused nothing is sent. Delivery failure is quiet —
one debug-log line per outage, retried at the next interval, never blocking
probing. The report-building step is a pure function of host state.
### Monitoring targets ### Monitoring targets
+21
View File
@@ -22,6 +22,27 @@ files, so merging it also closes most compliance gaps.
# Completed Steps # Completed Steps
- 2026-09-21: frontend reporting client — a `Reporter` class posts collected
samples to `/api/v1/reports` every `reportInterval` (default 60s) as a
per-host delta, with the report-building step a pure function of host state;
`vite.config.js` proxies `/api` to the local backend for `yarn dev`
- 2026-08-10: every interactive control now meets the 44x44 CSS px minimum tap
target (`.pin-btn`, `#interval-select`, the debug-log label and, on narrow
viewports, `#pause-btn`). The pin button's hit area grows via matching
negative margins, so its layout footprint and row density are unchanged
- 2026-08-10: per-host status line wraps below the 768px breakpoint instead of
forcing horizontal page scroll at 320px
- 2026-08-09: `Dockerfile.backend` reworked to the mandated Go multistage
lint-stage pattern: separate `lint` stage on the hash-pinned
`golangci/golangci-lint` image, `COPY --from=lint` stage dependency,
`CGO_ENABLED=0` static build driven by `ARG VERSION`, and no more `COPY .git`
- 2026-08-09: dotfile compliance — lifted `backend/.editorconfig` to the repo
root so `root = true` covers the frontend too, and replaced `.gitignore` with
the org model (OS, editor, node, and environment/secrets sections) plus this
repo's `dist/` and `*.log`. `.env`, `.env.*`, `*.pem`, and `*.key` are now
ignored repo-wide, not just under `backend/`. Excluding `.git` from
`.dockerignore` stays deferred: both images read git metadata at build time
(`COPY .git` in `Dockerfile.backend`, `git rev-parse` in `vite.config.js`)
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile - 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile
shims, README Entrypoints section shims, README Entrypoints section
- 2026-02-27: backend with buffered zstd-compressed report storage; CI workflow - 2026-02-27: backend with buffered zstd-compressed report storage; CI workflow
+9 -12
View File
@@ -1,25 +1,22 @@
UNAME_S := $(shell uname -s) # VERSION is overridable (the Dockerfile passes its ARG VERSION in) and
VERSION := $(shell git describe --always --dirty) # degrades to "dev" when git is unavailable or there is no .git — the
# build must not depend on the repository history being in the build
# context.
VERSION ?= $(shell { git describe --always --dirty; } 2>/dev/null || echo dev)
BUILDARCH := $(shell uname -m) BUILDARCH := $(shell uname -m)
BINARY := netwatch-server BINARY := netwatch-server
GOLDFLAGS += -s -w
GOLDFLAGS += -X main.Version=$(VERSION) GOLDFLAGS += -X main.Version=$(VERSION)
GOLDFLAGS += -X main.Buildarch=$(BUILDARCH) GOLDFLAGS += -X main.Buildarch=$(BUILDARCH)
ifeq ($(UNAME_S),Darwin)
GOFLAGS := -ldflags "$(GOLDFLAGS)"
else
GOFLAGS = -ldflags "-linkmode external -extldflags -static $(GOLDFLAGS)"
endif
.PHONY: all build test lint fmt fmt-check check docker hooks run clean .PHONY: all build test lint fmt fmt-check check docker hooks run clean
all: build all: build
build: ./$(BINARY) build:
CGO_ENABLED=0 go build -trimpath -ldflags "$(GOLDFLAGS)" \
./$(BINARY): $(shell find . -name '*.go' -type f) go.mod go.sum -o ./$(BINARY) ./cmd/netwatch-server/
go build -o $@ $(GOFLAGS) ./cmd/netwatch-server/
test: test:
timeout 30 go test ./... timeout 30 go test ./...
+159 -20
View File
@@ -7,9 +7,11 @@ import "./styles.css";
// graphMaxLatency — values above it pin to the top of the chart but still // graphMaxLatency — values above it pin to the top of the chart but still
// 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.
const CONFIG = { const CONFIG = {
updateInterval: 3000, updateInterval: 3000,
maxHistoryPoints: 100, maxHistoryPoints: 100,
reportInterval: 60000,
get historyDuration() { get historyDuration() {
return (this.maxHistoryPoints * this.updateInterval) / 1000; return (this.maxHistoryPoints * this.updateInterval) / 1000;
}, },
@@ -346,6 +348,121 @@ class AppState {
} }
} }
// --- Reporting ---------------------------------------------------------------
// A random id identifying this browser across reports. Generated once and
// kept in localStorage; if storage is unavailable (e.g. private mode) a
// fresh id is used for this session only.
function getClientId() {
const key = "netwatch-client-id";
try {
let id = localStorage.getItem(key);
if (!id) {
id = crypto.randomUUID();
localStorage.setItem(key, id);
}
return id;
} catch {
return crypto.randomUUID();
}
}
// Build the delta report body the backend decodes, plus the new per-host
// high-water marks. Pure function of the passed state: `hosts` is an array
// of { name, url, status, history }, `since` maps a host url to the Unix-ms
// timestamp of the last sample already reported for it, and `now` is a Date.
// Only non-paused samples newer than the mark are included. Returns null
// when no host has an unreported sample.
function buildReport(hosts, clientId, now, since) {
const reportHosts = [];
const marks = new Map();
for (const host of hosts) {
const mark = since.get(host.url) ?? 0;
const samples = [];
let high = mark;
for (const p of host.history) {
if (p.paused) continue;
if (p.timestamp <= mark) continue;
samples.push({
t: p.timestamp,
latency: p.latency,
error: p.error ?? null,
});
if (p.timestamp > high) high = p.timestamp;
}
if (samples.length === 0) continue;
reportHosts.push({
name: host.name,
url: host.url,
status: host.status,
history: samples,
});
marks.set(host.url, high);
}
if (reportHosts.length === 0) return null;
return {
body: {
clientId,
geo: null,
hosts: reportHosts,
timestamp: now.toISOString(),
},
marks,
};
}
// Periodically POSTs unreported samples to the same-origin backend. Holds
// the per-host high-water marks so each report is a delta; marks only
// advance on a delivered report, so a failed POST simply re-sends those
// samples next interval (bounded by the history window — whatever has since
// fallen out is dropped). Failure is quiet: one debug line per outage, one
// on recovery, never an alert, never a tight retry loop.
class Reporter {
constructor(state, clientId, intervalMs) {
this.state = state;
this.clientId = clientId;
this.intervalMs = intervalMs;
this.marks = new Map();
this.failing = false;
this.timerId = null;
}
start() {
if (this.timerId) return;
this.timerId = setInterval(() => this.flush(), this.intervalMs);
}
async flush() {
if (this.state.paused) return;
const report = buildReport(
this.state.allHosts,
this.clientId,
new Date(),
this.marks,
);
if (!report) return;
try {
const resp = await fetch("/api/v1/reports", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "omit",
body: JSON.stringify(report.body),
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
for (const [url, t] of report.marks) this.marks.set(url, t);
if (this.failing) {
log.debug("Report delivery recovered");
this.failing = false;
}
} catch (err) {
if (!this.failing) {
log.debug(`Report delivery failed: ${err.message}`);
this.failing = true;
}
}
}
}
// --- Latency Measurement ----------------------------------------------------- // --- Latency Measurement -----------------------------------------------------
async function measureLatency(url) { async function measureLatency(url) {
@@ -537,6 +654,12 @@ class SparklineRenderer {
// --- UI Renderer ------------------------------------------------------------- // --- UI Renderer -------------------------------------------------------------
// The per-host status line must stay wrappable: its populated content is
// wider than the host column at a 320px viewport, and `whitespace-nowrap`
// here overflows the element and forces the whole document to scroll
// horizontally.
const STATUS_TEXT_CLASS = "status-text text-xs text-right col-span-2 mt-5";
function hostRowHTML(host, index, showPin = true) { function hostRowHTML(host, index, showPin = true) {
const pinColor = host.pinned const pinColor = host.pinned
? "text-blue-500" ? "text-blue-500"
@@ -562,7 +685,7 @@ function hostRowHTML(host, index, showPin = true) {
<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="${host.url}" target="_blank" rel="noopener" class="text-xs text-gray-500 truncate block col-span-2 -mt-2">${host.url}</a>
<div class="status-text text-xs text-gray-500 whitespace-nowrap text-right col-span-2 mt-5" 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">
<canvas class="sparkline-canvas w-full" data-host="${index}" height="${CONFIG.canvasHeight}"></canvas> <canvas class="sparkline-canvas w-full" data-host="${index}" height="${CONFIG.canvasHeight}"></canvas>
@@ -671,7 +794,7 @@ function buildUI(state) {
</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>
<p class="mt-2"> <p class="mt-2">
<label class="cursor-pointer"> <label class="debug-toggle-label cursor-pointer">
<input type="checkbox" id="debug-toggle" class="mr-1"> <input type="checkbox" id="debug-toggle" class="mr-1">
<span>Debug log</span> <span>Debug log</span>
</label> </label>
@@ -688,6 +811,26 @@ function buildUI(state) {
// --- UI Updaters ------------------------------------------------------------- // --- UI Updaters -------------------------------------------------------------
// Renders `min 1ms / med 2ms / avg 3ms / max 4ms`. Each label, value and
// trailing separator is one unbreakable unit, so wrapping only ever happens
// between stats and a wrapped line never starts with a separator.
function statusStatsHTML(stats) {
return stats
.map(([label, value], i) => {
const sep =
i < stats.length - 1
? ` <span class="text-gray-500">/</span>`
: "";
return (
`<span class="whitespace-nowrap">` +
`<span class="text-gray-400">${label} </span>` +
`<span class="${latencyClass(value, "online")}">${value}ms</span>` +
`${sep}</span>`
);
})
.join(" ");
}
function updateHostRow(host, index) { function updateHostRow(host, index) {
const latencyEl = document.querySelector( const latencyEl = document.querySelector(
`.latency-value[data-host="${index}"]`, `.latency-value[data-host="${index}"]`,
@@ -712,28 +855,22 @@ function updateHostRow(host, index) {
const min = host.minLatency(); const min = host.minLatency();
const max = host.maxLatency(); const max = host.maxLatency();
if (host.status === "online" && avg !== null) { if (host.status === "online" && avg !== null) {
statusEl.innerHTML = statusEl.innerHTML = statusStatsHTML([
`<span class="text-gray-400">min </span><span class="${latencyClass(min, "online")}">${min}ms</span>` + ["min", min],
` <span class="text-gray-500">/</span> ` + ["med", med],
`<span class="text-gray-400">med </span><span class="${latencyClass(med, "online")}">${med}ms</span>` + ["avg", avg],
` <span class="text-gray-500">/</span> ` + ["max", max],
`<span class="text-gray-400">avg </span><span class="${latencyClass(avg, "online")}">${avg}ms</span>` + ]);
` <span class="text-gray-500">/</span> ` + statusEl.className = STATUS_TEXT_CLASS;
`<span class="text-gray-400">max </span><span class="${latencyClass(max, "online")}">${max}ms</span>`;
statusEl.className =
"status-text text-xs whitespace-nowrap text-right col-span-2 mt-5";
} else if (host.status === "offline") { } else if (host.status === "offline") {
statusEl.textContent = "unreachable"; statusEl.textContent = "unreachable";
statusEl.className = statusEl.className = `${STATUS_TEXT_CLASS} text-red-400`;
"status-text text-xs text-red-400 whitespace-nowrap text-right col-span-2 mt-5";
} else if (host.status === "error") { } else if (host.status === "error") {
statusEl.textContent = "timeout"; statusEl.textContent = "timeout";
statusEl.className = statusEl.className = `${STATUS_TEXT_CLASS} text-orange-400`;
"status-text text-xs text-orange-400 whitespace-nowrap text-right col-span-2 mt-5";
} else { } else {
statusEl.textContent = "connecting..."; statusEl.textContent = "connecting...";
statusEl.className = statusEl.className = `${STATUS_TEXT_CLASS} text-gray-500`;
"status-text text-xs text-gray-500 whitespace-nowrap text-right col-span-2 mt-5";
} }
SparklineRenderer.draw(canvas, host.history); SparklineRenderer.draw(canvas, host.history);
@@ -1044,8 +1181,7 @@ function greyOutUI(state) {
} }
if (statusEl) { if (statusEl) {
statusEl.textContent = "paused"; statusEl.textContent = "paused";
statusEl.className = statusEl.className = `${STATUS_TEXT_CLASS} text-gray-500`;
"status-text text-xs text-gray-500 whitespace-nowrap text-right col-span-2 mt-5";
} }
// Grey out the status dot // Grey out the status dot
const row = document.querySelector(`.host-row[data-index="${i}"]`); const row = document.querySelector(`.host-row[data-index="${i}"]`);
@@ -1143,6 +1279,9 @@ async function init() {
buildUI(state); buildUI(state);
log.info("UI built, starting tick loop"); log.info("UI built, starting tick loop");
const reporter = new Reporter(state, getClientId(), CONFIG.reportInterval);
reporter.start();
document document
.getElementById("pause-btn") .getElementById("pause-btn")
.addEventListener("click", () => togglePause(state)); .addEventListener("click", () => togglePause(state));
+34 -1
View File
@@ -14,6 +14,38 @@ body {
ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
} }
/* ---- Minimum tap targets ----------------------------------------------
Every interactive control is at least 44x44 CSS px (Apple HIG, WCAG 2.2
SC 2.5.5). Not scoped to a breakpoint or to `pointer: coarse`: a large
phone in landscape is above the 768px breakpoint and still a touch
device. */
/* The button grows to 44x44 while the negative margins keep its layout
footprint at the 16x16 of the icon inside it, so row height and the
icon's position are unchanged. */
.pin-btn {
display: flex;
align-items: center;
justify-content: center;
width: 2.75rem;
height: 2.75rem;
margin: -0.875rem;
}
/* A select paints its own background and border, so it has to actually be
44 tall rather than borrow the trick above. */
#interval-select {
min-height: 2.75rem;
}
/* The tappable target for #debug-toggle is the label wrapping it. */
.debug-toggle-label {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 2.75rem;
}
.sparkline-container { .sparkline-container {
background: linear-gradient( background: linear-gradient(
to bottom, to bottom,
@@ -38,9 +70,10 @@ body {
gap: 0.75rem; gap: 0.75rem;
} }
/* Pause button: smaller on mobile */ /* Pause button: smaller on mobile, but not below the tap-target floor */
#pause-btn { #pause-btn {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
min-height: 2.75rem;
} }
#pause-btn svg { #pause-btn svg {
+7
View File
@@ -7,6 +7,13 @@ const commitFull = execSync("git rev-parse HEAD").toString().trim();
export default defineConfig({ export default defineConfig({
plugins: [tailwindcss()], plugins: [tailwindcss()],
server: {
// Proxy /api to a locally running netwatch-server so `yarn dev`
// exercises the real report-posting path.
proxy: {
"/api": "http://127.0.0.1:8080",
},
},
define: { define: {
__COMMIT_HASH__: JSON.stringify(commitHash), __COMMIT_HASH__: JSON.stringify(commitHash),
__COMMIT_FULL__: JSON.stringify(commitFull), __COMMIT_FULL__: JSON.stringify(commitFull),