Compare commits
4 Commits
d24256faf8
...
feat/golan
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ec7de534b | ||
| f7c7f92e27 | |||
| 852a11eec2 | |||
| 25a852d35c |
@@ -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)
|
||||
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"]
|
||||
|
||||
43
TODO.md
43
TODO.md
@@ -10,18 +10,37 @@
|
||||
|
||||
# Status
|
||||
|
||||
pre-1.0. No git tags. Backend work in flight on feat/reportbuf-storage (dirty:
|
||||
src/main.js). Frontend is functional; backend is new and unmerged.
|
||||
pre-1.0. No git tags. `feat/reportbuf-storage` is merged; the backend, the CI
|
||||
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
|
||||
|
||||
Land feat/reportbuf-storage: finish the in-progress src/main.js change, get make
|
||||
check green, and merge the branch to main. The branch adds the backend (buffered
|
||||
zstd-compressed report storage), the CI workflow, and backend repo standard
|
||||
files, so merging it also closes most compliance gaps.
|
||||
Confirm the `.gitea/workflows/check.yml` run is green (main always green
|
||||
policy). The workflow file is already on `main`; what is unverified is that its
|
||||
latest run passes.
|
||||
|
||||
# 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
|
||||
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
|
||||
@@ -33,7 +52,7 @@ files, so merging it also closes most compliance gaps.
|
||||
shims, README Entrypoints section
|
||||
- 2026-02-27: backend with buffered zstd-compressed report storage; CI workflow
|
||||
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
|
||||
fixes; nginx config extracted; port hardcoded to 8080
|
||||
- 2026-02-26: debug log panel, median stats, recovery probe, Docker build fix,
|
||||
@@ -46,9 +65,11 @@ files, so merging it also closes most compliance gaps.
|
||||
|
||||
# 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
|
||||
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>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
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:
|
||||
timeout: 5m
|
||||
modules-download-mode: readonly
|
||||
@@ -14,8 +18,7 @@ linters:
|
||||
- wsl # Deprecated, replaced by wsl_v5
|
||||
- wrapcheck # Too verbose for internal packages
|
||||
- varnamelen # Short names like db, id are idiomatic Go
|
||||
|
||||
linters-settings:
|
||||
settings:
|
||||
lll:
|
||||
line-length: 88
|
||||
funlen:
|
||||
@@ -27,6 +30,5 @@ linters-settings:
|
||||
threshold: 100
|
||||
|
||||
issues:
|
||||
exclude-use-default: false
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
|
||||
@@ -1,30 +1,46 @@
|
||||
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
|
||||
# macOS ships shasum rather than sha256sum.
|
||||
SHA256SUM := $(shell command -v sha256sum >/dev/null 2>&1 && echo sha256sum || echo shasum -a 256)
|
||||
|
||||
# .golangci.yml is standardized org-wide and must never be edited here
|
||||
# (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
|
||||
|
||||
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 ./...
|
||||
|
||||
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 ./...
|
||||
|
||||
fmt:
|
||||
|
||||
@@ -163,7 +163,9 @@ func (b *Buffer) writeFile(data []byte) {
|
||||
name := fmt.Sprintf("reports-%s.jsonl.zst", ts)
|
||||
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,
|
||||
os.O_WRONLY|os.O_CREATE|os.O_EXCL,
|
||||
filePerms,
|
||||
|
||||
@@ -62,7 +62,10 @@ func New(
|
||||
OnStart: func(_ context.Context) error {
|
||||
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()
|
||||
}()
|
||||
|
||||
@@ -94,7 +97,7 @@ func (s *Server) run() {
|
||||
}
|
||||
|
||||
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(
|
||||
context.Background(),
|
||||
|
||||
59
src/main.js
59
src/main.js
@@ -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>
|
||||
@@ -671,7 +677,7 @@ function buildUI(state) {
|
||||
</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">
|
||||
<label class="cursor-pointer">
|
||||
<label class="debug-toggle-label cursor-pointer">
|
||||
<input type="checkbox" id="debug-toggle" class="mr-1">
|
||||
<span>Debug log</span>
|
||||
</label>
|
||||
@@ -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}"]`);
|
||||
|
||||
@@ -14,6 +14,38 @@ body {
|
||||
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 {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
@@ -38,9 +70,10 @@ body {
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* Pause button: smaller on mobile */
|
||||
/* Pause button: smaller on mobile, but not below the tap-target floor */
|
||||
#pause-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
min-height: 2.75rem;
|
||||
}
|
||||
|
||||
#pause-btn svg {
|
||||
|
||||
Reference in New Issue
Block a user