4 Commits

Author SHA1 Message Date
clawbot
0060c024d5 fix: wrap per-host status line so 320px viewport does not scroll (closes #42)
All checks were successful
check / check (push) Successful in 11s
The status line carried `whitespace-nowrap`, and once populated its content
(`min 1ms / med 2ms / avg 3ms / max 4ms`) measured 325px inside the 270px host
column at a 320px viewport. The element does not clip, so the overflow
propagated to the document and the page scrolled horizontally.

Drop `whitespace-nowrap` from the status line and instead make each
label/value/separator group individually unbreakable, so wrapping happens only
between stats and a wrapped line never starts with a separator. The line fits
on one row in the 420px desktop column, so the wide layout is unchanged.

The repeated class string is now a single constant.
2026-08-10 14:06:03 +00:00
25a852d35c build: Dockerfile.backend multistage lint stage (closes #17)
Some checks failed
check / check (push) Has been cancelled
2026-08-10 16:04:48 +02:00
a644efe9ff chore: root .editorconfig and hardened .gitignore (closes #15)
All checks were successful
check / check (push) Successful in 45s
2026-08-10 15:47:49 +02:00
fbfe1df349 frontend: gate the Docker build on make check (closes #11) (#12)
Some checks failed
check / check (push) Has been cancelled
Resolves #11. The frontend/root `Dockerfile` ran only `RUN yarn build`, so `script/lint` and `script/fmt-check` (prettier) never gated CI — only a broken build failed it. (`script/cibuild`'s comment even claimed "the Dockerfile runs make check", which was false.) The backend `Dockerfile.backend` already runs `make check`; nothing covered the frontend's lint/fmt-check.

Change (single file, `Dockerfile`):
- `apk add ... git` -> `apk add ... git make` (build stage needs `make`).
- `RUN yarn build` -> `RUN make check` — which runs `script/test` (`yarn build`, producing `dist/`) then `script/lint` + `script/fmt-check`. `dist/` is still produced in one build (no redundant rebuild); the final nginx runtime image is unchanged.

Verified via a fresh clone (a worktree's `.git` pointer breaks `vite`'s `git rev-parse`, so builds must come from a real checkout — as CI's `actions/checkout` provides): positive `docker build` succeeds with in-image `make check` green; a negative test (a prettier-violating but build-valid file) makes the build fail at `make check`, confirming CI now goes red on a check regression, not just a broken build.

Left open for review (not merged).

Co-authored-by: sneak <sneak@sneak.berlin>
Reviewed-on: #12
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-08-07 17:46:37 +02:00
8 changed files with 123 additions and 83 deletions

27
.gitignore vendored
View File

@@ -1,4 +1,27 @@
node_modules/
dist/
# OS
.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

View File

@@ -3,9 +3,12 @@ FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e3
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
RUN apk add --no-cache git
RUN apk add --no-cache git make
COPY . .
RUN yarn build
# make check runs script/check (test + lint + fmt-check); its test step
# is the production yarn build, so this both produces dist/ and gates the
# image on lint/fmt-check/test regressions, not merely a broken build.
RUN make check
# nginx:stable-alpine as of 2026-02-22
FROM nginx@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab

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)
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)
RUN CGO_ENABLED=0 go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@9f61b0f53f80672872fced07b6874397c3ed197b
WORKDIR /src
# 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 ./
RUN go mod download
COPY .git /repo/.git
COPY backend/ .
RUN make check
RUN make build
RUN make test
# 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)
FROM alpine:3.23@sha256:25109184c71bdad752c8312a8623239686a9a2071e8825f20acb8f2198c3f659
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
ENTRYPOINT ["netwatch-server"]

View File

@@ -125,9 +125,6 @@ dist/
false outage)
- Clickable service URLs
- Canvas-based sparkline rendering with devicePixelRatio scaling
- Mobile detection: viewports narrower than 768px show a friendly "not yet
available on mobile" message instead of the monitoring UI (no polling or
network requests on mobile)
- Zero runtime dependencies: all resources bundled into build artifacts
## Deployment
@@ -151,8 +148,6 @@ properties.
## Limitations
- **Mobile**: Viewports below 768px wide show a static "not yet available"
message. The full monitoring UI requires a desktop-width browser.
- **CORS**: Some hosts may block cross-origin HEAD requests. The app uses
`no-cors` mode which allows the request but provides opaque responses. Latency
is still measurable based on request timing.

13
TODO.md
View File

@@ -22,6 +22,19 @@ files, so merging it also closes most compliance gaps.
# Completed Steps
- 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
shims, README Entrypoints section
- 2026-02-27: backend with buffered zstd-compressed report storage; CI workflow

View File

@@ -1,25 +1,22 @@
UNAME_S := $(shell uname -s)
VERSION := $(shell git describe --always --dirty)
# VERSION is overridable (the Dockerfile passes its ARG VERSION in) and
# 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)
BINARY := netwatch-server
GOLDFLAGS += -s -w
GOLDFLAGS += -X main.Version=$(VERSION)
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
all: build
build: ./$(BINARY)
./$(BINARY): $(shell find . -name '*.go' -type f) go.mod go.sum
go build -o $@ $(GOFLAGS) ./cmd/netwatch-server/
build:
CGO_ENABLED=0 go build -trimpath -ldflags "$(GOLDFLAGS)" \
-o ./$(BINARY) ./cmd/netwatch-server/
test:
timeout 30 go test ./...

View File

@@ -537,6 +537,12 @@ class SparklineRenderer {
// --- 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) {
const pinColor = host.pinned
? "text-blue-500"
@@ -562,7 +568,7 @@ function hostRowHTML(host, index, showPin = true) {
<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>
<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 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>
@@ -688,6 +694,26 @@ function buildUI(state) {
// --- 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) {
const latencyEl = document.querySelector(
`.latency-value[data-host="${index}"]`,
@@ -712,28 +738,22 @@ function updateHostRow(host, index) {
const min = host.minLatency();
const max = host.maxLatency();
if (host.status === "online" && avg !== null) {
statusEl.innerHTML =
`<span class="text-gray-400">min </span><span class="${latencyClass(min, "online")}">${min}ms</span>` +
` <span class="text-gray-500">/</span> ` +
`<span class="text-gray-400">med </span><span class="${latencyClass(med, "online")}">${med}ms</span>` +
` <span class="text-gray-500">/</span> ` +
`<span class="text-gray-400">avg </span><span class="${latencyClass(avg, "online")}">${avg}ms</span>` +
` <span class="text-gray-500">/</span> ` +
`<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";
statusEl.innerHTML = statusStatsHTML([
["min", min],
["med", med],
["avg", avg],
["max", max],
]);
statusEl.className = STATUS_TEXT_CLASS;
} else if (host.status === "offline") {
statusEl.textContent = "unreachable";
statusEl.className =
"status-text text-xs text-red-400 whitespace-nowrap text-right col-span-2 mt-5";
statusEl.className = `${STATUS_TEXT_CLASS} text-red-400`;
} else if (host.status === "error") {
statusEl.textContent = "timeout";
statusEl.className =
"status-text text-xs text-orange-400 whitespace-nowrap text-right col-span-2 mt-5";
statusEl.className = `${STATUS_TEXT_CLASS} text-orange-400`;
} else {
statusEl.textContent = "connecting...";
statusEl.className =
"status-text text-xs text-gray-500 whitespace-nowrap text-right col-span-2 mt-5";
statusEl.className = `${STATUS_TEXT_CLASS} text-gray-500`;
}
SparklineRenderer.draw(canvas, host.history);
@@ -1044,8 +1064,7 @@ function greyOutUI(state) {
}
if (statusEl) {
statusEl.textContent = "paused";
statusEl.className =
"status-text text-xs text-gray-500 whitespace-nowrap text-right col-span-2 mt-5";
statusEl.className = `${STATUS_TEXT_CLASS} text-gray-500`;
}
// Grey out the status dot
const row = document.querySelector(`.host-row[data-index="${i}"]`);
@@ -1126,46 +1145,11 @@ function handleResize(state) {
});
}
// --- Mobile Detection --------------------------------------------------------
const MOBILE_BREAKPOINT = 768;
function isMobileViewport() {
return window.innerWidth < MOBILE_BREAKPOINT;
}
function buildMobileUI() {
const app = document.getElementById("app");
app.innerHTML = `
<div class="mx-auto px-[5%] py-8">
<header class="mb-8">
<h1 class="text-3xl font-bold text-white"><a href="https://git.eeqj.de/sneak/netwatch" target="_blank" rel="noopener" class="underline decoration-dashed decoration-gray-500 underline-offset-4">NetWatch</a> by <a href="https://sneak.berlin" target="_blank" rel="noopener" class="text-blue-400 underline hover:text-blue-300">@sneak</a></h1>
<p class="text-gray-400 text-sm mt-2">Real-time network latency monitor</p>
</header>
<div class="flex items-center justify-center min-h-[60vh]">
<div class="bg-gray-800/70 border border-gray-700/50 rounded-lg p-8 max-w-md text-center">
<p class="text-4xl mb-4">📡</p>
<p class="text-xl font-semibold text-white mb-2">Not yet available on mobile</p>
<p class="text-gray-400 text-sm">NetWatch requires a wider viewport to display latency charts and monitoring data. Please visit on a desktop browser.</p>
</div>
</div>
<footer class="mt-8 text-center text-gray-600 text-xs">
<p><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>
</footer>
</div>`;
}
// --- Bootstrap ---------------------------------------------------------------
async function init() {
log.info("NetWatch starting");
if (isMobileViewport()) {
log.info("Mobile viewport detected — skipping monitoring");
buildMobileUI();
return;
}
// Probe common gateway IPs to find the local router
const gateway = await detectGateway();
const localHosts = [LOCAL_CPE];