Compare commits
5 Commits
4d70317d6d
...
6ec7de534b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ec7de534b | ||
| f7c7f92e27 | |||
| 852a11eec2 | |||
| 25a852d35c | |||
| a644efe9ff |
27
.gitignore
vendored
27
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
@@ -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.12.2 (2026-08-09)
|
WORKDIR /src
|
||||||
RUN CGO_ENABLED=0 go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@c0d3ddc9cf3faa61a4e378e879ece580256d76e5
|
|
||||||
|
# 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"]
|
||||||
|
|||||||
43
TODO.md
43
TODO.md
@@ -17,19 +17,37 @@ remaining repo-compliance issues on the tracker.
|
|||||||
|
|
||||||
# Next Step
|
# Next Step
|
||||||
|
|
||||||
Compliance top-up as one small commit: add an `.editorconfig` at the repo root.
|
Confirm the `.gitea/workflows/check.yml` run is green (main always green
|
||||||
`backend/.editorconfig` exists but the root has none. (The `hooks` target this
|
policy). The workflow file is already on `main`; what is unverified is that its
|
||||||
step used to also name is already present in both the root `Makefile` and
|
latest run passes.
|
||||||
`backend/Makefile`.)
|
|
||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
- 2026-08-09: adopted the org-standard `backend/.golangci.yml` verbatim and
|
- 2026-08-10: adopted the org-standard `backend/.golangci.yml` verbatim and
|
||||||
bumped the pinned golangci-lint to v2.12.2; the previous config declared
|
moved the pinned golangci-lint from v2.7.2 to v2.12.2 (the `lint` stage of
|
||||||
`version: "2"` but used v1 schema keys, so every threshold in it was inert and
|
`Dockerfile.backend` now pins the `golangci/golangci-lint:v2.12.2` image by
|
||||||
its green result was meaningless. `backend/Makefile`'s `lint` target now
|
digest); the previous config declared `version: "2"` but used v1 schema keys,
|
||||||
asserts the config's sha256 against the canonical file first, so drift from
|
so every threshold in it was inert and its green result was meaningless.
|
||||||
the org standard fails the build instead of silently degrading to defaults
|
`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
|
||||||
|
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
|
||||||
@@ -47,12 +65,11 @@ step used to also name is already present in both the root `Makefile` and
|
|||||||
|
|
||||||
# Future Steps
|
# Future Steps
|
||||||
|
|
||||||
- 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`
|
- Upstream fix needed in `sneak/prompts`: the org-standard `.golangci.yml`
|
||||||
enables `gomodguard`, which golangci-lint v2.12.2 reports as deprecated since
|
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
|
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
|
deprecation warning. The file is standardized and must never be edited in this
|
||||||
repo, so nothing can be done here beyond tracking it
|
repo, so nothing can be done here beyond tracking it — tracked at
|
||||||
|
<https://git.eeqj.de/sneak/netwatch/issues/41>
|
||||||
|
|||||||
@@ -1,18 +1,17 @@
|
|||||||
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)
|
||||||
SHA256SUM := shasum -a 256
|
|
||||||
else
|
|
||||||
GOFLAGS = -ldflags "-linkmode external -extldflags -static $(GOLDFLAGS)"
|
|
||||||
SHA256SUM := sha256sum
|
|
||||||
endif
|
|
||||||
|
|
||||||
# .golangci.yml is standardized org-wide and must never be edited here
|
# .golangci.yml is standardized org-wide and must never be edited here
|
||||||
# (REPO_POLICIES.md). Its last silent drift replaced the v2 schema with
|
# (REPO_POLICIES.md). Its last silent drift replaced the v2 schema with
|
||||||
@@ -26,10 +25,9 @@ GOLANGCI_CONFIG_SHA256 := 021cc83f4e6fc7c31b95b34b846723dfcf20b66b7baeea1dc40406
|
|||||||
|
|
||||||
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 ./...
|
||||||
|
|||||||
59
src/main.js
59
src/main.js
@@ -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>
|
||||||
@@ -671,7 +677,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 +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}"]`);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user