4 Commits

Author SHA1 Message Date
clawbot
6ec7de534b lint: adopt org-standard .golangci.yml and golangci-lint v2.12.2 (closes #14)
All checks were successful
check / check (push) Successful in 46s
backend/.golangci.yml declared version: "2" on line 1 but used the
golangci-lint v1 schema below it: a top-level linters-settings key and
an issues.exclude-use-default key that does not exist in v2. Under v2
that config does not validate, so every threshold in it was inert --
lll fell back to its 120-column default rather than the intended 88,
and funlen, cyclop and dupl were not applied at all. The `0 issues.`
result the repo has been relying on was therefore meaningless.

Replace it with the org-standard file verbatim (sha256
021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb) and
move the lint stage of Dockerfile.backend from golangci-lint v2.7.2 to
the org-standard v2.12.2 by repinning the golangci/golangci-lint image
digest:

  sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240

The digest was resolved from the v2.12.2 tag and verified by running
the linter out of that exact image, which reports "golangci-lint has
version 2.12.2 built with go1.26.2 from c0d3ddc9 on
2026-05-06T11:07:58Z".

Guard against the config drifting from the standard again by asserting
its sha256 as the first step of the backend lint target. The check is a
local hash comparison against a constant in the Makefile: it needs no
network, fetches nothing, and adds no unpinned external reference to
the build path. It also catches a strictly larger class of breakage
than schema validation would, since a schema-valid but non-canonical
config is exactly how this file got into its broken state.

With the config actually loading, lll reports server.go:65 at 93
columns. Fix it, plus the two other over-long lines called out on the
issue (server.go:97 at 81 and reportbuf.go:166 at 88) which are inside
the 88-column lint limit but over the 77-column hard wrap in the Go
styleguide. All three were long //nolint justifications on the code
line; move the justification into a preceding comment block and leave
a short directive behind. No suppression is added or widened, and
.golangci.yml is not touched after the copy.

Drop the //nolint:wsl in server.go entirely rather than relocating it.
The standard config disables wsl, so the directive suppressed nothing;
removing it still yields `0 issues.`

Verified: `docker build --no-cache -f Dockerfile.backend .` is green,
with the lint stage running uncached for 14.9s and reporting
`0 issues.` -- the first proof that v2.12.2 and the canonical config
pass together through the multistage lint stage. Root `make check` and
`cd backend && make check` both pass. The drift guard's failure path
was re-proved inside the lint image: appending a byte to
.golangci.yml fails `make lint` at 0.17s with the expected and actual
hashes, before golangci-lint runs at all.

golangci-lint v2.12.2 emits a deprecation warning for the gomodguard
linter the standard config enables. It is a warning, not a failure,
and the config is not editable here; it is tracked separately.
2026-08-10 14:13:52 +00:00
f7c7f92e27 fix(frontend): meet the 44x44 minimum tap target on every control (closes #43)
All checks were successful
check / check (push) Successful in 10s
2026-08-10 16:12:00 +02:00
852a11eec2 fix: wrap per-host status line so 320px viewport does not scroll (closes #42)
Some checks failed
check / check (push) Has been cancelled
2026-08-10 16:07:28 +02: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
7 changed files with 148 additions and 64 deletions

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.12.2 (2026-08-10)
FROM golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 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"]

39
TODO.md
View File

@@ -10,22 +10,37 @@
# Status # Status
pre-1.0. No git tags. Backend work in flight on feat/reportbuf-storage (dirty: pre-1.0. No git tags. `feat/reportbuf-storage` is merged; the backend, the CI
src/main.js). Frontend is functional; backend is new and unmerged. workflow, and the backend repo standard files are all on `main`. Frontend and
backend are both functional. Working toward the 1.0.0 milestone by closing the
remaining repo-compliance issues on the tracker.
# Next Step # Next Step
Land feat/reportbuf-storage: finish the in-progress src/main.js change, get make Confirm the `.gitea/workflows/check.yml` run is green (main always green
check green, and merge the branch to main. The branch adds the backend (buffered policy). The workflow file is already on `main`; what is unverified is that its
zstd-compressed report storage), the CI workflow, and backend repo standard latest run passes.
files, so merging it also closes most compliance gaps.
# Completed Steps # Completed Steps
- 2026-08-10: adopted the org-standard `backend/.golangci.yml` verbatim and
moved the pinned golangci-lint from v2.7.2 to v2.12.2 (the `lint` stage of
`Dockerfile.backend` now pins the `golangci/golangci-lint:v2.12.2` image by
digest); the previous config declared `version: "2"` but used v1 schema keys,
so every threshold in it was inert and its green result was meaningless.
`backend/Makefile`'s `lint` target now asserts the config's sha256 against the
canonical file first, so drift from the org standard fails the build instead
of silently degrading to defaults
- 2026-08-10: every interactive control now meets the 44x44 CSS px minimum tap - 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 target (`.pin-btn`, `#interval-select`, the debug-log label and, on narrow
viewports, `#pause-btn`). The pin button's hit area grows via matching viewports, `#pause-btn`). The pin button's hit area grows via matching
negative margins, so its layout footprint and row density are unchanged 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 - 2026-08-09: dotfile compliance — lifted `backend/.editorconfig` to the repo
root so `root = true` covers the frontend too, and replaced `.gitignore` with root so `root = true` covers the frontend too, and replaced `.gitignore` with
the org model (OS, editor, node, and environment/secrets sections) plus this the org model (OS, editor, node, and environment/secrets sections) plus this
@@ -37,7 +52,7 @@ files, so merging it also closes most compliance gaps.
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
and backend repo standard files; backend Dockerfile fixed (Go 1.25, and backend repo standard files; backend Dockerfile fixed (Go 1.25,
golangci-lint) and moved to repo root (feat/reportbuf-storage, unmerged) golangci-lint) and moved to repo root (feat/reportbuf-storage)
- 2026-02-26: host row layout redesigned with CSS grid; overflow and spacing - 2026-02-26: host row layout redesigned with CSS grid; overflow and spacing
fixes; nginx config extracted; port hardcoded to 8080 fixes; nginx config extracted; port hardcoded to 8080
- 2026-02-26: debug log panel, median stats, recovery probe, Docker build fix, - 2026-02-26: debug log panel, median stats, recovery probe, Docker build fix,
@@ -50,9 +65,11 @@ files, so merging it also closes most compliance gaps.
# Future Steps # Future Steps
- Compliance top-up as one small commit: add .editorconfig and add the hooks
target to the Makefile
- After merge, confirm .gitea/workflows/check.yml is on main and CI is green
(main always green policy)
- Decide what to do with untracked resume.sh: commit it, gitignore it, or delete - Decide what to do with untracked resume.sh: commit it, gitignore it, or delete
it it
- Upstream fix needed in `sneak/prompts`: the org-standard `.golangci.yml`
enables `gomodguard`, which golangci-lint v2.12.2 reports as deprecated since
v2.12.0 and replaced by `gomodguard_v2`, so every backend lint run prints a
deprecation warning. The file is standardized and must never be edited in this
repo, so nothing can be done here beyond tracking it — tracked at
<https://git.eeqj.de/sneak/netwatch/issues/41>

View File

@@ -1,5 +1,9 @@
version: "2" version: "2"
# Config schema uses the golangci-lint v2 layout (settings live under
# linters.settings, not top-level linters-settings) so that the
# thresholds below are actually applied by golangci-lint >= v2.
run: run:
timeout: 5m timeout: 5m
modules-download-mode: readonly modules-download-mode: readonly
@@ -14,8 +18,7 @@ linters:
- wsl # Deprecated, replaced by wsl_v5 - wsl # Deprecated, replaced by wsl_v5
- wrapcheck # Too verbose for internal packages - wrapcheck # Too verbose for internal packages
- varnamelen # Short names like db, id are idiomatic Go - varnamelen # Short names like db, id are idiomatic Go
settings:
linters-settings:
lll: lll:
line-length: 88 line-length: 88
funlen: funlen:
@@ -27,6 +30,5 @@ linters-settings:
threshold: 100 threshold: 100
issues: issues:
exclude-use-default: false
max-issues-per-linter: 0 max-issues-per-linter: 0
max-same-issues: 0 max-same-issues: 0

View File

@@ -1,30 +1,46 @@
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) # macOS ships shasum rather than sha256sum.
GOFLAGS := -ldflags "$(GOLDFLAGS)" SHA256SUM := $(shell command -v sha256sum >/dev/null 2>&1 && echo sha256sum || echo shasum -a 256)
else
GOFLAGS = -ldflags "-linkmode external -extldflags -static $(GOLDFLAGS)" # .golangci.yml is standardized org-wide and must never be edited here
endif # (REPO_POLICIES.md). Its last silent drift replaced the v2 schema with
# v1 keys, which left every threshold in the file inert while the build
# stayed green. The lint target therefore asserts the file still matches
# the canonical copy byte for byte. The check is a local hash comparison:
# no network, no remote schema, nothing unpinned in the build path.
GOLANGCI_CONFIG_SHA256 := 021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406e643346bcb
.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 ./...
lint: lint:
@actual=$$($(SHA256SUM) .golangci.yml | cut -d' ' -f1); \
if [ "$$actual" != "$(GOLANGCI_CONFIG_SHA256)" ]; then \
echo ".golangci.yml has drifted from the org standard."; \
echo " expected $(GOLANGCI_CONFIG_SHA256)"; \
echo " actual $$actual"; \
echo "Restore it verbatim from sneak/prompts; do not edit it."; \
exit 1; \
fi
golangci-lint run ./... golangci-lint run ./...
fmt: fmt:

View File

@@ -163,7 +163,9 @@ func (b *Buffer) writeFile(data []byte) {
name := fmt.Sprintf("reports-%s.jsonl.zst", ts) name := fmt.Sprintf("reports-%s.jsonl.zst", ts)
path := filepath.Join(b.dataDir, name) path := filepath.Join(b.dataDir, name)
f, err := os.OpenFile( //nolint:gosec // path built from controlled dataDir + timestamp // path is built from the operator-supplied dataDir plus a
// generated timestamp, so it carries no external input.
f, err := os.OpenFile( //nolint:gosec // see comment above
path, path,
os.O_WRONLY|os.O_CREATE|os.O_EXCL, os.O_WRONLY|os.O_CREATE|os.O_EXCL,
filePerms, filePerms,

View File

@@ -62,7 +62,10 @@ func New(
OnStart: func(_ context.Context) error { OnStart: func(_ context.Context) error {
s.startupTime = time.Now().UTC() s.startupTime = time.Now().UTC()
go func() { //nolint:contextcheck // fx OnStart ctx is startup-only; run() creates its own // The fx OnStart context is scoped to startup and is
// cancelled once the hook returns; run() derives its
// own context instead of inheriting this one.
go func() { //nolint:contextcheck // see comment above
s.run() s.run()
}() }()
@@ -94,7 +97,7 @@ func (s *Server) run() {
} }
func (s *Server) serve() int { func (s *Server) serve() int {
var ctx context.Context //nolint:wsl // ctx must be declared before multi-assign var ctx context.Context
ctx, s.cancelFunc = context.WithCancel( ctx, s.cancelFunc = context.WithCancel(
context.Background(), context.Background(),

View File

@@ -537,6 +537,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 +568,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>
@@ -688,6 +694,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 +738,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 +1064,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}"]`);