45 Commits

Author SHA1 Message Date
clawbot
c36dc36819 test: automated responsive-layout harness (closes #13)
All checks were successful
check / check (push) Successful in 22s
Verifies the mobile layout from #5 with a real browser engine instead of
by hand on a phone. make frontend-viewport-test builds dist/, serves it
from the same digest-pinned nginx image and the same nginx.conf the
shipping container uses, and drives a digest-pinned headless Chrome
against it over CDP.

Viewport widths are derived from the app's own CSS rather than from a
list of phone models: the @media conditions in src/styles.css and any
Tailwind responsive prefixes in the markup are parsed, and each
breakpoint is tested one pixel below, exactly on, and one pixel above.
max-width: 768px matches at 768, and a generic 375px test sails past
that boundary entirely. Four anchor viewports are added with stated
reasons: a 320px floor, a desktop baseline, and two phone-landscape
sizes straddling the breakpoint.

Assertions are on computed layout, not screenshots: horizontal overflow,
elements past the viewport edge, clipped text (deliberate ellipsis
truncation excluded), 44x44 minimum tap targets, and genuine reflow of
the host rows checked on both flex-direction and geometry. Probing and
gateway detection are asserted to still run at narrow widths, since the
early-return mobile path rejected in #8 is what would silently regress.
Screenshots are written to tmp/viewport/ as artifacts alongside the
results, not as the evidence.

puppeteer-core rather than playwright: it is the one variant of either
that never downloads or bundles a browser, so the browser stays a
digest-pinned image and the npm side is pinned by yarn.lock integrity.

The browser container runs on an --internal docker network with no route
off the host; the harness answers the app's latency probes itself from a
fixed delay table so the rows render a realistic spread of value widths.

Kept out of make check: it needs Docker and takes minutes, where make
test has to stay under 20 seconds.

Every check guards its own presence, so none can pass against a page it
is not measuring. The tap-target check in particular would otherwise be
inert: an empty undersized set means both "all controls are big enough"
and "the selectors have gone stale", and the size comparison alone
cannot tell those apart. Each selector therefore declares the minimum
number of visible instances the page must contain, per selector rather
than in total, so one stale selector out of four fails rather than only
all four at once.

Layout expectation is likewise refused rather than guessed. A width is
narrow when a max-width block matches (desktop-first, what the app does
today) or, for a min-width-only mobile-first set, when it falls below
every breakpoint; a set mixing both cannot be resolved from the
conditions alone, because which block owns the reflow is a property of
the rules inside it, so the run fails with an explanation instead of
testing the right widths against the wrong expectation.

The harness was observed failing before being trusted, four times: a
planted 900px fixed-width element in a host row; the mobile reflow rule
neutered; the tap-target threshold lowered so nothing was undersized and
.pin-btn then renamed, which took the check from 8/8 green at every
touch viewport to failing at all six, naming the stale selector; and a
second media block added so the breakpoint set mixed max and min, which
aborted the run. All reverted.

Against the current layout it reports two real defects, filed as #42
(horizontal overflow at 320px) and #43 (tap targets below 44x44).
2026-08-09 15:22:54 +00:00
fbfe1df349 frontend: gate the Docker build on make check (closes #11) (#12)
All checks were successful
check / check (push) Successful in 26s
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
e45bc578b2 scripts-to-rule-them-all (#10)
All checks were successful
check / check (push) Successful in 21s
Reviewed-on: #10
Co-authored-by: sneak <sneak@sneak.berlin>
Co-committed-by: sneak <sneak@sneak.berlin>
2026-07-07 02:14:16 +02:00
247a3c33fd TODO (#9)
All checks were successful
check / check (push) Successful in 22s
Reviewed-on: #9
2026-07-06 21:20:37 +02:00
1fb3ff2954 feat: responsive mobile layout for host rows (closes #2) (#5)
All checks were successful
check / check (push) Successful in 1m10s
Redesigns host rows for portrait/mobile viewports (<=768px):
- Host info panel stacks on top, full width
- Sparkline renders full width below
- Each host row becomes taller to accommodate vertical layout
- Summary line wraps gracefully
- Header controls stack below title

Desktop layout is unchanged — all changes are inside a `@media (max-width: 768px)` query and CSS class hooks added to the HTML.

Closes #2

Co-authored-by: user <user@Mac.lan guest wan>
Reviewed-on: #5
Co-authored-by: clawbot <clawbot@noreply.example.org>
Co-committed-by: clawbot <clawbot@noreply.example.org>
2026-03-10 19:56:40 +01:00
36202e1a3a Merge pull request 'fix: show 'not available on mobile' message instead of broken layout' (#3) from fix/mobile-not-available into main
All checks were successful
check / check (push) Successful in 32s
Reviewed-on: #3
2026-02-27 11:07:11 +01:00
user
38bbd13c7f fix: show 'not available on mobile' message instead of broken layout
All checks were successful
check / check (push) Successful in 28s
Detect mobile devices via user agent and viewport width (<=768px).
On mobile, skip all checker initialization and render only the
header, description, and a styled 'Not yet available on mobile' box.

Desktop behavior is completely unchanged — the mobile check returns
early before any existing code runs.
2026-02-27 02:00:01 -08:00
add5f1f4f3 Merge pull request 'add backend in advance of sending report data' (#1) from feat/reportbuf-storage into main
All checks were successful
check / check (push) Successful in 27s
Reviewed-on: #1
2026-02-27 07:23:00 +01:00
8ca57746df Move backend Dockerfile to repo root for git access
All checks were successful
check / check (push) Successful in 24s
Place the backend Dockerfile at repo root as Dockerfile.backend so
the build context includes .git, giving git describe access for
version stamping. Fix .gitignore pattern to anchor /netwatch-server
so it does not exclude cmd/netwatch-server/. Remove .git from
.dockerignore. Update CI workflow and backend Makefile docker target.
2026-02-27 12:45:38 +07:00
c61c047cd8 Fix backend Dockerfile: use Go 1.25, install golangci-lint
Some checks failed
check / check (push) Failing after 52s
Update base image from golang:1.24-alpine to golang:1.25-alpine
to match go.mod requirement. Install golangci-lint by pinned commit
hash so make check passes inside the container. Update runtime
image to alpine:3.23.
2026-02-27 12:24:49 +07:00
4ad2573532 Add CI workflow and backend repo standard files
Some checks failed
check / check (push) Failing after 26s
Add .gitea/workflows/check.yml that builds both the root and
backend Docker images on push. Add LICENSE and README.md to the
backend subproject to match repo standards.
2026-02-27 12:18:35 +07:00
b57afeddbd Add backend with buffered zstd-compressed report storage
Introduce the Go backend (netwatch-server) with an HTTP API that
accepts telemetry reports and persists them as zstd-compressed JSONL
files. Reports are buffered in memory and flushed to disk when the
buffer reaches 10 MiB or every 60 seconds.
2026-02-27 12:14:34 +07:00
4ad98d578b Move latency figure and stats line down in host row 2026-02-26 19:14:16 +07:00
c875793314 Pull URL closer to hostname in host row 2026-02-26 19:12:44 +07:00
b357ccf978 Center-align grid row items in host row 2026-02-26 19:11:04 +07:00
cd16edd486 Add mt-3 to all stats className assignments 2026-02-26 19:10:33 +07:00
f791798044 Increase top margin on per-host stats line 2026-02-26 19:10:13 +07:00
2861de3c6e Add vertical spacing to latency figure and stats line in host row 2026-02-26 19:09:46 +07:00
d9556e3cfd Preserve col-span-2 on stats line when className is updated
All updateHostRow() and greyOutUI() className assignments were
dropping col-span-2, causing the stats line to only span column 1
instead of both grid columns.
2026-02-26 19:08:21 +07:00
dcdc2873a6 Fix host row: min-width on name, fixed grid width, no clipping
- Name cell gets min-w-[200px] so titles don't over-truncate
- Grid is flex-shrink-0 at 420px so it never squeezes
- Removed overflow-hidden that was clipping the stats line
2026-02-26 19:06:49 +07:00
f5327a11c1 Fix latency text overlapping sparkline in host row grid
Use minmax(0,1fr) for the name column so it can shrink below its
min-content width, and add overflow-hidden on the grid container
to clip any overflow at the boundary.
2026-02-26 19:05:32 +07:00
85058a336a Fix host row layout: use CSS grid to prevent stats overflow
Replace absolute positioning with a 2-column CSS grid so the stats
line (col-span-2, text-right) is contained within the 480px block
and cannot extend past the left edge of the row.
2026-02-26 19:03:45 +07:00
db983fb340 Increase left padding on per-host stats line 2026-02-26 18:30:21 +07:00
30895f4219 Hardcode port 8080, extract nginx config, fix host row overflow
- Nginx: extract config from Dockerfile heredoc to nginx.conf, hardcode
  port 8080, remove envsubst templating
- Host row: add bottom padding so stats line stays within the row well
2026-02-26 18:28:01 +07:00
05a2ee970c Redesign host row layout, fix Docker build, add S3 Singapore
- Host row: two-layer layout with name/URL on the left (normal flow)
  and latency/stats on the right (absolute positioned), preventing
  overlap and keeping sparklines aligned
- Docker: install git in build stage and include .git in context so
  vite can resolve commit hash for footer
- Add S3 ap-southeast-1 (Singapore) endpoint for AWS peering comparison
2026-02-26 18:25:03 +07:00
de98e74539 Add debug log panel, median stats, recovery probe, and UI improvements
- Debug log: togglable panel with timestamped, level-tagged, color-coded
  entries (error/warning/notice/info/debug) from throughout the app
- Median latency: added to per-host stats and summary (min/med/avg/max)
- Recovery probe: rapid 500ms polling of 4 random hosts when hard offline,
  triggers normal tick as soon as connectivity returns
- Health status: multi-level (healthy/slow/degraded/offline) with
  hard-offline detection for recovery probe activation
- First tick discarded to avoid DNS/TLS cold-start latency skew
- Added Google, S3 ap-southeast-1 (Singapore) to monitored hosts
- UI: reduced row padding, larger sparkline canvas, bigger axis labels,
  pin icon hidden (but space preserved) for local network hosts
- Commit hash shown in footer via vite define plugin
2026-02-26 17:23:30 +07:00
f4517ae953 Redesign summary box and add host pinning
Summary now shows current min/avg/max and history-window min/max.
Each host row has a pin icon that pins it to the top. Pinned hosts
sort alphabetically, unpinned sort by latency. datavi.be is pinned
by default.
2026-02-23 01:13:04 +07:00
a3bd3d06d1 Add local and UTC clocks below header, updated every second 2026-02-23 01:06:48 +07:00
77e40bf4ef Add Checks counter to summary line 2026-02-23 01:05:10 +07:00
d896c2d19b Sort WAN hosts by latency after first check and every 5 ticks
datavi.be stays pinned at top. Remaining hosts sort ascending by
last latency, with unreachable hosts at the bottom.
2026-02-23 01:04:15 +07:00
0bbf4d66a8 Switch from HEAD to GET for latency measurement
Hetzner speed test servers drop the connection on HEAD requests,
causing fetch to throw a network error. GET works universally and
with no-cors mode the response is opaque anyway.
2026-02-23 01:01:50 +07:00
60372f0708 Derive request timeout from update interval
requestTimeout is now updateInterval - 50ms, ensuring requests
complete before the next tick. maxLatency matches requestTimeout.
2026-02-23 00:59:29 +07:00
4aaf9c2a49 Replace GCS endpoints with Hetzner regional, change interval to 3s
GCS locational endpoints were too slow (>1500ms). Replace with 6
Hetzner speed test servers (Nuremberg DE, Falkenstein DE, Helsinki
FI, Ashburn VA-US, Hillsboro OR-US, Singapore SG) which are genuine
per-DC HTTPS endpoints. Bump update interval from 2s to 3s.
2026-02-23 00:55:06 +07:00
c31b976f01 Widen host name column from w-48 to w-72
Prevents truncation of longer endpoint names like the S3/GCS
regional identifiers.
2026-02-23 00:43:59 +07:00
869f123a5b Shorten storage endpoint display names and drop continent labels
Remove verbose continent labels from S3 names, shorten GCS region
codes (us-cent1, eu-west1, asia-se1, aus-se1) to fit the name column.
2026-02-23 00:42:15 +07:00
ca67f65242 Set page side margins to 10% for balanced layout 2026-02-23 00:39:22 +07:00
bc612daf22 Remove max-width cap so host wells fill the viewport
The max-w-7xl (1280px) constraint left too much dead space between
the host wells and the window edges. Remove it so the layout uses
all available width.
2026-02-23 00:38:20 +07:00
a3feacb842 Reduce page side margins by 50%
px-4 (1rem) to px-2 (0.5rem) to give more horizontal space for
long host names.
2026-02-23 00:36:58 +07:00
94169b8d65 Add S3, GCS, and B2 regional storage endpoints
Remove DigitalOcean. Add B2, 7 S3 endpoints across all continents
(Cape Town, London, Bahrain, Tokyo, Sydney, Oregon, São Paulo), and
4 GCS locational endpoints (Iowa, Belgium, Singapore, Sydney) for
cross-provider latency comparison.
2026-02-23 00:35:07 +07:00
14764a79ad Color-code per-host avg label and clamp graph Y-axis to 1000ms
The avg latency text below each host's big number is now color-coded
using the same thresholds as the main figure. The sparkline Y-axis
stays 0-1000ms — values between 1000-1500ms pin to the top of the
chart but still show their real value in the latency display.
2026-02-23 00:26:00 +07:00
55fb63bec1 Increase request timeout and max latency to 1500ms
For high-latency connections, 1000ms was too aggressive and caused
false unreachable readings. Bump to 1500ms and add a 1500 tick to
the Y-axis.
2026-02-23 00:23:43 +07:00
0e84b973ce Add make dev target for running the dev server 2026-02-23 00:17:36 +07:00
b23da0797e Add gateway auto-detection and rename to Local CPE
Local CPE (192.168.100.1) is always monitored. On startup, probe
192.168.1.1, 192.168.0.1, 192.168.8.1, and 10.0.0.1 in parallel
and add whichever responds first as "Local Gateway".
2026-02-23 00:05:48 +07:00
83bd23945c Add Anthropic and OpenAI API endpoints to monitoring targets
Reorder host list: datavi.be first, then LLM APIs, then cloud
providers (AWS, GCP, Azure), then CDN/hosting, then GitHub.
2026-02-23 00:01:50 +07:00
cb8d47d7aa Enable prettier prose wrapping for markdown
Add proseWrap: "always" to .prettierrc so markdown prose is
hard-wrapped at 80 columns.
2026-02-23 00:00:25 +07:00
59 changed files with 4457 additions and 245 deletions

View File

@@ -1,6 +1,6 @@
node_modules node_modules
dist dist
.git tmp
.DS_Store .DS_Store
*.log *.log
.claude .claude

View File

@@ -0,0 +1,10 @@
name: check
on: [push]
jobs:
check:
runs-on: ubuntu-latest
steps:
# actions/checkout v4.2.2, 2026-02-22
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- run: script/cibuild
- run: docker build -f Dockerfile.backend .

1
.gitignore vendored
View File

@@ -1,4 +1,5 @@
node_modules/ node_modules/
dist/ dist/
tmp/
.DS_Store .DS_Store
*.log *.log

View File

@@ -1,4 +1,6 @@
backend/
dist/ dist/
node_modules/ node_modules/
tmp/
yarn.lock yarn.lock
.claude/ .claude/

View File

@@ -1,3 +1,4 @@
{ {
"tabWidth": 4 "tabWidth": 4,
"proseWrap": "always"
} }

View File

@@ -3,48 +3,19 @@ FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e3
WORKDIR /app WORKDIR /app
COPY package.json yarn.lock ./ COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile RUN yarn install --frozen-lockfile
RUN apk add --no-cache git make
COPY . . 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 # nginx:stable-alpine as of 2026-02-22
FROM nginx@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab FROM nginx@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab
# Remove default config
RUN rm /etc/nginx/conf.d/default.conf RUN rm /etc/nginx/conf.d/default.conf
# Config template — envsubst replaces $PORT at container start COPY nginx.conf /etc/nginx/conf.d/netwatch.conf
COPY <<'EOF' /etc/nginx/netwatch.conf.template
server {
listen $PORT;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Trust RFC1918 reverse proxies for X-Forwarded-For
set_real_ip_from 10.0.0.0/8;
set_real_ip_from 172.16.0.0/12;
set_real_ip_from 192.168.0.0/16;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
# Access log to stdout (Docker best practice)
access_log /dev/stdout combined;
error_log /dev/stderr warn;
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets aggressively
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
EOF
COPY --from=build /app/dist /usr/share/nginx/html COPY --from=build /app/dist /usr/share/nginx/html
ENV PORT=8080
EXPOSE 8080 EXPOSE 8080
CMD ["/bin/sh", "-c", "envsubst '$PORT' < /etc/nginx/netwatch.conf.template > /etc/nginx/conf.d/netwatch.conf && exec nginx -g 'daemon off;'"] CMD ["nginx", "-g", "daemon off;"]

25
Dockerfile.backend Normal file
View File

@@ -0,0 +1,25 @@
# 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
# 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 /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
# 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
EXPOSE 8080
ENTRYPOINT ["netwatch-server"]

View File

@@ -1,18 +1,42 @@
.PHONY: test lint fmt fmt-check check docker .PHONY: bootstrap setup dev test lint fmt fmt-check check \
frontend-viewport-test docker hooks
# Standard targets are thin shims; the implementations live in script/
# per the scripts-to-rule-them-all pattern (see the Entrypoints section
# of README.md).
bootstrap:
@script/bootstrap
setup:
@script/setup
dev:
yarn dev
test: test:
timeout 30 yarn build @script/test
lint: lint:
yarn prettier --check . @script/lint
fmt: fmt:
yarn prettier --write . @script/fmt
fmt-check: fmt-check:
yarn prettier --check . @script/fmt-check
check: test lint fmt-check check:
@script/check
# Responsive-layout verification in a containerised browser. Kept out of
# check: it needs Docker and takes minutes, where make test has to stay
# under 20 seconds.
frontend-viewport-test:
@script/frontend-viewport-test
docker: docker:
timeout 300 docker build -t netwatch . @script/docker
hooks:
@script/install-precommit

110
README.md
View File

@@ -1,4 +1,7 @@
NetWatch is an MIT-licensed JavaScript single-page application by [@sneak](https://sneak.berlin) that provides real-time network latency monitoring to common internet hosts, displayed with color-coded figures and sparkline graphs, served from a static bucket or Docker container. NetWatch is an MIT-licensed JavaScript single-page application by
[@sneak](https://sneak.berlin) that provides real-time network latency
monitoring to common internet hosts, displayed with color-coded figures and
sparkline graphs, served from a static bucket or Docker container.
## Getting Started ## Getting Started
@@ -20,29 +23,89 @@ docker build -t netwatch .
docker run -p 8080:8080 netwatch docker run -p 8080:8080 netwatch
``` ```
## Entrypoints
This repository adheres to the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
standard: normalized scripts in `script/` are the entrypoints for the
development workflow, and the Makefile targets are thin shims that call them. We
provide:
- `script/bootstrap` — install all dependencies (pinned node via nvm if needed,
yarn via corepack, `yarn install --frozen-lockfile`)
- `script/setup` — make a fresh clone ready for development: bootstrap plus the
git pre-commit hook
- `script/projectname` — print the project name (used for the Docker image tag)
- `script/test` — run the production build as the test (no unit tests yet)
- `script/lint` — run prettier in check mode
- `script/fmt` — format all files (writes)
- `script/fmt-check` — check formatting (read-only)
- `script/check` — run test, lint, and fmt-check
- `script/frontend-viewport-test` — responsive-layout verification of the built
frontend in a containerised headless Chrome (see
[test/viewport/README.md](test/viewport/README.md)). Not part of
`script/check`: it needs Docker and takes minutes.
- `script/docker` — build the Docker image tagged via `script/projectname`
- `script/cibuild` — CI entrypoint: plain `docker build .`
- `script/precommit` — run by the git pre-commit hook; runs `script/check`
- `script/install-precommit` — install the git pre-commit hook
## Responsive layout
The narrow-viewport layout lives in the `max-width: 768px` media block in
`src/styles.css`. It is verified automatically by `make frontend-viewport-test`,
which drives a digest-pinned headless Chrome against the built `dist/` and
asserts on computed layout at widths derived from that CSS — one pixel either
side of every breakpoint it declares, plus a 320px floor, a desktop baseline and
two landscape sizes. See [test/viewport/README.md](test/viewport/README.md) for
what it covers and what it genuinely cannot.
## Rationale ## Rationale
When debugging network issues, it's useful to have a persistent at-a-glance view of latency and reachability to multiple well-known internet endpoints. NetWatch provides this as a zero-dependency SPA that can be deployed anywhere static files are served, with no backend required. When debugging network issues, it's useful to have a persistent at-a-glance view
of latency and reachability to multiple well-known internet endpoints. NetWatch
provides this as a zero-dependency SPA that can be deployed anywhere static
files are served, with no backend required.
## Design ## Design
The application is a single-page app built with Vite and Tailwind CSS v4. All code lives in `src/main.js` with a class-based architecture: The application is a single-page app built with Vite and Tailwind CSS v4. All
code lives in `src/main.js` with a class-based architecture:
- **`CONFIG`**: Frozen configuration object (update interval, timeouts, axis ticks, etc.) - **`CONFIG`**: Frozen configuration object (update interval, timeouts, axis
- **`HostState`**: Per-host state management — history buffer, latency tracking, status transitions ticks, etc.)
- **`AppState`**: Top-level state container — WAN hosts, local hosts, pause state, aggregate stats - **`HostState`**: Per-host state management — history buffer, latency tracking,
- **`SparklineRenderer`**: Canvas 2D sparkline drawing with fixed axes, color-coded line segments, error regions, and DPR-aware scaling status transitions
- **UI functions**: `buildUI()` constructs the DOM, `updateHostRow()` / `updateSummary()` / `updateHealthBox()` handle incremental updates - **`AppState`**: Top-level state container — WAN hosts, local hosts, pause
- **`tick()`**: Main loop — measures all hosts in parallel via `Promise.all`, pushes samples, redraws UI. When paused, pushes blank markers (no probes, no false outage) state, aggregate stats
- **`SparklineRenderer`**: Canvas 2D sparkline drawing with fixed axes,
color-coded line segments, error regions, and DPR-aware scaling
- **UI functions**: `buildUI()` constructs the DOM, `updateHostRow()` /
`updateSummary()` / `updateHealthBox()` handle incremental updates
- **`tick()`**: Main loop — measures all hosts in parallel via `Promise.all`,
pushes samples, redraws UI. When paused, pushes blank markers (no probes, no
false outage)
### Monitoring targets ### Monitoring targets
- **9 WAN hosts**: Google Cloud Console, AWS Console, GitHub, Cloudflare, Azure, DigitalOcean, Fastly, Akamai, datavi.be - **22 WAN hosts**: datavi.be, Anthropic API, OpenAI API, AWS Console, GCP
- **1 Local host**: Local Gateway (192.168.100.1), tracked separately from WAN stats Console, Azure, Cloudflare, Fastly, Akamai, GitHub, B2, 7 S3 regional
endpoints (Cape Town, London, Bahrain, Tokyo, Sydney, Oregon, São Paulo), 4
GCS locational endpoints (Iowa, Belgium, Singapore, Sydney)
- **Local CPE**: Cable modem at 192.168.100.1 (always monitored)
- **Local Gateway**: Auto-detected on startup by probing common default gateway
addresses (192.168.1.1, 192.168.0.1, 192.168.8.1, 10.0.0.1); first responder
wins. Note: modern browsers enforce Private Network Access restrictions that
block public-origin pages from reaching RFC1918 addresses, so local targets
only work when NetWatch is served from localhost or a private address.
Local hosts are tracked separately from WAN stats.
### Latency measurement ### Latency measurement
HEAD requests with `mode: 'no-cors'` and `cache: 'no-store'`, timed with `performance.now()`. 1-second timeout; anything over 1000ms is clamped to unreachable. IPv4 only. HEAD requests with `mode: 'no-cors'` and `cache: 'no-store'`, timed with
`performance.now()`. 1-second timeout; anything over 1000ms is clamped to
unreachable. IPv4 only.
### Color coding ### Color coding
@@ -72,31 +135,40 @@ dist/
- Summary stats: reachable count, min/max/avg latency across WAN hosts only - Summary stats: reachable count, min/max/avg latency across WAN hosts only
- Fixed chart axes: Y-axis 01000ms, X-axis 0300s - Fixed chart axes: Y-axis 01000ms, X-axis 0300s
- Color-coded latency figures and sparkline line segments - Color-coded latency figures and sparkline line segments
- Play/pause: pause stops probes but history keeps scrolling (blank gaps, no false outage) - Play/pause: pause stops probes but history keeps scrolling (blank gaps, no
false outage)
- Clickable service URLs - Clickable service URLs
- Canvas-based sparkline rendering with devicePixelRatio scaling - Canvas-based sparkline rendering with devicePixelRatio scaling
- Zero runtime dependencies: all resources bundled into build artifacts - Zero runtime dependencies: all resources bundled into build artifacts
## Deployment ## Deployment
After running `yarn build`, deploy the contents of the `dist/` directory to any static file host (S3, GCS, Cloudflare Pages, Vercel, Netlify, GitHub Pages) or use the Docker image behind a reverse proxy. After running `yarn build`, deploy the contents of the `dist/` directory to any
static file host (S3, GCS, Cloudflare Pages, Vercel, Netlify, GitHub Pages) or
use the Docker image behind a reverse proxy.
The Docker image: The Docker image:
- Listens on port 8080 by default (override with `PORT` env var) - Listens on port 8080 by default (override with `PORT` env var)
- Trusts `X-Forwarded-For` from RFC1918 reverse proxies (10/8, 172.16/12, 192.168/16) - Trusts `X-Forwarded-For` from RFC1918 reverse proxies (10/8, 172.16/12,
192.168/16)
- Sends access logs to stdout - Sends access logs to stdout
- Caches static assets with immutable headers - Caches static assets with immutable headers
## Browser Compatibility ## Browser Compatibility
Requires a modern browser with ES modules, Fetch API, Canvas API, and CSS custom properties. Requires a modern browser with ES modules, Fetch API, Canvas API, and CSS custom
properties.
## Limitations ## Limitations
- **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. - **CORS**: Some hosts may block cross-origin HEAD requests. The app uses
- **Local gateway**: The 192.168.100.1 endpoint requires the host to be accessible from your network. `no-cors` mode which allows the request but provides opaque responses. Latency
- **Network conditions**: Measurements reflect browser-to-endpoint latency, which includes your local network, ISP, and internet routing. is still measurable based on request timing.
- **Local gateway**: The 192.168.100.1 endpoint requires the host to be
accessible from your network.
- **Network conditions**: Measurements reflect browser-to-endpoint latency,
which includes your local network, ISP, and internet routing.
## TODO ## TODO

View File

@@ -1,110 +1,408 @@
# Development Policies ---
title: Repository Policies
last_modified: 2026-07-06
---
- Docker image references by tag are server-mutable, therefore using them is This document covers repository structure, tooling, and workflow standards. Code
an RCE vulnerability. All docker image references must use cryptographic style conventions are in separate documents:
hashes to securely specify the exact image that is expected.
- Correspondingly, `go install` commands using things like '@latest' are - [Code Styleguide](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE.md)
also dangerous RCE. Whenever writing scripts or tools, ALWAYS specify go (general, bash, Docker)
install targets using commit hashes which are cryptographically secure. - [Go](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_GO.md)
- [JavaScript](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_JS.md)
- [Python](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_PYTHON.md)
- [Go HTTP Server Conventions](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/GO_HTTP_SERVER_CONVENTIONS.md)
- Every repo with software in it must have a Makefile in the root. Each ---
such Makefile should support `make test` (runs the project-specific
tests), `make lint`, `make fmt` (writes), `make fmt-check` (readonly), and
`make check` (has `test`, `lint`, and `fmt-check` as prereqs), `make
docker` (builds docker image).
- Every repo should have a Dockerfile. If the repo contains non-server - Cross-project documentation (such as this file) must include
software, the Dockerfile should bring up a development environment and `last_modified: YYYY-MM-DD` in the YAML front matter so it can be kept in sync
`make check` (i.e. the docker build should fail if the branch is not with the authoritative source as policies evolve.
green).
- Platform-specific standard formatting should be used. `black` for python, - **ALL external references must be pinned by cryptographic hash.** This
`prettier` for js/css/etc, `go fmt` for go. The only changes to default includes Docker base images, Go modules, npm packages, GitHub Actions, and
settings should be to specify four-space indents where applicable (i.e. anything else fetched from a remote source. Version tags (`@v4`, `@latest`,
everything except `go fmt`). `:3.21`, etc.) are server-mutable and therefore remote code execution
vulnerabilities. The ONLY acceptable way to reference an external dependency
is by its content hash (Docker `@sha256:...`, Go module hash in `go.sum`, npm
integrity hash in lockfile, GitHub Actions `@<commit-sha>`). No exceptions.
This also means never `curl | bash` to install tools like pyenv, nvm, rustup,
etc. Instead, download a specific release archive from GitHub, verify its hash
(hardcoded in the Dockerfile or script), and only then install. Unverified
install scripts are arbitrary remote code execution. This is the single most
important rule in this document. Double-check every external reference in
every file before committing. There are zero exceptions to this rule.
- If local testing is possible (it is not always), `make check` should be a - Every repo with software must have a root `Makefile` with these targets:
pre-commit hook. If it is not possible, `make lint && make fmt-check` `make bootstrap`, `make setup`, `make test`, `make lint`, `make fmt` (writes),
should be a pre-commit hook. `make fmt-check` (read-only), `make check` (runs `test`, `lint`, `fmt-check`),
`make docker`, and `make hooks` (installs pre-commit hook). A model Makefile
is at `https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`.
- If a working `make test` takes more than 20 seconds, that's a bug that - Repos follow the
needs fixing. In fact, there should be a timeout specified in the [Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
`Makefile` that fails it automatically if it takes >30s. pattern: the implementation of each Makefile target lives in an executable
script in `script/` (`script/bootstrap`, `script/setup`, `script/test`,
`script/lint`, `script/fmt`, `script/fmt-check`, `script/check`,
`script/docker`), and the Makefile targets are thin shims that call them. The
scripts must be POSIX sh (`#!/bin/sh`, `set -eu`, no bashisms) so they run in
minimal containers (e.g. alpine images have no bash); locate the repo root
with `$(cd "$(dirname "$0")/.." && pwd -P)` and `cd` there before acting. From
the standard's canonical set we use `bootstrap`, `setup` (make the repo ready
for development after a fresh clone: runs `bootstrap`, then
`install-precommit`, plus any repo-specific initialization), `test`, and
`cibuild`. `script/bootstrap` installs all dependencies idempotently and
assumes nothing is present: base tools come from nix, apt, brew, or apk
(detected in that order; apt runs noninteractive). For node it uses the
installed node if present; otherwise it installs a PINNED node version via
nvm, first installing nvm itself if missing — from a hash-verified GitHub
release archive (never `curl | sh`), with bash installed as an explicit
prerequisite since nvm requires bash. yarn is then pinned via
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
always exact versions. `script/cibuild` runs the CI build: it changes to the
repo root and runs `docker build .`; the Gitea workflow calls it. Four further
scripts are our own extensions to the standard: `script/check` runs
`script/test`, `script/lint`, and `script/fmt-check`; `script/precommit` is
what the git pre-commit hook runs, and it calls `script/check`;
`script/install-precommit` installs the git pre-commit hook (the `make hooks`
target shims to it); and `script/projectname` (literally that filename) simply
outputs the project's name. Scripts that need the name call
`script/projectname` — e.g. `script/docker` assembles its image tag from it —
so those scripts stay byte-identical across all repos. Repo-type-specific
pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong in
`script/precommit`, not in the hook itself. Model scripts are at
`https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README
must document the provided scripts in an **Entrypoints** section (see the
README requirements below).
- Docker builds should time out in 5 minutes or less. - Always use Makefile targets (`make fmt`, `make test`, `make lint`, etc.)
instead of invoking the underlying tools directly. The Makefile is the single
source of truth for how these operations are run.
- The Makefile is authoritative documentation for how the repo is used. Beyond
the required targets above, it should have targets for every common operation:
running a local development server (`make run`, `make dev`), re-initializing
or migrating the database (`make db-reset`, `make migrate`), building
artifacts (`make build`), generating code, seeding data, or anything else a
developer would do regularly. If someone checks out the repo and types
`make<tab>`, they should see every meaningful operation available. A new
contributor should be able to understand the entire development workflow by
reading the Makefile.
- Every repo should have a `Dockerfile`. All Dockerfiles must run `make check`
as a build step so the build fails if the branch is not green. For non-server
repos, the Dockerfile should bring up a development environment and run
`make check`. For server repos, `make check` should run as an early build
stage before the final image is assembled. Dockerfiles install development
prerequisites by running `script/bootstrap` rather than duplicating installs
inline; COPY `script/` and the dependency manifests (`package.json` +
`yarn.lock`, `go.mod` + `go.sum`, etc.) before running it so the bootstrap
layer stays cached until dependencies change.
- **Dockerfiles must use a separate lint stage for fail-fast feedback.** Go
repos use a multistage build where linting runs in an independent stage based
on the `golangci/golangci-lint` image (pinned by hash). This stage runs
`make fmt-check` and `make lint` before the full build begins. The build stage
then declares an explicit dependency on the lint stage via
`COPY --from=lint /src/go.sum /dev/null`, which forces BuildKit to complete
linting before proceeding to compilation and tests. This ensures lint failures
surface in seconds rather than minutes, without blocking on dependency
download or compilation in the build stage.
The standard pattern for a Go repo Dockerfile is:
```dockerfile
# Lint stage — fast feedback on formatting and lint issues
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
FROM golangci/golangci-lint@sha256:... AS lint
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN make fmt-check
RUN make lint
# Build stage
# golang:1.x-alpine, YYYY-MM-DD
FROM golang@sha256:... AS builder
WORKDIR /src
# Force BuildKit to run the lint stage before proceeding
COPY --from=lint /src/go.sum /dev/null
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN make test
ARG VERSION=dev
RUN CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w -X main.Version=${VERSION}" \
-o /app ./cmd/app/
# Runtime stage
FROM alpine@sha256:...
COPY --from=builder /app /usr/local/bin/app
ENTRYPOINT ["app"]
```
Key points:
- The lint stage uses the `golangci/golangci-lint` image directly (it
includes both Go and the linter), so there is no need to install the
linter separately.
- `COPY --from=lint /src/go.sum /dev/null` is a no-op file copy that creates
a stage dependency. BuildKit runs stages in parallel by default; without
this line, the build stage would not wait for lint to finish and a lint
failure might not fail the overall build.
- If the project uses `//go:embed` directives that reference build artifacts
(e.g. a web frontend compiled in a separate stage), the lint stage must
create placeholder files so the embed directives resolve. Example:
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
The lint stage should not depend on the actual build output — it exists to
fail fast.
- If the project requires CGO or system libraries for linting (e.g.
`vips-dev`), install them in the lint stage with `apk add`.
- The build stage runs `make test` after compilation setup. Tests run in the
build stage, not the lint stage, because they may require compiled
artifacts or heavier dependencies.
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
runs `script/cibuild` (which runs `docker build .`) on push. Since the
Dockerfile already runs `make check`, a successful build implies all checks
pass.
- Use platform-standard formatters: `black` for Python, `prettier` for
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
two exceptions: four-space indents (except Go), and `proseWrap: always` for
Markdown (hard-wrap at 80 columns). Documentation and writing repos (Markdown,
HTML, CSS) should also have `.prettierrc` and `.prettierignore`.
- Pre-commit hook: runs `script/precommit`, which calls `script/check`. If local
testing is not possible in the repo, `script/precommit` may skip `script/test`
and run only `script/lint` and `script/fmt-check`. The hook is installed by
`script/install-precommit`; the Makefile must provide a `make hooks` target
that shims to it.
- All repos with software must have tests that run via the platform-standard
test framework (`go test`, `pytest`, `jest`/`vitest`, etc.). If no meaningful
tests exist yet, add the most minimal test possible — e.g. importing the
module under test to verify it compiles/parses. There is no excuse for
`make test` to be a no-op.
- `make test` must complete in under 20 seconds. Add a 30-second timeout in the
Makefile.
- **`make test` should use the conditional verbose rerun pattern.** Run tests
without `-v` (verbose) first. If tests fail, automatically rerun with `-v` to
show full output. This keeps CI logs and `docker build` output clean on
success (just package/suite summaries) while providing full diagnostic detail
on failure (every test case, every assertion). The general shell pattern:
```makefile
test:
@<test-command> || \
{ echo "--- Rerunning with -v for details ---"; \
<test-command-with-v>; exit 1; }
```
Go example:
```makefile
test:
@go test -timeout 30s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \
go test -timeout 30s -race -v ./...; exit 1; }
```
Python example:
```makefile
test:
@python -m pytest || \
{ echo "--- Rerunning with -v for details ---"; \
python -m pytest -v; exit 1; }
```
The `exit 1` ensures the target always fails after a rerun — the first run
already proved the tests are broken, so the build must not pass even if a
flaky test happens to succeed on the second attempt. The rerun exists solely
for diagnostic output.
- Docker builds must complete in under 5 minutes.
- `make check` must not modify any files in the repo. Tests may use temporary
directories.
- `main` must always pass `make check`, no exceptions. - `main` must always pass `make check`, no exceptions.
- Do all changes on a feature branch. You can do whatever you want on a - Never commit secrets. `.env` files, credentials, API keys, and private keys
must be in `.gitignore`. No exceptions.
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`.
Fetch the standard `.gitignore` from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
a new repo.
- **No build artifacts in version control.** Code-derived data (compiled
bundles, minified output, generated assets) must never be committed to the
repository if it can be avoided. The build process (e.g. Dockerfile, Makefile)
should generate these at build time. Notable exception: Go protobuf generated
files (`.pb.go`) ARE committed because repos need to work with `go get`, which
downloads code but does not execute code generation.
- Never use `git add -A` or `git add .`. Always stage files explicitly by name.
- Never force-push to `main`.
- Make all changes on a feature branch. You can do whatever you want on a
feature branch. feature branch.
- We have a standardized `.golangci.yml` which we reuse and is _NEVER_ to be - `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only
modified by an agent, only manually by the user. It can be copied from manually by the user. Fetch from
`~/dev/upaas/.golangci.yml` if it exists at that location. `https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`.
- When specifying images or packages by hash in Dockerfiles or - When pinning images or packages by hash, add a comment above the reference
`docker-compose.yml`, put a comment above the line and show the version with the version and date (YYYY-MM-DD).
and date at which it was current.
- For javascript, always use `yarn` over `npm`. - Use `yarn`, not `npm`.
- Whenever writing dates, ALWAYS write YYYY-MM-DD (ISO 8601). - Write all dates as YYYY-MM-DD (ISO 8601).
- Simple projects should be configured with environment variables, as is - Simple projects should be configured with environment variables.
standard for Dockerized applications.
- Dockerized web services should listen on the default HTTP port of 8080 - Dockerized web services listen on port 8080 by default, overridable with
unless overridden with the `PORT` environment variable. `PORT`.
- The `README.md` is a project's primary documentation. It should contain - **HTTP/web services must be hardened for production internet exposure before
at a minimum the following sections: tagging 1.0.** This means full compliance with security best practices
- Description including, without limitation, all of the following:
- Include a short and complete description of the functionality and - **Security headers** on every response:
purpose of the software as the first line in the readme. It must - `Strict-Transport-Security` (HSTS) with `max-age` of at least one year
include: and `includeSubDomains`.
- the name - `Content-Security-Policy` (CSP) with a restrictive default policy
- the purpose (`default-src 'self'` as a baseline, tightened per-resource as
- the category (web server, SPA, command line tool, etc) needed). Never use `unsafe-inline` or `unsafe-eval` unless
- the license unavoidable, and document the reason.
- the author - `X-Frame-Options: DENY` (or `SAMEORIGIN` if framing is required).
- eg: "µPaaS is an MIT-licensed Go web application by @sneak Prefer the `frame-ancestors` CSP directive as the primary control.
that receives git-frontend webhooks and interacts with a - `X-Content-Type-Options: nosniff`.
Docker server to build and deploy applications in realtime as - `Referrer-Policy: strict-origin-when-cross-origin` (or stricter).
certain branches are updated." - `Permissions-Policy` restricting access to browser features the
- Getting Started application does not use (camera, microphone, geolocation, etc.).
- a code block with copy-pasteable installation/use sections - **Request and response limits:**
- Rationale - Maximum request body size enforced on all endpoints (e.g. Go
- why does this exist? `http.MaxBytesReader`). Choose a sane default per-route; never accept
- Design unbounded input.
- how is the program structured? - Maximum response body size where applicable (e.g. paginated APIs).
- TODO - `ReadTimeout` and `ReadHeaderTimeout` on the `http.Server` to defend
- This is your TODO list for the project - update it meticulously, against slowloris attacks.
even in between commits. Whenever planning, put your todo list in - `WriteTimeout` on the `http.Server`.
the README so that a separate agent with new context can pick up - `IdleTimeout` on the `http.Server`.
where you left off. - Per-handler execution time limits via `context.WithTimeout` or
- License chi/stdlib `middleware.Timeout`.
- GPL or MIT or WTFPL - ask the user when beginning a new project - **Authentication and session security:**
and include a LICENSE file in the root and in a section in the - Rate limiting on password-based authentication endpoints. API keys are
README. high-entropy and not susceptible to brute force, so they are exempt.
- Author - CSRF tokens on all state-mutating HTML forms. API endpoints
- @sneak (link `@sneak` to `https://sneak.berlin`). authenticated via `Authorization` header (Bearer token, API key) are
exempt because the browser does not attach these automatically.
- Passwords stored using bcrypt, scrypt, or argon2 — never plain-text,
MD5, or SHA.
- Session cookies set with `HttpOnly`, `Secure`, and `SameSite=Lax` (or
`Strict`) attributes.
- **Reverse proxy awareness:**
- True client IP detection when behind a reverse proxy
(`X-Forwarded-For`, `X-Real-IP`). The application must accept
forwarded headers only from a configured set of trusted proxy
addresses — never trust `X-Forwarded-For` unconditionally.
- **CORS:**
- Authenticated endpoints must restrict `Access-Control-Allow-Origin` to
an explicit allowlist of known origins. Wildcard (`*`) is acceptable
only for public, unauthenticated read-only APIs.
- **Error handling:**
- Internal errors must never leak stack traces, SQL queries, file paths,
or other implementation details to the client. Return generic error
messages in production; detailed errors only when `DEBUG` is enabled.
- **TLS:**
- Services never terminate TLS directly. They are always deployed behind
a TLS-terminating reverse proxy. The service itself listens on plain
HTTP. However, HSTS headers and `Secure` cookie flags must still be
set by the application so that the browser enforces HTTPS end-to-end.
- When beginning a new project, initialize a git repo and make the first This list is non-exhaustive. Apply defense-in-depth: if a standard security
commit simply the first version of the README.md in the root of the repo. hardening measure exists for HTTP services and is not listed here, it is
still expected. When in doubt, harden.
- For Go packages, the module root is `sneak.berlin/go/...`, such - `README.md` is the primary documentation. Required sections:
as `sneak.berlin/go/dnswatcher`. - **Description**: First line must include the project name, purpose,
category (web server, SPA, CLI tool, etc.), license, and author. Example:
"µPaaS is an MIT-licensed Go web application by @sneak that receives
git-frontend webhooks and deploys applications via Docker in realtime."
- **Getting Started**: Copy-pasteable install/usage code block.
- **Entrypoints**: Opens by stating that the repo adheres to the
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
standard (with that link), then documents each provided `script/`
entrypoint and its purpose.
- **Rationale**: Why does this exist?
- **Design**: How is the program structured?
- **TODO**: Update meticulously, even between commits. When planning, put
the todo list in the README so a new agent can pick up where the last one
left off.
- **License**: MIT, GPL, or WTFPL. Ask the user for new projects. Include a
`LICENSE` file in the repo root and a License section in the README.
- **Author**: [@sneak](https://sneak.berlin).
- We use SemVer always. - First commit of a new repo should contain only `README.md`.
- If no tag `1.0.0` or greater exists in the repository, modify the existing - Go module root: `sneak.berlin/go/<name>`. Always run `go mod tidy` before
migrations and assume no installed base or existing databases. If committing.
`>=1.0.0`, database changes add new migration files.
- New repos must have at a minimum the following files: - Use SemVer.
- `README.md`, `.git`, `.gitignore`
- `POLICIES.md` (copy from `~/Documents/_PROMPTS/POLICIES.md`) - Database migrations live in `internal/db/migrations/` and must be embedded in
the binary.
- `000_migration.sql` — contains ONLY the creation of the migrations
tracking table itself. Nothing else.
- `001_schema.sql` — the full application schema.
- **Pre-1.0.0:** never add additional migration files (002, 003, etc.).
There is no installed base to migrate. Edit `001_schema.sql` directly.
- **Post-1.0.0:** add new numbered migration files for each schema change.
Never edit existing migrations after release.
- All repos should have an `.editorconfig` enforcing the project's indentation
settings.
- Avoid putting files in the repo root unless necessary. Root should contain
only project-level config files (`README.md`, `Makefile`, `Dockerfile`,
`LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and
language-specific config). Everything else goes in a subdirectory. Canonical
subdirectory names:
- `bin/` — executable scripts and tools
- `cmd/` — Go command entrypoints
- `configs/` — configuration templates and examples
- `deploy/` — deployment manifests (k8s, compose, terraform)
- `docs/` — documentation and markdown (README.md stays in root)
- `internal/` — Go internal packages
- `internal/db/migrations/` — database migrations
- `pkg/` — Go library packages
- `share/` — systemd units, data files
- `static/` — static assets (images, fonts, etc.)
- `web/` — web frontend source
- When setting up a new repo, files from the `prompts` repo may be used as
templates. Fetch them from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/<path>`.
- New repos must contain at minimum:
- `README.md`, `.git`, `.gitignore`, `.editorconfig`
- `LICENSE`, `REPO_POLICIES.md` (copy from the `prompts` repo)
- `Makefile`
- `script/` entrypoints (`bootstrap`, `setup`, `projectname`, `test`,
`lint`, `fmt`, `fmt-check`, `check`, `docker`, `cibuild`, `precommit`,
`install-precommit`)
- `Dockerfile`, `.dockerignore` - `Dockerfile`, `.dockerignore`
- for go: `go.mod`, `go.sum`, `.golangci.yml` - `.gitea/workflows/check.yml`
- for js: `package.json` - Go: `go.mod`, `go.sum`, `.golangci.yml`
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
- Python: `pyproject.toml`

56
TODO.md Normal file
View File

@@ -0,0 +1,56 @@
# Workflow
- branch (from `main`)
- do the work in Next Step
- move Next Step to the top of Completed Steps
- move the top item of Future Steps into Next Step
- commit (`TODO.md` changes in the same commit as the work)
- merge to `main` if the branch is not protected, otherwise open a PR
- push
# 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.
# 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.
# Completed Steps
- 2026-08-09: automated responsive-layout harness
(`make frontend-viewport-test`): digest-pinned headless Chrome driven over CDP
against the built `dist/`, viewport widths derived from the breakpoints in
`src/styles.css` (#13). Every check carries a presence guard so none of them
can pass against a page it is not actually measuring. Found two real layout
defects, filed as #42 and #43
- 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
and backend repo standard files; backend Dockerfile fixed (Go 1.25,
golangci-lint) and moved to repo root (feat/reportbuf-storage, unmerged)
- 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,
S3 Singapore endpoint added
- 2026-02-23: summary box redesign, host pinning, local and UTC clocks, checks
counter
- 2026-02-23: hosts sorted by latency; GET instead of HEAD for latency; timeout
derived from interval; Hetzner regional endpoints; 3s interval
- 2026-01-29: initial NetWatch network latency monitor
# Future Steps
- Fix the two layout defects the viewport harness found (#42 horizontal overflow
at 320px, #43 tap targets below 44x44), then wire
`script/frontend-viewport-test` into CI as its own step
- 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

2
backend/.dockerignore Normal file
View File

@@ -0,0 +1,2 @@
node_modules
.DS_Store

12
backend/.editorconfig Normal file
View File

@@ -0,0 +1,12 @@
root = true
[*]
indent_style = space
indent_size = 4
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[Makefile]
indent_style = tab

6
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
/netwatch-server
*.log
*.out
*.test
.env
data/

32
backend/.golangci.yml Normal file
View File

@@ -0,0 +1,32 @@
version: "2"
run:
timeout: 5m
modules-download-mode: readonly
linters:
default: all
disable:
# Genuinely incompatible with project patterns
- exhaustruct # Requires all struct fields
- depguard # Dependency allow/block lists
- godot # Requires comments to end with periods
- wsl # Deprecated, replaced by wsl_v5
- wrapcheck # Too verbose for internal packages
- varnamelen # Short names like db, id are idiomatic Go
linters-settings:
lll:
line-length: 88
funlen:
lines: 80
statements: 50
cyclop:
max-complexity: 15
dupl:
threshold: 100
issues:
exclude-use-default: false
max-issues-per-linter: 0
max-same-issues: 0

21
backend/LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 @sneak (https://sneak.berlin)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

53
backend/Makefile Normal file
View File

@@ -0,0 +1,53 @@
UNAME_S := $(shell uname -s)
VERSION := $(shell git describe --always --dirty)
BUILDARCH := $(shell uname -m)
BINARY := netwatch-server
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/
test:
timeout 30 go test ./...
lint:
golangci-lint run ./...
fmt:
go fmt ./...
fmt-check:
@test -z "$$(gofmt -l .)" || \
(echo "Files not formatted:"; gofmt -l .; exit 1)
check: test lint fmt-check
docker:
timeout 300 docker build -t netwatch-server -f ../Dockerfile.backend ..
hooks:
@printf '#!/bin/sh\ncd backend && make check\n' > \
$$(git rev-parse --show-toplevel)/.git/hooks/pre-commit
@chmod +x \
$$(git rev-parse --show-toplevel)/.git/hooks/pre-commit
@echo "Pre-commit hook installed"
run: build
./$(BINARY)
clean:
rm -f ./$(BINARY)

70
backend/README.md Normal file
View File

@@ -0,0 +1,70 @@
netwatch-server is an MIT-licensed Go HTTP backend by
[@sneak](https://sneak.berlin) that receives telemetry reports from the NetWatch
SPA and persists them as zstd-compressed JSONL files on disk.
## Getting Started
```bash
# Build and run locally
make run
# Run tests, lint, and format check
make check
# Docker
docker build -t netwatch-server .
docker run -p 8080:8080 netwatch-server
```
## Rationale
The NetWatch frontend collects latency measurements from the browser but has no
way to persist or aggregate them. This backend provides a minimal
`POST /api/v1/reports` endpoint that buffers incoming reports in memory and
flushes them to compressed files on disk for later analysis.
## Design
The server is structured as an `fx`-wired Go application under `cmd/netwatch-server/`.
Internal packages in `internal/` follow standard Go project layout:
- **`config`**: Loads configuration from environment variables and config files
via Viper.
- **`handlers`**: HTTP request handlers for the API (health check, report
ingestion).
- **`reportbuf`**: In-memory buffer that accumulates JSONL report lines and
flushes to zstd-compressed files when the buffer reaches 10 MiB or every 60
seconds.
- **`server`**: Chi-based HTTP server with middleware wiring and route
registration.
- **`healthcheck`**, **`middleware`**, **`logger`**, **`globals`**: Supporting
infrastructure.
### Configuration
| Variable | Default | Description |
| ---------- | ------------------ | --------------------------------- |
| `PORT` | `8080` | HTTP listen port |
| `DATA_DIR` | `./data/reports` | Directory for compressed reports |
| `DEBUG` | `false` | Enable debug logging |
### Report storage
Reports are written as `reports-<timestamp>.jsonl.zst` files in `DATA_DIR`.
Each file contains one JSON object per line, compressed with zstd. Files are
created with `O_EXCL` to prevent overwrites.
## TODO
- Add integration test that POSTs a report and verifies the compressed output
- Add report decompression/query endpoint
- Add metrics (Prometheus) for buffer size, flush count, report count
- Add retention policy to prune old report files
## License
MIT. See [LICENSE](LICENSE).
## Author
[@sneak](https://sneak.berlin)

View File

@@ -0,0 +1,42 @@
// Package main is the entry point for netwatch-server.
package main
import (
"sneak.berlin/go/netwatch/internal/config"
"sneak.berlin/go/netwatch/internal/globals"
"sneak.berlin/go/netwatch/internal/handlers"
"sneak.berlin/go/netwatch/internal/healthcheck"
"sneak.berlin/go/netwatch/internal/logger"
"sneak.berlin/go/netwatch/internal/middleware"
"sneak.berlin/go/netwatch/internal/reportbuf"
"sneak.berlin/go/netwatch/internal/server"
"go.uber.org/fx"
)
//nolint:gochecknoglobals // set via ldflags at build time
var (
Appname = "netwatch-server"
Version string
Buildarch string
)
func main() {
globals.Appname = Appname
globals.Version = Version
globals.Buildarch = Buildarch
fx.New(
fx.Provide(
config.New,
globals.New,
handlers.New,
healthcheck.New,
logger.New,
middleware.New,
reportbuf.New,
server.New,
),
fx.Invoke(func(*server.Server) {}),
).Run()
}

30
backend/go.mod Normal file
View File

@@ -0,0 +1,30 @@
module sneak.berlin/go/netwatch
go 1.25.5
require (
github.com/go-chi/chi/v5 v5.2.5
github.com/go-chi/cors v1.2.2
github.com/joho/godotenv v1.5.1
github.com/klauspost/compress v1.18.4
github.com/spf13/viper v1.21.0
go.uber.org/fx v1.24.0
)
require (
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
go.uber.org/dig v1.19.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
go.uber.org/zap v1.26.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/text v0.28.0 // indirect
)

65
backend/go.sum Normal file
View File

@@ -0,0 +1,65 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4=
go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE=
go.uber.org/fx v1.24.0 h1:wE8mruvpg2kiiL1Vqd0CC+tr0/24XIB10Iwp2lLWzkg=
go.uber.org/fx v1.24.0/go.mod h1:AmDeGyS+ZARGKM4tlH4FY2Jr63VjbEDJHtqXTGP5hbo=
go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -0,0 +1,86 @@
// Package config loads application configuration from
// environment variables, .env files, and config files.
package config
import (
"errors"
"log/slog"
"sneak.berlin/go/netwatch/internal/globals"
"sneak.berlin/go/netwatch/internal/logger"
_ "github.com/joho/godotenv/autoload" // loads .env file
"github.com/spf13/viper"
"go.uber.org/fx"
)
// Params defines the dependencies for Config.
type Params struct {
fx.In
Globals *globals.Globals
Logger *logger.Logger
}
// Config holds the resolved application configuration.
type Config struct {
DataDir string
Debug bool
MetricsPassword string
MetricsUsername string
Port int
SentryDSN string
log *slog.Logger
params *Params
}
// New loads configuration from env, .env files, and config
// files, returning a fully resolved Config.
func New(
_ fx.Lifecycle,
params Params,
) (*Config, error) {
log := params.Logger.Get()
name := params.Globals.Appname
viper.SetConfigName(name)
viper.SetConfigType("yaml")
viper.AddConfigPath("/etc/" + name)
viper.AddConfigPath("$HOME/.config/" + name)
viper.AutomaticEnv()
viper.SetDefault("DATA_DIR", "./data/reports")
viper.SetDefault("DEBUG", "false")
viper.SetDefault("PORT", "8080")
viper.SetDefault("SENTRY_DSN", "")
viper.SetDefault("METRICS_USERNAME", "")
viper.SetDefault("METRICS_PASSWORD", "")
err := viper.ReadInConfig()
if err != nil {
var notFound viper.ConfigFileNotFoundError
if !errors.As(err, &notFound) {
log.Error("config file malformed", "error", err)
panic(err)
}
}
s := &Config{
DataDir: viper.GetString("DATA_DIR"),
Debug: viper.GetBool("DEBUG"),
MetricsPassword: viper.GetString("METRICS_PASSWORD"),
MetricsUsername: viper.GetString("METRICS_USERNAME"),
Port: viper.GetInt("PORT"),
SentryDSN: viper.GetString("SENTRY_DSN"),
log: log,
params: &params,
}
if s.Debug {
params.Logger.EnableDebugLogging()
s.log = params.Logger.Get()
}
return s, nil
}

View File

@@ -0,0 +1,31 @@
// Package globals provides build-time variables injected via
// ldflags and made available through dependency injection.
package globals
import "go.uber.org/fx"
//nolint:gochecknoglobals // set from main before fx starts
var (
// Appname is the application name.
Appname string
// Version is the git version tag.
Version string
// Buildarch is the build architecture.
Buildarch string
)
// Globals holds build-time metadata for the application.
type Globals struct {
Appname string
Version string
Buildarch string
}
// New creates a Globals instance from package-level variables.
func New(_ fx.Lifecycle) (*Globals, error) {
return &Globals{
Appname: Appname,
Buildarch: Buildarch,
Version: Version,
}, nil
}

View File

@@ -0,0 +1,74 @@
// Package handlers implements HTTP request handlers for the
// netwatch-server API.
package handlers
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"sneak.berlin/go/netwatch/internal/globals"
"sneak.berlin/go/netwatch/internal/healthcheck"
"sneak.berlin/go/netwatch/internal/logger"
"sneak.berlin/go/netwatch/internal/reportbuf"
"go.uber.org/fx"
)
const jsonContentType = "application/json; charset=utf-8"
// Params defines the dependencies for Handlers.
type Params struct {
fx.In
Buffer *reportbuf.Buffer
Globals *globals.Globals
Healthcheck *healthcheck.Healthcheck
Logger *logger.Logger
}
// Handlers provides HTTP handler factories for all endpoints.
type Handlers struct {
buf *reportbuf.Buffer
hc *healthcheck.Healthcheck
log *slog.Logger
params *Params
}
// New creates a Handlers instance.
func New(
lc fx.Lifecycle,
params Params,
) (*Handlers, error) {
s := new(Handlers)
s.buf = params.Buffer
s.params = &params
s.log = params.Logger.Get()
s.hc = params.Healthcheck
lc.Append(fx.Hook{
OnStart: func(_ context.Context) error {
return nil
},
})
return s, nil
}
func (s *Handlers) respondJSON(
w http.ResponseWriter,
_ *http.Request,
data any,
status int,
) {
w.Header().Set("Content-Type", jsonContentType)
w.WriteHeader(status)
if data != nil {
err := json.NewEncoder(w).Encode(data)
if err != nil {
s.log.Error("json encode error", "error", err)
}
}
}

View File

@@ -0,0 +1,13 @@
package handlers_test
import (
"testing"
_ "sneak.berlin/go/netwatch/internal/handlers"
)
func TestImport(t *testing.T) {
t.Parallel()
// Compilation check — verifies the package parses
// and all imports resolve.
}

View File

@@ -0,0 +1,11 @@
package handlers
import "net/http"
// HandleHealthCheck returns a handler for the health check
// endpoint.
func (s *Handlers) HandleHealthCheck() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
s.respondJSON(w, r, s.hc.Check(), http.StatusOK)
}
}

View File

@@ -0,0 +1,82 @@
package handlers
import (
"encoding/json"
"net/http"
)
const maxReportBodyBytes = 1 << 20 // 1 MiB
type reportSample struct {
T int64 `json:"t"`
Latency *int `json:"latency"`
Error *string `json:"error"`
}
type reportHost struct {
History []reportSample `json:"history"`
Name string `json:"name"`
Status string `json:"status"`
URL string `json:"url"`
}
type report struct {
ClientID string `json:"clientId"`
Geo json.RawMessage `json:"geo"`
Hosts []reportHost `json:"hosts"`
Timestamp string `json:"timestamp"`
}
// HandleReport returns a handler that accepts telemetry
// reports from NetWatch clients.
func (s *Handlers) HandleReport() http.HandlerFunc {
type response struct {
Status string `json:"status"`
}
return func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(
w, r.Body, maxReportBodyBytes,
)
var rpt report
err := json.NewDecoder(r.Body).Decode(&rpt)
if err != nil {
s.log.Error("failed to decode report",
"error", err,
)
s.respondJSON(w, r,
&response{Status: "error"},
http.StatusBadRequest,
)
return
}
totalSamples := 0
for _, h := range rpt.Hosts {
totalSamples += len(h.History)
}
s.log.Info("report received",
"client_id", rpt.ClientID,
"timestamp", rpt.Timestamp,
"host_count", len(rpt.Hosts),
"total_samples", totalSamples,
"geo", string(rpt.Geo),
)
bufErr := s.buf.Append(rpt)
if bufErr != nil {
s.log.Error("failed to buffer report",
"error", bufErr,
)
}
s.respondJSON(w, r,
&response{Status: "ok"},
http.StatusOK,
)
}
}

View File

@@ -0,0 +1,82 @@
// Package healthcheck provides a service that reports
// application health, uptime, and version information.
package healthcheck
import (
"context"
"log/slog"
"time"
"sneak.berlin/go/netwatch/internal/config"
"sneak.berlin/go/netwatch/internal/globals"
"sneak.berlin/go/netwatch/internal/logger"
"go.uber.org/fx"
)
// Params defines the dependencies for Healthcheck.
type Params struct {
fx.In
Config *config.Config
Globals *globals.Globals
Logger *logger.Logger
}
// Healthcheck tracks startup time and builds health responses.
type Healthcheck struct {
StartupTime time.Time
log *slog.Logger
params *Params
}
// Response is the JSON payload returned by the health check
// endpoint.
type Response struct {
Appname string `json:"appname"`
Now string `json:"now"`
Status string `json:"status"`
UptimeHuman string `json:"uptimeHuman"`
UptimeSeconds int64 `json:"uptimeSeconds"`
Version string `json:"version"`
}
// New creates a Healthcheck, recording startup time via an
// fx lifecycle hook.
func New(
lc fx.Lifecycle,
params Params,
) (*Healthcheck, error) {
s := new(Healthcheck)
s.params = &params
s.log = params.Logger.Get()
lc.Append(fx.Hook{
OnStart: func(_ context.Context) error {
s.StartupTime = time.Now().UTC()
return nil
},
OnStop: func(_ context.Context) error {
return nil
},
})
return s, nil
}
// Check returns the current health status of the application.
func (s *Healthcheck) Check() *Response {
return &Response{
Appname: s.params.Globals.Appname,
Now: time.Now().UTC().Format(time.RFC3339Nano),
Status: "ok",
UptimeHuman: s.uptime().String(),
UptimeSeconds: int64(s.uptime().Seconds()),
Version: s.params.Globals.Version,
}
}
func (s *Healthcheck) uptime() time.Duration {
return time.Since(s.StartupTime)
}

View File

@@ -0,0 +1,85 @@
// Package logger provides a configured slog.Logger with TTY
// detection for development vs production output.
package logger
import (
"log/slog"
"os"
"sneak.berlin/go/netwatch/internal/globals"
"go.uber.org/fx"
)
// Params defines the dependencies for Logger.
type Params struct {
fx.In
Globals *globals.Globals
}
// Logger wraps slog.Logger with dynamic level control.
type Logger struct {
log *slog.Logger
level *slog.LevelVar
params Params
}
// New creates a Logger with TTY-aware output formatting.
func New(_ fx.Lifecycle, params Params) (*Logger, error) {
l := new(Logger)
l.level = new(slog.LevelVar)
l.level.Set(slog.LevelInfo)
l.params = params
tty := false
if fileInfo, _ := os.Stdout.Stat(); fileInfo != nil {
if (fileInfo.Mode() & os.ModeCharDevice) != 0 {
tty = true
}
}
var handler slog.Handler
if tty {
handler = slog.NewTextHandler(
os.Stdout,
&slog.HandlerOptions{
Level: l.level,
AddSource: true,
},
)
} else {
handler = slog.NewJSONHandler(
os.Stdout,
&slog.HandlerOptions{
Level: l.level,
AddSource: true,
},
)
}
l.log = slog.New(handler)
return l, nil
}
// EnableDebugLogging sets the log level to debug.
func (l *Logger) EnableDebugLogging() {
l.level.Set(slog.LevelDebug)
l.log.Debug("debug logging enabled", "debug", true)
}
// Get returns the underlying slog.Logger.
func (l *Logger) Get() *slog.Logger {
return l.log
}
// Identify logs the application's build-time metadata.
func (l *Logger) Identify() {
l.log.Info("starting",
"appname", l.params.Globals.Appname,
"version", l.params.Globals.Version,
"buildarch", l.params.Globals.Buildarch,
)
}

View File

@@ -0,0 +1,129 @@
// Package middleware provides HTTP middleware for logging,
// CORS, and other cross-cutting concerns.
package middleware
import (
"log/slog"
"net"
"net/http"
"time"
"sneak.berlin/go/netwatch/internal/config"
"sneak.berlin/go/netwatch/internal/globals"
"sneak.berlin/go/netwatch/internal/logger"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"go.uber.org/fx"
)
const corsMaxAgeSec = 300
// Params defines the dependencies for Middleware.
type Params struct {
fx.In
Config *config.Config
Globals *globals.Globals
Logger *logger.Logger
}
// Middleware holds shared state for middleware factories.
type Middleware struct {
log *slog.Logger
params *Params
}
// New creates a Middleware instance.
func New(
_ fx.Lifecycle,
params Params,
) (*Middleware, error) {
s := new(Middleware)
s.params = &params
s.log = params.Logger.Get()
return s, nil
}
type loggingResponseWriter struct {
http.ResponseWriter
statusCode int
}
func newLoggingResponseWriter(
w http.ResponseWriter,
) *loggingResponseWriter {
return &loggingResponseWriter{w, http.StatusOK}
}
func (lrw *loggingResponseWriter) WriteHeader(code int) {
lrw.statusCode = code
lrw.ResponseWriter.WriteHeader(code)
}
func ipFromHostPort(hostPort string) string {
host, _, err := net.SplitHostPort(hostPort)
if err != nil {
return hostPort
}
return host
}
// Logging returns middleware that logs each request with
// timing, status code, and client information.
func (s *Middleware) Logging() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
start := time.Now().UTC()
lrw := newLoggingResponseWriter(w)
ctx := r.Context()
defer func() {
latency := time.Since(start)
s.log.InfoContext(ctx, "request",
"request_start", start,
"method", r.Method,
"url", r.URL.String(),
"useragent", r.UserAgent(),
"request_id",
ctx.Value(
middleware.RequestIDKey,
),
"referer", r.Referer(),
"proto", r.Proto,
"remote_ip",
ipFromHostPort(r.RemoteAddr),
"status", lrw.statusCode,
"latency_ms",
latency.Milliseconds(),
)
}()
next.ServeHTTP(lrw, r)
},
)
}
}
// CORS returns middleware that adds permissive CORS headers.
func (s *Middleware) CORS() func(http.Handler) http.Handler {
return cors.Handler(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{
"GET", "POST", "PUT", "DELETE", "OPTIONS",
},
AllowedHeaders: []string{
"Accept",
"Authorization",
"Content-Type",
"X-CSRF-Token",
},
ExposedHeaders: []string{"Link"},
AllowCredentials: false,
MaxAge: corsMaxAgeSec,
})
}

View File

@@ -0,0 +1,199 @@
// Package reportbuf accumulates telemetry reports in memory
// and periodically flushes them to zstd-compressed JSONL files.
package reportbuf
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io/fs"
"log/slog"
"os"
"path/filepath"
"sync"
"time"
"sneak.berlin/go/netwatch/internal/config"
"sneak.berlin/go/netwatch/internal/logger"
"github.com/klauspost/compress/zstd"
"go.uber.org/fx"
)
const (
flushSizeThreshold = 10 << 20 // 10 MiB
flushInterval = 1 * time.Minute
defaultDataDir = "./data/reports"
dirPerms fs.FileMode = 0o750
filePerms fs.FileMode = 0o640
)
// Params defines the dependencies for Buffer.
type Params struct {
fx.In
Config *config.Config
Logger *logger.Logger
}
// Buffer accumulates JSON lines in memory and flushes them
// to zstd-compressed files on disk.
type Buffer struct {
buf bytes.Buffer
dataDir string
done chan struct{}
log *slog.Logger
mu sync.Mutex
}
// New creates a Buffer and registers lifecycle hooks to
// manage the data directory and flush goroutine.
func New(
lc fx.Lifecycle,
params Params,
) (*Buffer, error) {
dir := params.Config.DataDir
if dir == "" {
dir = defaultDataDir
}
b := &Buffer{
dataDir: dir,
done: make(chan struct{}),
log: params.Logger.Get(),
}
lc.Append(fx.Hook{
OnStart: func(_ context.Context) error {
err := os.MkdirAll(b.dataDir, dirPerms)
if err != nil {
return fmt.Errorf("create data dir: %w", err)
}
go b.flushLoop()
return nil
},
OnStop: func(_ context.Context) error {
close(b.done)
b.flushLocked()
return nil
},
})
return b, nil
}
// Append marshals v as a single JSON line and appends it to
// the buffer. If the buffer reaches the size threshold, it is
// drained and written to disk asynchronously.
func (b *Buffer) Append(v any) error {
line, err := json.Marshal(v)
if err != nil {
return fmt.Errorf("marshal report: %w", err)
}
b.mu.Lock()
b.buf.Write(line)
b.buf.WriteByte('\n')
if b.buf.Len() >= flushSizeThreshold {
data := b.drainBuf()
b.mu.Unlock()
go b.writeFile(data)
return nil
}
b.mu.Unlock()
return nil
}
// flushLoop runs a ticker that periodically flushes buffered
// data to disk until the done channel is closed.
func (b *Buffer) flushLoop() {
ticker := time.NewTicker(flushInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
b.flushLocked()
case <-b.done:
return
}
}
}
// flushLocked acquires the lock, drains the buffer, and
// writes the data to a compressed file.
func (b *Buffer) flushLocked() {
b.mu.Lock()
if b.buf.Len() == 0 {
b.mu.Unlock()
return
}
data := b.drainBuf()
b.mu.Unlock()
b.writeFile(data)
}
// drainBuf copies the buffer contents and resets it.
// The caller must hold b.mu.
func (b *Buffer) drainBuf() []byte {
data := make([]byte, b.buf.Len())
copy(data, b.buf.Bytes())
b.buf.Reset()
return data
}
// writeFile creates a timestamped zstd-compressed JSONL file
// in the data directory.
func (b *Buffer) writeFile(data []byte) {
ts := time.Now().UTC().Format("2006-01-02T15-04-05.000Z")
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,
os.O_WRONLY|os.O_CREATE|os.O_EXCL,
filePerms,
)
if err != nil {
b.log.Error("create report file", "error", err)
return
}
defer func() { _ = f.Close() }()
enc, err := zstd.NewWriter(f)
if err != nil {
b.log.Error("create zstd encoder", "error", err)
return
}
_, writeErr := enc.Write(data)
if writeErr != nil {
b.log.Error("write compressed data", "error", writeErr)
_ = enc.Close()
return
}
closeErr := enc.Close()
if closeErr != nil {
b.log.Error("close zstd encoder", "error", closeErr)
}
}

View File

@@ -0,0 +1,13 @@
package reportbuf_test
import (
"testing"
_ "sneak.berlin/go/netwatch/internal/reportbuf"
)
func TestImport(t *testing.T) {
t.Parallel()
// Compilation check — verifies the package parses
// and all imports resolve.
}

View File

@@ -0,0 +1,43 @@
package server
import (
"errors"
"fmt"
"net/http"
"time"
)
const (
readTimeout = 10 * time.Second
writeTimeout = 10 * time.Second
maxHeaderBytes = 1 << 20 // 1 MiB
)
func (s *Server) serveUntilShutdown() {
listenAddr := fmt.Sprintf(":%d", s.params.Config.Port)
s.httpServer = &http.Server{
Addr: listenAddr,
Handler: s,
MaxHeaderBytes: maxHeaderBytes,
ReadTimeout: readTimeout,
WriteTimeout: writeTimeout,
}
s.SetupRoutes()
s.log.Info("http begin listen",
"listenaddr", listenAddr,
"version", s.params.Globals.Version,
"buildarch", s.params.Globals.Buildarch,
)
err := s.httpServer.ListenAndServe()
if err != nil && !errors.Is(err, http.ErrServerClosed) {
s.log.Error("listen error", "error", err)
if s.cancelFunc != nil {
s.cancelFunc()
}
}
}

View File

@@ -0,0 +1,31 @@
package server
import (
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
const requestTimeout = 60 * time.Second
// SetupRoutes configures the chi router with middleware and
// all application routes.
func (s *Server) SetupRoutes() {
s.router = chi.NewRouter()
s.router.Use(middleware.Recoverer)
s.router.Use(middleware.RequestID)
s.router.Use(s.mw.Logging())
s.router.Use(s.mw.CORS())
s.router.Use(middleware.Timeout(requestTimeout))
s.router.Get(
"/.well-known/healthcheck",
s.h.HandleHealthCheck(),
)
s.router.Route("/api/v1", func(r chi.Router) {
r.Post("/reports", s.h.HandleReport())
})
}

View File

@@ -0,0 +1,147 @@
// Package server provides the HTTP server lifecycle,
// including startup, routing, signal handling, and graceful
// shutdown.
package server
import (
"context"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"sneak.berlin/go/netwatch/internal/config"
"sneak.berlin/go/netwatch/internal/globals"
"sneak.berlin/go/netwatch/internal/handlers"
"sneak.berlin/go/netwatch/internal/logger"
"sneak.berlin/go/netwatch/internal/middleware"
"github.com/go-chi/chi/v5"
"go.uber.org/fx"
)
// Params defines the dependencies for Server.
type Params struct {
fx.In
Config *config.Config
Globals *globals.Globals
Handlers *handlers.Handlers
Logger *logger.Logger
Middleware *middleware.Middleware
}
// Server is the top-level HTTP server orchestrator.
type Server struct {
cancelFunc context.CancelFunc
exitCode int
h *handlers.Handlers
httpServer *http.Server
log *slog.Logger
mw *middleware.Middleware
params Params
router *chi.Mux
startupTime time.Time
}
// New creates a Server and registers lifecycle hooks for
// starting and stopping it.
func New(
lc fx.Lifecycle,
params Params,
) (*Server, error) {
s := new(Server)
s.params = params
s.mw = params.Middleware
s.h = params.Handlers
s.log = params.Logger.Get()
lc.Append(fx.Hook{
OnStart: func(_ context.Context) error {
s.startupTime = time.Now().UTC()
go func() { //nolint:contextcheck // fx OnStart ctx is startup-only; run() creates its own
s.run()
}()
return nil
},
OnStop: func(_ context.Context) error {
if s.cancelFunc != nil {
s.cancelFunc()
}
return nil
},
})
return s, nil
}
// ServeHTTP delegates to the chi router.
func (s *Server) ServeHTTP(
w http.ResponseWriter,
r *http.Request,
) {
s.router.ServeHTTP(w, r)
}
func (s *Server) run() {
exitCode := s.serve()
os.Exit(exitCode)
}
func (s *Server) serve() int {
var ctx context.Context //nolint:wsl // ctx must be declared before multi-assign
ctx, s.cancelFunc = context.WithCancel(
context.Background(),
)
go func() {
c := make(chan os.Signal, 1)
signal.Ignore(syscall.SIGPIPE)
signal.Notify(c, os.Interrupt, syscall.SIGTERM)
sig := <-c
s.log.Info("signal received", "signal", sig)
if s.cancelFunc != nil {
s.cancelFunc()
}
}()
go func() {
s.serveUntilShutdown()
}()
<-ctx.Done()
s.cleanShutdown()
return s.exitCode
}
const shutdownTimeout = 5 * time.Second
func (s *Server) cleanShutdown() {
s.exitCode = 0
ctxShutdown, shutdownCancel := context.WithTimeout(
context.Background(),
shutdownTimeout,
)
defer shutdownCancel()
err := s.httpServer.Shutdown(ctxShutdown)
if err != nil {
s.log.Error(
"server clean shutdown failed",
"error", err,
)
}
s.log.Info("server stopped")
}

28
nginx.conf Normal file
View File

@@ -0,0 +1,28 @@
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Trust RFC1918 reverse proxies for X-Forwarded-For
set_real_ip_from 10.0.0.0/8;
set_real_ip_from 172.16.0.0/12;
set_real_ip_from 192.168.0.0/16;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
# Access log to stdout (Docker best practice)
access_log /dev/stdout combined;
error_log /dev/stderr warn;
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets aggressively
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

View File

@@ -14,6 +14,7 @@
"autoprefixer": "^10.4.23", "autoprefixer": "^10.4.23",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"puppeteer-core": "25.5.0",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"vite": "^7.3.1" "vite": "^7.3.1"
} }

143
script/bootstrap Executable file
View File

@@ -0,0 +1,143 @@
#!/bin/sh
# script/bootstrap: install all dependencies needed to build and develop
# this repo. Idempotent: every install is guarded by a check so already
# installed tools are skipped. Base tooling comes from nix, apt, brew,
# or apk (detected in that order); assumes nothing is present. Node is
# used directly if installed; otherwise it is installed at a pinned
# version via nvm (installing nvm itself first, from a hash-verified
# release archive, never curl | sh).
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Pinned versions, 2026-07-07
NODE_VERSION="22.17.0"
NVM_VERSION="0.40.3"
# sha256 of https://github.com/nvm-sh/nvm/archive/refs/tags/v0.40.3.tar.gz
NVM_SHA256="5f4d6aaa04a177dc93c985e31dbc411ab6b8c6e1e21d8015dbc1372625fcd1d0"
YARN_VERSION="1.22.22"
PKGMGR=""
SUDO=""
APT_UPDATED=""
detect_pkgmgr() {
[ -n "$PKGMGR" ] && return 0
if command -v nix-env >/dev/null 2>&1; then
PKGMGR="nix"
elif command -v apt-get >/dev/null 2>&1; then
PKGMGR="apt"
elif command -v brew >/dev/null 2>&1; then
PKGMGR="brew"
elif command -v apk >/dev/null 2>&1; then
PKGMGR="apk"
else
echo "bootstrap: no supported package manager (nix, apt, brew, apk)" >&2
exit 1
fi
if [ "$PKGMGR" = "apt" ]; then
export DEBIAN_FRONTEND=noninteractive
if [ "$(id -u)" != "0" ]; then
SUDO="sudo"
fi
fi
}
# pkg_install <nix-attr> <apt-pkg> <brew-formula> <apk-pkg>
pkg_install() {
detect_pkgmgr
case "$PKGMGR" in
nix) nix-env -iA "nixpkgs.$1" ;;
apt)
if [ -z "$APT_UPDATED" ]; then
$SUDO env DEBIAN_FRONTEND=noninteractive apt-get update
APT_UPDATED=1
fi
$SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$2"
;;
brew) brew install "$3" ;;
apk) apk add --no-cache "$4" ;;
esac
}
missing() {
! command -v "$1" >/dev/null 2>&1
}
# verify_sha256 <file> <expected-hash>
verify_sha256() {
if command -v sha256sum >/dev/null 2>&1; then
actual="$(sha256sum "$1" | cut -d' ' -f1)"
else
actual="$(shasum -a 256 "$1" | cut -d' ' -f1)"
fi
if [ "$actual" != "$2" ]; then
echo "bootstrap: sha256 mismatch for $1" >&2
echo " expected: $2" >&2
echo " actual: $actual" >&2
exit 1
fi
}
# nvm is a bash script; run a command in a bash with nvm loaded
nvm_sh() {
bash -c ". \"\$HOME/.nvm/nvm.sh\" && $*"
}
ensure_nvm() {
[ -s "$HOME/.nvm/nvm.sh" ] && return 0
# nvm prerequisites; nvm itself requires bash
if missing bash; then pkg_install bash bash bash bash; fi
if missing curl; then pkg_install curl curl curl curl; fi
if missing git; then pkg_install git git git git; fi
tmp="$(mktemp -d)"
curl -fsSL -o "$tmp/nvm.tar.gz" \
"https://github.com/nvm-sh/nvm/archive/refs/tags/v${NVM_VERSION}.tar.gz"
verify_sha256 "$tmp/nvm.tar.gz" "$NVM_SHA256"
mkdir -p "$HOME/.nvm"
tar -xzf "$tmp/nvm.tar.gz" -C "$HOME/.nvm" --strip-components=1
rm -rf "$tmp"
}
ensure_node() {
if ! missing node; then return 0; fi
ensure_nvm
nvm_sh "nvm install $NODE_VERSION"
}
ensure_yarn() {
if ! missing yarn; then return 0; fi
if ! missing corepack; then
corepack enable
corepack prepare "yarn@$YARN_VERSION" --activate
elif [ -s "$HOME/.nvm/nvm.sh" ]; then
nvm_sh "nvm use $NODE_VERSION >/dev/null && corepack enable && \
corepack prepare yarn@$YARN_VERSION --activate"
else
npm install -g "yarn@$YARN_VERSION"
fi
}
install_js_deps() {
if missing yarn && [ -s "$HOME/.nvm/nvm.sh" ]; then
nvm_sh "nvm use $NODE_VERSION >/dev/null && cd \"$ROOT\" && \
yarn install --frozen-lockfile"
else
yarn install --frozen-lockfile
fi
}
main() {
cd "$ROOT"
if missing make; then pkg_install gnumake make make make; fi
if missing git; then pkg_install git git git git; fi
ensure_node
ensure_yarn
install_js_deps
echo "bootstrap complete"
}
main "$@"

14
script/check Executable file
View File

@@ -0,0 +1,14 @@
#!/bin/sh
# script/check: run all checks (test, lint, fmt-check). Our own
# extension to scripts-to-rule-them-all. Must not modify any files.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() {
"$SCRIPT_DIR/test"
"$SCRIPT_DIR/lint"
"$SCRIPT_DIR/fmt-check"
}
main "$@"

13
script/cibuild Executable file
View File

@@ -0,0 +1,13 @@
#!/bin/sh
# script/cibuild: run the CI build. The Dockerfile runs make check, so
# a successful build implies all checks pass.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
docker build .
}
main "$@"

14
script/docker Executable file
View File

@@ -0,0 +1,14 @@
#!/bin/sh
# script/docker: build the Docker image tagged with the project name.
# The tag comes from script/projectname.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
timeout 300 docker build -t "$("$SCRIPT_DIR/projectname")" .
}
main "$@"

12
script/fmt Executable file
View File

@@ -0,0 +1,12 @@
#!/bin/sh
# script/fmt: format all files (writes).
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
yarn prettier --write .
}
main "$@"

13
script/fmt-check Executable file
View File

@@ -0,0 +1,13 @@
#!/bin/sh
# script/fmt-check: check formatting (read-only). Same scope as
# script/fmt, but fails instead of writing.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
yarn prettier --check .
}
main "$@"

102
script/frontend-viewport-test Executable file
View File

@@ -0,0 +1,102 @@
#!/bin/sh
# script/frontend-viewport-test: verify the responsive layout of the built
# frontend in a real browser engine.
#
# Builds dist/, serves it with the same nginx image and the same nginx.conf
# the shipping container uses, points a containerised headless Chrome at it
# over CDP, and asserts on computed layout at every viewport width derived
# from the app's own CSS. See test/viewport/README.md for what this covers
# and what it cannot.
#
# Deliberately not part of script/check: it needs Docker and takes far
# longer than the 20s budget make test has to stay inside.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# chromedp/headless-shell 151.0.7922.109, 2026-08-09
BROWSER_IMAGE="chromedp/headless-shell@sha256:2d349b544a1ea6b5b5fd7c0fe99215ff662339c57407ee2e8c0a11af93516b04"
# nginx:stable-alpine, 2026-02-22 (the digest Dockerfile ships)
SERVER_IMAGE="nginx@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab"
# node:22-alpine, 2026-02-22 (the digest Dockerfile builds with)
NODE_IMAGE="node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34"
RUN_ID="$$-$(date +%s)"
NETWORK="netwatch-viewport-$RUN_ID"
SERVER="netwatch-viewport-server-$RUN_ID"
BROWSER="netwatch-viewport-browser-$RUN_ID"
HARNESS="netwatch-viewport-harness-$RUN_ID"
ARTIFACT_DIR="$ROOT/tmp/viewport"
# Every container is named and removed here, including the harness itself:
# `timeout` below kills the `docker run` client, not the container it
# started, and an unnamed survivor keeps the --internal network in use so
# `docker network rm` fails too. This host runs many sessions at once and
# neither may be left behind.
cleanup() {
docker rm -f "$HARNESS" > /dev/null 2>&1 || true
docker rm -f "$BROWSER" > /dev/null 2>&1 || true
docker rm -f "$SERVER" > /dev/null 2>&1 || true
docker network rm "$NETWORK" > /dev/null 2>&1 || true
}
trap cleanup EXIT INT TERM
main() {
cd "$ROOT"
# Test what ships: the production build, not a dev server.
"$ROOT/script/test"
if [ ! -f "$ROOT/dist/index.html" ]; then
echo "frontend-viewport-test: dist/index.html missing after build" >&2
exit 1
fi
mkdir -p "$ARTIFACT_DIR"
# An --internal network has no route off the host, so the browser
# cannot reach the real internet no matter what the page asks for.
# Latency probes are answered by the harness instead. This also means
# no port can be published from it, which is why the harness itself
# runs as a third container on the same network rather than on the
# host.
docker network create --internal "$NETWORK" > /dev/null
docker run -d --rm --name "$SERVER" \
--network "$NETWORK" --network-alias netwatch \
-v "$ROOT/dist:/usr/share/nginx/html:ro" \
-v "$ROOT/nginx.conf:/etc/nginx/conf.d/default.conf:ro" \
"$SERVER_IMAGE" > /dev/null
# The image's own entrypoint already exposes CDP on 9222 and passes
# --no-sandbox, so only extra flags belong here; re-specifying the
# debugging port collides with it and leaves the endpoint bound to
# loopback only. --hide-scrollbars keeps innerWidth equal to
# clientWidth, so the overflow assertion has no scrollbar-sized slack
# to hide behind, and matches the overlay scrollbars phones use.
docker run -d --rm --name "$BROWSER" --init --shm-size=1g \
--network "$NETWORK" \
"$BROWSER_IMAGE" \
--hide-scrollbars \
> /dev/null
# Chrome refuses DevTools requests whose Host header is neither
# localhost nor an IP address, so dial the container by address rather
# than by its network alias.
browser_ip="$(docker inspect \
-f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' \
"$BROWSER")"
timeout 900 docker run --rm --init --name "$HARNESS" \
--network "$NETWORK" \
--user "$(id -u):$(id -g)" \
-v "$ROOT:/app" \
-w /app \
-e NETWATCH_ROOT=/app \
-e NETWATCH_BASE_URL=http://netwatch:8080 \
-e "NETWATCH_CDP_URL=http://$browser_ip:9222" \
-e NETWATCH_ARTIFACT_DIR=/app/tmp/viewport \
"$NODE_IMAGE" \
node test/viewport/harness.js
}
main "$@"

16
script/install-precommit Executable file
View File

@@ -0,0 +1,16 @@
#!/bin/sh
# script/install-precommit: install the git pre-commit hook that runs
# script/precommit. Our own extension to scripts-to-rule-them-all.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
hook=".git/hooks/pre-commit"
printf '#!/bin/sh\nset -e\nscript/precommit\n' > "$hook"
chmod +x "$hook"
echo "pre-commit hook installed: runs script/precommit"
}
main "$@"

12
script/lint Executable file
View File

@@ -0,0 +1,12 @@
#!/bin/sh
# script/lint: run the linter (prettier in check mode).
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
yarn prettier --check .
}
main "$@"

12
script/precommit Executable file
View File

@@ -0,0 +1,12 @@
#!/bin/sh
# script/precommit: run by the git pre-commit hook; fails the commit if
# checks fail. Our own extension to scripts-to-rule-them-all.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() {
"$SCRIPT_DIR/check"
}
main "$@"

12
script/projectname Executable file
View File

@@ -0,0 +1,12 @@
#!/bin/sh
# script/projectname: output the name of this project. Our own
# extension to scripts-to-rule-them-all. Other scripts that need the
# name (e.g. script/docker) call this, so they can stay identical
# across all repos.
set -eu
main() {
echo "netwatch"
}
main "$@"

13
script/setup Executable file
View File

@@ -0,0 +1,13 @@
#!/bin/sh
# script/setup: set up the repo for development after a fresh clone:
# installs dependencies and the git pre-commit hook.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
main() {
"$SCRIPT_DIR/bootstrap"
"$SCRIPT_DIR/install-precommit"
}
main "$@"

13
script/test Executable file
View File

@@ -0,0 +1,13 @@
#!/bin/sh
# script/test: run the test suite. This repo has no unit tests; the
# production build serves as the test (fails on broken code).
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() {
cd "$ROOT"
timeout 30 yarn build
}
main "$@"

File diff suppressed because it is too large Load Diff

View File

@@ -21,3 +21,96 @@ body {
rgba(255, 255, 255, 0) 100% rgba(255, 255, 255, 0) 100%
); );
} }
/* ---- Mobile responsive layout (portrait / narrow viewports) ---- */
@media (max-width: 768px) {
/* Header: stack title and controls vertically */
header .flex.items-center.justify-between {
flex-direction: column;
align-items: flex-start !important;
gap: 1rem;
}
header .flex.flex-col.items-end {
align-items: flex-start !important;
flex-direction: row;
flex-wrap: wrap;
gap: 0.75rem;
}
/* Pause button: smaller on mobile */
#pause-btn {
padding: 0.5rem 1rem;
}
#pause-btn svg {
width: 1.25rem;
height: 1.25rem;
}
#pause-text {
font-size: 0.875rem;
}
/* Summary box: wrap into a grid for readability */
#summary {
display: flex;
flex-wrap: wrap;
gap: 0.25rem 0.5rem;
justify-content: center;
line-height: 1.6;
}
/* Hide the pipe separators on mobile */
#summary .text-gray-600.mx-3 {
display: none;
}
/* Host row: stack vertically */
.host-row .flex.items-center.gap-4 {
flex-direction: column;
align-items: stretch !important;
gap: 0.5rem;
}
/* Info section: full width, remove fixed width */
.host-row .w-\[420px\] {
width: 100% !important;
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
}
/* Host name row with dot */
.host-row .flex.items-center.gap-2.min-w-\[200px\] {
min-width: 0;
}
/* Latency value: slightly smaller on mobile */
.host-row .latency-value {
font-size: 1.875rem;
line-height: 2.25rem;
}
/* Sparkline: full width below the info */
.host-row .sparkline-container {
width: 100%;
flex-shrink: 0;
}
/* Pin button: inline with the host info */
.host-row .pin-btn {
position: absolute;
right: 0.5rem;
top: 0.5rem;
}
.host-row {
position: relative;
}
/* Footer legend: wrap nicely */
footer p {
line-height: 1.8;
}
}

112
test/viewport/README.md Normal file
View File

@@ -0,0 +1,112 @@
# Responsive-layout harness
Automated verification of the responsive layout that landed in #5. Run it with:
```bash
make frontend-viewport-test
```
It builds `dist/`, serves it from the same digest-pinned `nginx` image and the
same `nginx.conf` the shipping container uses, drives a digest-pinned headless
Chrome against it over CDP, and asserts on computed layout at every viewport
width derived from the app's own CSS. Screenshots land in `tmp/viewport/`
alongside a `results.json`; they are artifacts for a human to look at when
something fails, not the evidence. The assertions are the evidence.
The target is deliberately outside `make check`: it needs Docker and takes
minutes, and `make test` has to stay under 20 seconds.
## How the widths are chosen
Not from a list of phone models. `viewports.js` parses the `@media` conditions
out of `src/styles.css` and scans `src/main.js` and `index.html` for Tailwind
responsive prefixes, then tests every breakpoint it finds at one pixel below it,
exactly on it, and one pixel above it. A generic 375px "phone" test sails
straight past an off-by-one at a media query boundary; `max-width: 768px`
matches _at_ 768, and the sweep pins down which side of that line each layout is
on.
Nothing hardcodes 768. Add a second media block or start using `md:` classes and
the new breakpoint is covered without this directory being touched. The app is
desktop-first today (all narrow rules live in `max-width` blocks); a
`min-width`-only, mobile-first set is handled as its inverse, and a set that
mixes the two makes the run fail loudly rather than test the right widths with
the wrong expectation. Four further viewports are fixed anchors, each with a
stated reason: a 320px floor, a 1280px desktop baseline, and two phone-landscape
sizes straddling the breakpoint for the rotation case.
## What it asserts
- **app-rendered** — enough host rows exist and enough of them show a numeric
latency. This one exists so the rest cannot pass vacuously against a blank
page.
- **no-horizontal-overflow** — `documentElement.scrollWidth` fits the layout
viewport, with the widest offending element named.
- **nothing-past-viewport-edge** — no visible element's box extends past the
viewport edge.
- **no-clipped-text** — nothing hides text behind `overflow: hidden`. Deliberate
ellipsis truncation (Tailwind's `truncate`, used on host names and URLs) is
excluded: it is a design choice, not breakage.
- **tap-targets-44px** — every interactive control is at least 44x44 CSS px on
touch viewports, _and_ each selector in the control list matched at least the
number of visible elements it declares. The second half is what stops the
check passing vacuously: with size alone, a renamed class would take its
controls out of the measured set and the check would report "all 0 controls
are at least 44x44" and pass. See below.
- **host-rows-stacked / host-rows-side-by-side** — the rows genuinely reflow.
Computed `flex-direction` _and_ the actual geometry are checked, and in the
narrow layout the info block and the sparkline must each occupy essentially
the full row width. A row that merely shrank its 420px column would fail.
- **probing-still-runs / gateway-detection-still-runs** — narrow viewports keep
probing and keep detecting the gateway. The mobile early-return path proposed
in #8 was rejected; this is what would catch it coming back.
### The tap-target threshold
44x44 CSS px. That is the figure in Apple's Human Interface Guidelines and in
WCAG 2.2 SC 2.5.5 "Target Size (Enhanced)". WCAG 2.2 SC 2.5.8 (level AA) sets a
lower 24x24 floor, but that floor comes with a spacing exception these controls
do not qualify for — the pin buttons sit directly against the host name they
belong to.
## Determinism
The browser container runs on an `--internal` docker network and has no route to
the internet, so the app's latency probes cannot reach anything real. The
harness answers them itself from a fixed delay table, with a deterministic
fraction failed outright, so the rows render a realistic spread of one-, two-
and three-digit latencies plus some unreachable rows. That spread is what the
layout has to survive; 24 identical `---` placeholders would not exercise it.
## What this cannot verify
Real limits, so nobody re-parks this issue as needing hardware:
- **Non-Chromium engines.** This is Chrome. iOS Safari is WebKit and cannot be
emulated by it; Safari-specific bugs (viewport units under a collapsing URL
bar, `-webkit-fill-available`, form control metrics) will not show up here.
- **Real touch input.** `hasTouch` emulation changes what the page is told, not
how a finger behaves. Gesture handling, scroll momentum, double-tap zoom and
hover-state fallbacks on touch are out of scope.
- **Physical pixel density and rendering.** `deviceScaleFactor` is set, but
subpixel antialiasing, OLED colour rendering and actual legibility at a given
physical size are not measurable here.
- **Fonts.** The container has DejaVu, not the platform's own UI monospace. Text
metrics are therefore close to, but not identical to, a real device — a layout
that fits here by a few pixels might not there.
- **On-device performance.** Canvas sparkline redraw cost, battery, and
behaviour on a slow radio are not measured.
- **Browser chrome.** The address bar, safe-area insets and notch cutouts are
not simulated.
Everything else this issue was actually about — does the layout reflow, does
anything overflow, is content clipped, are the controls big enough — is a
function of viewport width and CSS, and is covered above.
## Relation to the unit test framework (#21)
Complementary layers, not two stacks. `vitest` (#21) will exercise module-level
logic in-process with no browser. This harness exercises rendered layout in a
real engine and is the only thing here that can see a media query. Neither
replaces the other; assertions about computed styles and element geometry belong
here, assertions about functions belong in `vitest`.

245
test/viewport/checks.js Normal file
View File

@@ -0,0 +1,245 @@
// Pass/fail decisions for the responsive-layout harness.
//
// Kept in node rather than in the page so that a failure can be reported
// with the measurements that produced it. Every check runs at every
// viewport; none of them short-circuits, so one failure does not hide the
// rest.
// Minimum tap target, in CSS pixels. 44x44 is the figure in Apple's Human
// Interface Guidelines and in WCAG 2.2 SC 2.5.5 "Target Size (Enhanced)".
// WCAG 2.2 SC 2.5.8 (level AA) sets a lower 24x24 floor, but that floor
// comes with a spacing exception these controls do not qualify for: the
// pin buttons sit directly against the host name they belong to. Held at
// 44 deliberately.
export const MIN_TAP_TARGET_PX = 44;
// The controls named in the definition of done, plus the pause button.
// Each carries the smallest number of *visible* instances the page has to
// contain for the tap-target oracle to be measuring anything at all.
//
// Without those floors the check is inert: `undersized` is empty both when
// every control is large enough and when the selectors have gone stale and
// matched nothing, and the pass condition cannot tell those apart. A single
// combined floor would not be enough either — 26 pin buttons would cover
// for all three singleton controls vanishing at once — so the floor is per
// selector, and one stale selector out of four fails the check.
export const INTERACTIVE_CONTROLS = [
{ selector: "#pause-btn", minCount: 1 },
{ selector: "#interval-select", minCount: 1 },
// One per pinnable host row. `app-rendered` already requires at least
// 10 host rows, so a count below that means the pin buttons stopped
// being rendered per row rather than that there were fewer hosts.
{ selector: ".pin-btn", minCount: 10 },
{ selector: "#debug-toggle", minCount: 1 },
];
export const INTERACTIVE_SELECTORS = INTERACTIVE_CONTROLS.map(
(control) => control.selector,
);
// A host row is only "reflowed" if it stacked *and* went full width.
// A row that merely shrank its 420px info column would keep
// flex-direction: row, and a row that stacked but left the info column at
// its fixed width would fail the width test.
const FULL_WIDTH_FRACTION = 0.9;
function summarise(items, format, limit = 3) {
const shown = items.slice(0, limit).map(format).join("; ");
const rest = items.length > limit ? ` (+${items.length - limit} more)` : "";
return shown + rest;
}
// Collapse an overflow report to the elements actually responsible.
// Identical elements (24 host rows all doing the same thing) are counted
// rather than listed, and the deepest ones come first, since every
// ancestor of an overflowing element also reports as overflowing.
function deepestOffenders(entries) {
const byElement = new Map();
for (const entry of entries) {
const reach = entry.reach ?? entry.right;
const existing = byElement.get(entry.el);
if (existing) {
existing.count += 1;
existing.reach = Math.max(existing.reach, reach);
} else {
byElement.set(entry.el, { ...entry, reach, count: 1 });
}
}
return [...byElement.values()].sort(
(a, b) => b.depth - a.depth || b.reach - a.reach,
);
}
function checkRowLayout(row, expectStacked) {
if (expectStacked) {
if (row.flexDirection !== "column") {
return `row ${row.index}: flex-direction is ${row.flexDirection}, expected column`;
}
if (row.sparkline.top < row.info.bottom - 1) {
return `row ${row.index}: sparkline top ${row.sparkline.top} is above info bottom ${row.info.bottom} — still side by side`;
}
const minWidth = row.containerWidth * FULL_WIDTH_FRACTION;
if (row.info.width < minWidth) {
return `row ${row.index}: info block is ${row.info.width}px of ${row.containerWidth}px — shrunk, not reflowed`;
}
if (row.sparkline.width < minWidth) {
return `row ${row.index}: sparkline is ${row.sparkline.width}px of ${row.containerWidth}px — shrunk, not reflowed`;
}
return null;
}
if (row.flexDirection !== "row") {
return `row ${row.index}: flex-direction is ${row.flexDirection}, expected row`;
}
if (row.sparkline.left < row.info.right - 1) {
return `row ${row.index}: sparkline left ${row.sparkline.left} overlaps info right ${row.info.right} — not side by side`;
}
return null;
}
export function evaluateChecks(facts, viewport, probes) {
const checks = [];
const check = (name, ok, detail) => checks.push({ name, ok, detail });
// Guard against the whole harness passing vacuously because the page
// never rendered. Everything below is only meaningful if this holds.
check(
"app-rendered",
facts.rowCount >= 10 && facts.numericLatencies >= 5,
`${facts.rowCount} host rows, ${facts.numericLatencies} showing a numeric latency`,
);
const viewportWidth = Math.min(facts.innerWidth, facts.documentClientWidth);
const culprits = deepestOffenders([
...facts.overflowing,
...facts.contentOverflowing,
]);
check(
"no-horizontal-overflow",
facts.documentScrollWidth <= viewportWidth,
`documentElement.scrollWidth ${facts.documentScrollWidth} vs viewport ${viewportWidth}` +
(culprits.length === 0
? ""
: "; widest content: " +
summarise(
culprits,
(c) =>
`${c.el} reaches ${Math.round(c.reach)}px${c.count > 1 ? ` (x${c.count})` : ""}`,
)),
);
check(
"nothing-past-viewport-edge",
facts.overflowing.length === 0,
facts.overflowing.length === 0
? "no element extends past the viewport"
: `${facts.overflowing.length} element(s) past the edge: ` +
summarise(
facts.overflowing,
(o) => `${o.el} spans ${o.left}..${o.right}`,
),
);
check(
"no-clipped-text",
facts.clipped.length === 0,
facts.clipped.length === 0
? "no element hides text behind overflow (deliberate ellipsis excluded)"
: `${facts.clipped.length} element(s) clipping text: ` +
summarise(
facts.clipped,
(c) =>
`${c.el} scrollWidth ${c.scrollWidth} > clientWidth ${c.clientWidth}`,
),
);
if (viewport.touch) {
// Presence first: a selector that matches nothing contributes no
// undersized targets, so without this the check would report
// "all 0 controls are at least 44x44" and pass.
const seen = new Map();
for (const target of facts.tapTargets) {
seen.set(target.selector, (seen.get(target.selector) ?? 0) + 1);
}
const missing = INTERACTIVE_CONTROLS.filter(
(control) => (seen.get(control.selector) ?? 0) < control.minCount,
);
const undersized = facts.tapTargets.filter(
(t) => t.width < MIN_TAP_TARGET_PX || t.height < MIN_TAP_TARGET_PX,
);
const bySelector = new Map();
for (const target of undersized) {
const existing = bySelector.get(target.selector);
if (!existing || target.width * target.height < existing.area) {
bySelector.set(target.selector, {
...target,
area: target.width * target.height,
count: (existing?.count ?? 0) + 1,
});
} else {
existing.count += 1;
}
}
const detail = [];
if (missing.length > 0) {
detail.push(
"oracle is not measuring the page: " +
summarise(
missing,
(c) =>
`${c.selector} matched ${seen.get(c.selector) ?? 0} visible element(s), expected at least ${c.minCount}`,
4,
),
);
}
detail.push(
undersized.length === 0
? `${facts.tapTargets.length} controls measured, all at least ${MIN_TAP_TARGET_PX}x${MIN_TAP_TARGET_PX}`
: `${undersized.length} of ${facts.tapTargets.length} controls below ${MIN_TAP_TARGET_PX}x${MIN_TAP_TARGET_PX}: ` +
summarise(
[...bySelector.values()],
(t) =>
`${t.selector} ${t.width}x${t.height}${t.count > 1 ? ` (x${t.count})` : ""}`,
4,
),
);
check(
`tap-targets-${MIN_TAP_TARGET_PX}px`,
missing.length === 0 && undersized.length === 0,
detail.join("; "),
);
}
const badRows = facts.rows
.map((row) => checkRowLayout(row, viewport.expectStacked))
.filter(Boolean);
check(
viewport.expectStacked ? "host-rows-stacked" : "host-rows-side-by-side",
facts.rows.length > 0 && badRows.length === 0,
facts.rows.length === 0
? "no host rows were measured"
: badRows.length === 0
? `all ${facts.rows.length} rows laid out as expected`
: `${badRows.length} of ${facts.rows.length} rows wrong: ` +
summarise(badRows, (r) => r),
);
// The mobile early-return path proposed in #8 was rejected: narrow
// viewports must keep probing and keep detecting the gateway, not
// quietly skip work.
check(
"probing-still-runs",
probes.attempted > 0,
`${probes.attempted} outbound probe requests issued`,
);
check(
"gateway-detection-still-runs",
facts.gatewayDetected,
facts.gatewayDetected
? "Local Gateway row present"
: "no Local Gateway row — gateway detection did not run or did not complete",
);
return checks;
}

184
test/viewport/facts.js Normal file
View File

@@ -0,0 +1,184 @@
// Layout facts collected from inside the page.
//
// This function is serialised and evaluated in the browser, so it must be
// entirely self-contained: no imports, no closures over module scope. It
// only *measures*; every pass/fail decision is made back in node by
// checks.js, so failures can be reported with real numbers attached.
export function collectLayoutFacts(options) {
const describe = (el) => {
const id = el.id ? "#" + el.id : "";
const classes =
typeof el.className === "string" && el.className.trim()
? "." + el.className.trim().split(/\s+/).slice(0, 3).join(".")
: "";
return el.tagName.toLowerCase() + id + classes;
};
const round = (n) => Math.round(n * 10) / 10;
// Overflow propagates up every ancestor, so a single wide element
// reports as body, #app, the row, and so on. Depth lets the report
// name the deepest — that is, the actual — offender.
const depthOf = (el) => {
let depth = 0;
for (let node = el.parentElement; node; node = node.parentElement) {
depth++;
}
return depth;
};
const isVisible = (el) => {
const style = getComputedStyle(el);
if (style.display === "none") return false;
if (style.visibility === "hidden") return false;
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
};
const innerWidth = window.innerWidth;
const clientWidth = document.documentElement.clientWidth;
// Under mobile emulation Chrome lets window.innerWidth *grow* to the
// width of overflowing content, exactly as a phone zooms out to fit a
// too-wide page. Measuring against it would therefore hide the
// overflow it is supposed to expose: at a 320px device width a page
// that spills to 350 reports innerWidth 350 and looks clean. Every
// comparison below is against the layout viewport instead.
const viewportWidth = Math.min(innerWidth, clientWidth);
const elements = Array.from(document.querySelectorAll("body *"));
// Elements sticking out past the right (or left) edge of the viewport.
// The document-level scrollWidth check says *that* the page overflows;
// this says *what* is doing it.
const overflowing = [];
// Elements clipping their own text. Deliberate ellipsis truncation
// (Tailwind's `truncate`) is opt-in and excluded: it is a design
// choice, not breakage.
const clipped = [];
// Elements whose content spills out of their own box without being
// clipped, past the right edge of the viewport. A block element is
// only ever as wide as its container, so text overflowing it has no
// element rect of its own to catch — but it is exactly what drags
// documentElement.scrollWidth past the viewport width, so without
// this the page-level overflow failure has nothing to point at.
const contentOverflowing = [];
for (const el of elements) {
if (!isVisible(el)) continue;
const rect = el.getBoundingClientRect();
if (rect.right > viewportWidth + 1 || rect.left < -1) {
overflowing.push({
el: describe(el),
depth: depthOf(el),
left: round(rect.left),
right: round(rect.right),
});
}
const style = getComputedStyle(el);
const clips =
style.overflowX === "hidden" || style.overflowX === "clip";
const ellipsis = style.textOverflow === "ellipsis";
const hasText = el.textContent.trim().length > 0;
const spills =
el.clientWidth > 0 && el.scrollWidth > el.clientWidth + 1;
if (clips && !ellipsis && hasText && spills) {
clipped.push({
el: describe(el),
scrollWidth: el.scrollWidth,
clientWidth: el.clientWidth,
});
}
if (
!clips &&
spills &&
rect.left + el.scrollWidth > viewportWidth + 1
) {
contentOverflowing.push({
el: describe(el),
depth: depthOf(el),
scrollWidth: el.scrollWidth,
clientWidth: el.clientWidth,
reach: round(rect.left + el.scrollWidth),
});
}
}
// Interactive controls. The measured target is the nearest thing that
// is genuinely tappable — for a checkbox that is the <label> wrapping
// it, which is larger than the box itself and is what a finger hits.
const tapTargets = [];
for (const selector of options.interactiveSelectors) {
for (const el of document.querySelectorAll(selector)) {
if (!isVisible(el)) continue;
const target = el.closest("button, a, label, select") || el;
const rect = target.getBoundingClientRect();
tapTargets.push({
selector,
el: describe(target),
width: round(rect.width),
height: round(rect.height),
});
}
}
// Host rows. The question is not "did it get narrower" but "did it
// reflow": the info block and the sparkline must end up stacked
// vertically and full width in the narrow layout, and side by side in
// the wide one. Both the computed flex-direction and the actual
// geometry are recorded so a row that claims to be a column but is
// still laid out side by side cannot slip through.
const rows = [];
for (const row of document.querySelectorAll(".host-row")) {
const inner = row.firstElementChild;
if (!inner) continue;
const sparkline = inner.querySelector(".sparkline-container");
const info = sparkline ? sparkline.previousElementSibling : null;
if (!sparkline || !info) continue;
const innerStyle = getComputedStyle(inner);
const innerRect = inner.getBoundingClientRect();
const infoRect = info.getBoundingClientRect();
const sparkRect = sparkline.getBoundingClientRect();
rows.push({
index: row.dataset.index,
flexDirection: innerStyle.flexDirection,
containerWidth: round(innerRect.width),
info: {
left: round(infoRect.left),
right: round(infoRect.right),
bottom: round(infoRect.bottom),
width: round(infoRect.width),
},
sparkline: {
left: round(sparkRect.left),
top: round(sparkRect.top),
width: round(sparkRect.width),
},
});
}
const localRows = Array.from(
document.querySelectorAll("#local-hosts .host-row"),
);
return {
innerWidth,
// innerWidth includes any classic scrollbar, clientWidth does not.
// Reported separately so the overflow check can hold itself to the
// narrower of the two rather than to whichever one is more
// forgiving.
documentClientWidth: document.documentElement.clientWidth,
documentScrollWidth: document.documentElement.scrollWidth,
overflowing,
contentOverflowing,
clipped,
tapTargets,
rows,
rowCount: document.querySelectorAll(".host-row").length,
numericLatencies: Array.from(
document.querySelectorAll(".latency-value"),
).filter((el) => /\d/.test(el.textContent)).length,
gatewayDetected: localRows.some((row) =>
row.textContent.includes("Local Gateway"),
),
};
}

258
test/viewport/harness.js Normal file
View File

@@ -0,0 +1,258 @@
// Responsive-layout harness.
//
// Drives the built frontend in a real, containerised, digest-pinned Chrome
// over CDP and asserts on computed layout at every viewport width derived
// from the app's own CSS. Screenshots are written alongside as artifacts;
// they are not the evidence, the assertions are.
//
// This is not meant to be run by hand. `make frontend-viewport-test` brings
// up the browser and the web server and then runs this; every input it
// needs arrives in the environment.
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import puppeteer from "puppeteer-core";
import { collectLayoutFacts } from "./facts.js";
import { evaluateChecks, INTERACTIVE_SELECTORS } from "./checks.js";
import { deriveViewports } from "./viewports.js";
function required(name) {
const value = process.env[name];
if (!value) {
throw new Error(
`${name} is not set; run this via script/frontend-viewport-test`,
);
}
return value;
}
const ROOT = required("NETWATCH_ROOT");
const BASE_URL = required("NETWATCH_BASE_URL");
const CDP_URL = required("NETWATCH_CDP_URL");
const ARTIFACT_DIR = required("NETWATCH_ARTIFACT_DIR");
const BROWSER_TIMEOUT_MS = 60000;
const PAGE_TIMEOUT_MS = 30000;
// Canned responses for the app's outbound latency probes. The browser
// container sits on an --internal docker network and physically cannot
// reach the internet, so nothing here is about blocking traffic; it is
// about determinism. Real probes would render 24 rows of whatever the
// network happened to be doing. These delays make the rows show a
// realistic spread of value widths — one, two and three digit latencies,
// plus some unreachable rows — because that spread is what the layout has
// to survive.
const PROBE_DELAYS_MS = [2, 45, 123, 456, 780];
// One in every UNREACHABLE_MODULUS probes is failed outright so that the
// offline row rendering is exercised too.
const UNREACHABLE_MODULUS = 7;
// The gateway candidate that "answers", so gateway detection succeeds and
// the Local Gateway row renders. Matches GATEWAY_CANDIDATES in src/main.js.
const RESPONSIVE_GATEWAY = "http://192.168.1.1";
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
function stableHash(text) {
let hash = 0;
for (let i = 0; i < text.length; i++) {
hash = (hash * 31 + text.charCodeAt(i)) | 0;
}
return Math.abs(hash);
}
async function connectBrowser() {
const deadline = Date.now() + BROWSER_TIMEOUT_MS;
let lastError;
for (;;) {
try {
const response = await fetch(`${CDP_URL}/json/version`);
const info = await response.json();
// The endpoint advertises whatever Host it was reached on;
// pin it back to the address we actually dialled.
const endpoint = new URL(info.webSocketDebuggerUrl);
endpoint.host = new URL(CDP_URL).host;
const browser = await puppeteer.connect({
browserWSEndpoint: endpoint.toString(),
protocolTimeout: BROWSER_TIMEOUT_MS,
});
return { browser, version: info.Browser };
} catch (error) {
lastError = error;
if (Date.now() > deadline) {
throw new Error(`browser never came up: ${lastError}`);
}
await sleep(250);
}
}
}
function installProbeResponder(page, probes) {
const respond = (request, delayMs) =>
sleep(delayMs).then(() =>
request.respond({
status: 200,
contentType: "text/plain",
body: "",
}),
);
page.on("request", (request) => {
const url = request.url();
const settle = async () => {
if (url.startsWith(BASE_URL) || url.startsWith("data:")) {
return request.continue();
}
probes.attempted++;
if (url.startsWith(RESPONSIVE_GATEWAY)) {
probes.fulfilled++;
return respond(request, 5);
}
const hash = stableHash(url);
if (hash % UNREACHABLE_MODULUS === 0) {
probes.failed++;
return request.abort("connectionfailed");
}
probes.fulfilled++;
return respond(
request,
PROBE_DELAYS_MS[hash % PROBE_DELAYS_MS.length],
);
};
// The page may be torn down while a delayed response is pending;
// that is not a harness failure.
settle().catch(() => {});
});
}
async function runViewport(browser, viewport) {
const page = await browser.newPage();
const probes = { attempted: 0, fulfilled: 0, failed: 0 };
try {
page.setDefaultTimeout(PAGE_TIMEOUT_MS);
await page.setRequestInterception(true);
installProbeResponder(page, probes);
await page.setViewport({
width: viewport.width,
height: viewport.height,
deviceScaleFactor: viewport.deviceScaleFactor,
isMobile: viewport.touch,
hasTouch: viewport.touch,
isLandscape: viewport.width > viewport.height,
});
await page.goto(BASE_URL, { waitUntil: "load" });
await page.waitForSelector(".host-row");
// The app discards its first tick as a cold start, so rows only
// carry real values from the second one. Changing the interval
// restarts the loop at 1s, which reaches a populated UI without
// waiting out two default 3s intervals — and exercises the
// interval dropdown while we are at it.
await page.select("#interval-select", "1000");
await page.waitForFunction(
() =>
Array.from(document.querySelectorAll(".latency-value")).filter(
(el) => /\d/.test(el.textContent),
).length >= 5,
);
// Let the resize/redraw handlers settle before measuring.
await page.evaluate(
() =>
new Promise((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(resolve)),
),
);
const facts = await page.evaluate(collectLayoutFacts, {
interactiveSelectors: INTERACTIVE_SELECTORS,
});
const screenshot = join(
ARTIFACT_DIR,
`${viewport.width}x${viewport.height}-${viewport.name}.png`,
);
await page.screenshot({ path: screenshot, fullPage: true });
return {
viewport,
probes,
facts,
screenshot,
checks: evaluateChecks(facts, viewport, probes),
};
} finally {
await page.close().catch(() => {});
}
}
function report(results, conditions, browserVersion) {
const label = (viewport) =>
`${viewport.width}x${viewport.height}`.padEnd(9) +
" " +
viewport.name.padEnd(24);
console.log(`browser: ${browserVersion}`);
console.log(`served from: ${BASE_URL} (built dist/)`);
console.log(
"breakpoints: " +
conditions
.map((c) => `${c.type}-width ${c.px}px (${c.source})`)
.join(", "),
);
console.log("");
let passed = 0;
let failed = 0;
for (const result of results) {
const bad = result.checks.filter((c) => !c.ok);
passed += result.checks.length - bad.length;
failed += bad.length;
// Passing viewports get one line. Detail is for failures.
console.log(
`${bad.length === 0 ? "PASS" : "FAIL"} ${label(result.viewport)} ` +
`${result.checks.length - bad.length}/${result.checks.length} checks` +
`${result.viewport.expectStacked ? " [narrow layout expected]" : ""}`,
);
for (const check of bad) {
console.log(` ${check.name}: ${check.detail}`);
}
if (bad.length > 0) {
console.log(` why this width: ${result.viewport.why}`);
console.log(` screenshot: ${result.screenshot}`);
}
}
console.log("");
console.log(
`${results.length} viewports, ${passed + failed} checks: ` +
`${passed} passed, ${failed} failed`,
);
console.log(`artifacts: ${ARTIFACT_DIR}`);
return failed;
}
async function main() {
const { conditions, viewports } = deriveViewports(ROOT);
mkdirSync(ARTIFACT_DIR, { recursive: true });
const { browser, version } = await connectBrowser();
const results = [];
try {
for (const viewport of viewports) {
results.push(await runViewport(browser, viewport));
}
} finally {
await browser.disconnect().catch(() => {});
}
writeFileSync(
join(ARTIFACT_DIR, "results.json"),
JSON.stringify({ browser: version, conditions, results }, null, 2) +
"\n",
);
const failed = report(results, conditions, version);
process.exitCode = failed === 0 ? 0 : 1;
}
await main();

224
test/viewport/viewports.js Normal file
View File

@@ -0,0 +1,224 @@
// Viewport derivation for the responsive-layout harness.
//
// The widths tested are read out of the CSS the application actually
// ships, not taken from a list of popular phone models. A generic 375px
// "phone" test sails straight past an off-by-one error at a media query
// boundary, which is the classic way a responsive layout breaks, so
// every breakpoint found in the sources is probed three times: one pixel
// below it, exactly on it, and one pixel above it.
//
// Nothing here hardcodes 768. If someone adds a second media block or
// starts using Tailwind responsive prefixes, that breakpoint starts
// being covered without this file being edited.
import { readFileSync } from "node:fs";
import { join } from "node:path";
// Tailwind CSS v4 default breakpoints, in rem. The app currently uses
// none of these prefixes, so the whole table is inert until someone
// writes an `md:`-prefixed utility class.
const TAILWIND_BREAKPOINT_REM = {
sm: 40,
md: 48,
lg: 64,
xl: 80,
"2xl": 96,
};
// The app does not override the root font size, so rem and em in media
// queries resolve against the browser default.
const ROOT_FONT_SIZE_PX = 16;
// Extract every min-width / max-width condition from the @media blocks in
// a stylesheet. Returns e.g. [{ type: "max", px: 768, source: "..." }].
export function mediaConditionsFromCss(css, source) {
const conditions = [];
for (const block of css.matchAll(/@media([^{]+)\{/g)) {
const features = block[1].matchAll(
/\(\s*(min|max)-width\s*:\s*([\d.]+)(px|rem|em)\s*\)/g,
);
for (const feature of features) {
const scale = feature[3] === "px" ? 1 : ROOT_FONT_SIZE_PX;
conditions.push({
type: feature[1],
px: Math.round(Number(feature[2]) * scale),
source,
});
}
}
return conditions;
}
// Extract the breakpoints implied by Tailwind responsive prefixes used in
// markup. A prefix only counts when it opens a utility class, so `text-sm`
// does not masquerade as the `sm:` breakpoint.
export function mediaConditionsFromMarkup(sources) {
const conditions = [];
for (const { path, text } of sources) {
for (const [name, rem] of Object.entries(TAILWIND_BREAKPOINT_REM)) {
const used = new RegExp(
`(^|["'\\s])${name}:[a-z0-9[\\](),_./%-]+`,
"m",
).test(text);
if (used) {
conditions.push({
type: "min",
px: rem * ROOT_FONT_SIZE_PX,
source: path,
});
}
}
}
return conditions;
}
// Whether a given width should be rendering the app's narrow (stacked)
// layout.
//
// Two breakpoint styles can be answered from the condition list alone:
//
// - Desktop-first, which is what the app ships today: the wide layout is
// unconditional and every narrow rule lives in a `max-width` block, so
// a width is narrow exactly when one of those blocks matches. Note that
// `max-width: 768px` matches *at* 768 — getting this inclusive boundary
// wrong in either direction is what the three-widths-per-breakpoint
// sweep exists to catch.
// - Mobile-first, which is what Tailwind's `sm:`/`md:` prefixes are: the
// stacked layout is the unconditional base and a `min-width` block is
// what widens it, so a width is narrow exactly when it sits below every
// `min-width` breakpoint.
//
// A mix of the two cannot be resolved from the breakpoints alone — which
// block owns the host-row reflow is a property of the rules inside it, not
// of the condition — so this throws rather than guessing. Guessing is how
// the wrong expectation gets applied at the right widths and the whole
// sweep quietly verifies nothing.
export function expectsStackedLayout(width, conditions) {
const kinds = new Set(conditions.map((c) => c.type));
for (const kind of kinds) {
if (kind !== "max" && kind !== "min") {
throw new Error(
`unsupported media condition type "${kind}" in ` +
"expectsStackedLayout (test/viewport/viewports.js)",
);
}
}
if (kinds.has("max") && kinds.has("min")) {
throw new Error(
"the app now mixes max-width and min-width breakpoints (" +
conditions
.map((c) => `${c.type}-width ${c.px}px in ${c.source}`)
.join(", ") +
"), so which layout a width should be showing can no longer " +
"be inferred from the breakpoint list; teach " +
"expectsStackedLayout in test/viewport/viewports.js which " +
"block owns the host-row reflow",
);
}
if (kinds.has("min")) {
return !conditions.some((c) => width >= c.px);
}
return conditions.some((c) => width <= c.px);
}
// Viewports that are not derived from a breakpoint. Each one is here for
// a stated reason; none of them is a stand-in for "a phone".
const ANCHOR_VIEWPORTS = [
{
name: "floor-portrait",
width: 320,
height: 568,
deviceScaleFactor: 2,
touch: true,
why: "320px is the narrowest viewport still in mainstream use; nothing has to work below it",
},
{
name: "phone-landscape-narrow",
width: 667,
height: 375,
deviceScaleFactor: 2,
touch: true,
why: "phone rotated to landscape, still inside the narrow layout",
},
{
name: "phone-landscape-wide",
width: 844,
height: 390,
deviceScaleFactor: 3,
touch: true,
why: "large phone rotated to landscape: crosses into the wide layout while still being a touch device",
},
{
name: "desktop",
width: 1280,
height: 800,
deviceScaleFactor: 1,
touch: false,
why: "desktop baseline",
},
];
export function deriveViewports(root) {
const conditions = [
...mediaConditionsFromCss(
readFileSync(join(root, "src/styles.css"), "utf8"),
"src/styles.css",
),
...mediaConditionsFromMarkup([
{
path: "src/main.js",
text: readFileSync(join(root, "src/main.js"), "utf8"),
},
{
path: "index.html",
text: readFileSync(join(root, "index.html"), "utf8"),
},
]),
];
if (conditions.length === 0) {
throw new Error(
"no responsive breakpoints found in src/styles.css, src/main.js or " +
"index.html — either the responsive layout was deleted or this " +
"derivation has stopped matching the sources",
);
}
const viewports = new Map();
const add = (viewport) => {
const key = `${viewport.width}x${viewport.height}`;
if (!viewports.has(key)) viewports.set(key, viewport);
};
for (const condition of conditions) {
for (const [offset, label] of [
[-1, "below"],
[0, "at"],
[+1, "above"],
]) {
const width = condition.px + offset;
add({
name: `${condition.type}-width-${condition.px}-${label}`,
width,
// Tall enough that the whole app is laid out in one column
// without the viewport height influencing wrapping.
height: 1024,
deviceScaleFactor: 2,
touch: true,
why: `${offset === 0 ? "exactly on" : `1px ${label}`} the ${condition.type}-width: ${condition.px}px breakpoint declared in ${condition.source}`,
});
}
}
for (const anchor of ANCHOR_VIEWPORTS) add(anchor);
return {
conditions,
viewports: [...viewports.values()]
.map((viewport) => ({
...viewport,
expectStacked: expectsStackedLayout(viewport.width, conditions),
}))
.sort((a, b) => a.width - b.width || a.height - b.height),
};
}

View File

@@ -1,8 +1,16 @@
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
import { execSync } from "child_process";
const commitHash = execSync("git rev-parse --short HEAD").toString().trim();
const commitFull = execSync("git rev-parse HEAD").toString().trim();
export default defineConfig({ export default defineConfig({
plugins: [tailwindcss()], plugins: [tailwindcss()],
define: {
__COMMIT_HASH__: JSON.stringify(commitHash),
__COMMIT_FULL__: JSON.stringify(commitFull),
},
build: { build: {
target: "esnext", target: "esnext",
minify: "esbuild", minify: "esbuild",

154
yarn.lock
View File

@@ -197,6 +197,14 @@
"@emnapi/runtime" "^1.7.1" "@emnapi/runtime" "^1.7.1"
"@tybys/wasm-util" "^0.10.1" "@tybys/wasm-util" "^0.10.1"
"@puppeteer/browsers@3.1.0":
version "3.1.0"
resolved "https://registry.yarnpkg.com/@puppeteer/browsers/-/browsers-3.1.0.tgz#5728ae0bc649263133ac1f8bd5d360eb75d11748"
integrity sha512-RDLpio3fH/qrj5k4DVY6eyiN8tCS0Zovd/6jW//n605oeqkWcUjn+3k+9ZtZBnbwMpsu0F7xDIiKXvVmG5c5Bw==
dependencies:
modern-tar "^0.7.6"
yargs "^18.0.0"
"@rollup/rollup-android-arm-eabi@4.57.0": "@rollup/rollup-android-arm-eabi@4.57.0":
version "4.57.0" version "4.57.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.0.tgz#f762035679a6b168138c94c960fda0b0cdb00d98" resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.0.tgz#f762035679a6b168138c94c960fda0b0cdb00d98"
@@ -441,6 +449,16 @@
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e"
integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==
ansi-regex@^6.2.2:
version "6.2.2"
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.2.2.tgz#60216eea464d864597ce2832000738a0589650c1"
integrity sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==
ansi-styles@^6.2.1:
version "6.2.3"
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.3.tgz#c044d5dcc521a076413472597a1acb1f103c4041"
integrity sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==
autoprefixer@^10.4.23: autoprefixer@^10.4.23:
version "10.4.23" version "10.4.23"
resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.4.23.tgz#c6aa6db8e7376fcd900f9fd79d143ceebad8c4e6" resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.4.23.tgz#c6aa6db8e7376fcd900f9fd79d143ceebad8c4e6"
@@ -473,16 +491,43 @@ caniuse-lite@^1.0.30001759, caniuse-lite@^1.0.30001760:
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz#b6f6b55cb25a2d888d9393104d14751c6a7d6f7a" resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz#b6f6b55cb25a2d888d9393104d14751c6a7d6f7a"
integrity sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA== integrity sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==
chromium-bidi@17.0.2:
version "17.0.2"
resolved "https://registry.yarnpkg.com/chromium-bidi/-/chromium-bidi-17.0.2.tgz#921a586deecd0c2d8b9242c4c1b73c3aa39ff77c"
integrity sha512-5v9GQFhTktFvotn/OFNJBmKLKRAb6n9r0bVCwf7sHgWc3/JryK0bj1nn93L3pHFrfgcsu6Be6EWsDi+1XHTGDg==
dependencies:
mitt "^3.0.1"
zod "^3.24.1"
cliui@^9.0.1:
version "9.0.1"
resolved "https://registry.yarnpkg.com/cliui/-/cliui-9.0.1.tgz#6f7890f386f6f1f79953adc1f78dec46fcc2d291"
integrity sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==
dependencies:
string-width "^7.2.0"
strip-ansi "^7.1.0"
wrap-ansi "^9.0.0"
detect-libc@^2.0.3: detect-libc@^2.0.3:
version "2.1.2" version "2.1.2"
resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.1.2.tgz#689c5dcdc1900ef5583a4cb9f6d7b473742074ad" resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.1.2.tgz#689c5dcdc1900ef5583a4cb9f6d7b473742074ad"
integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ== integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==
devtools-protocol@0.0.1653615:
version "0.0.1653615"
resolved "https://registry.yarnpkg.com/devtools-protocol/-/devtools-protocol-0.0.1653615.tgz#c600e0c619612156b2422a66d958ba188d87dbe8"
integrity sha512-pGVkY3T/qXxAp2nFPodwYqOevk6ncNMSmvL8QfRCx5ZWGd6Vor7AFNmyaA8Zs6uJyP1QAfjuLandCgvSix1BNA==
electron-to-chromium@^1.5.263: electron-to-chromium@^1.5.263:
version "1.5.282" version "1.5.282"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.282.tgz#6695816e5b170210d6aa07561546ed7d97347630" resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.282.tgz#6695816e5b170210d6aa07561546ed7d97347630"
integrity sha512-FCPkJtpst28UmFzd903iU7PdeVTfY0KAeJy+Lk0GLZRwgwYHn/irRcaCbQQOmr5Vytc/7rcavsYLvTM8RiHYhQ== integrity sha512-FCPkJtpst28UmFzd903iU7PdeVTfY0KAeJy+Lk0GLZRwgwYHn/irRcaCbQQOmr5Vytc/7rcavsYLvTM8RiHYhQ==
emoji-regex@^10.3.0:
version "10.6.0"
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-10.6.0.tgz#bf3d6e8f7f8fd22a65d9703475bc0147357a6b0d"
integrity sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==
enhanced-resolve@^5.18.3: enhanced-resolve@^5.18.3:
version "5.18.4" version "5.18.4"
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz#c22d33055f3952035ce6a144ce092447c525f828" resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz#c22d33055f3952035ce6a144ce092447c525f828"
@@ -523,7 +568,7 @@ esbuild@^0.27.0:
"@esbuild/win32-ia32" "0.27.2" "@esbuild/win32-ia32" "0.27.2"
"@esbuild/win32-x64" "0.27.2" "@esbuild/win32-x64" "0.27.2"
escalade@^3.2.0: escalade@^3.1.1, escalade@^3.2.0:
version "3.2.0" version "3.2.0"
resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5"
integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==
@@ -543,6 +588,16 @@ fsevents@~2.3.2, fsevents@~2.3.3:
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
get-caller-file@^2.0.5:
version "2.0.5"
resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e"
integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==
get-east-asian-width@^1.0.0, get-east-asian-width@^1.5.0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz#216900f91df11a8b2c198c3e1d93d6c035a776b9"
integrity sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==
graceful-fs@^4.2.4: graceful-fs@^4.2.4:
version "4.2.11" version "4.2.11"
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3"
@@ -634,6 +689,16 @@ magic-string@^0.30.21:
dependencies: dependencies:
"@jridgewell/sourcemap-codec" "^1.5.5" "@jridgewell/sourcemap-codec" "^1.5.5"
mitt@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/mitt/-/mitt-3.0.1.tgz#ea36cf0cc30403601ae074c8f77b7092cdab36d1"
integrity sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==
modern-tar@^0.7.6:
version "0.7.7"
resolved "https://registry.yarnpkg.com/modern-tar/-/modern-tar-0.7.7.tgz#ca71d79603630076b10733b0751ccab284bbc1ef"
integrity sha512-t9VmxaqrmANnEOBhpSDI6HD192Ge48k8vmWqQQL7hSFEqHEYwZbbsu49+aKLWZeRvFs3j1pMhXOqqF4kPlvjkQ==
nanoid@^3.3.11: nanoid@^3.3.11:
version "3.3.11" version "3.3.11"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b"
@@ -673,6 +738,18 @@ prettier@^3.8.1:
resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.1.tgz#edf48977cf991558f4fcbd8a3ba6015ba2a3a173" resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.1.tgz#edf48977cf991558f4fcbd8a3ba6015ba2a3a173"
integrity sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg== integrity sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==
puppeteer-core@25.5.0:
version "25.5.0"
resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-25.5.0.tgz#a41b14d582056b998e0bc3561ac47c7615d38a53"
integrity sha512-XPNT0dQJtphqQ4I29zxlG4IIPbg1iEHAQKWuQgtMJGXjACV77pZSmJvDi51IIIfd+DTKICcopJwUx4upVQ4XbA==
dependencies:
"@puppeteer/browsers" "3.1.0"
chromium-bidi "17.0.2"
devtools-protocol "0.0.1653615"
typed-query-selector "^2.12.2"
webdriver-bidi-protocol "0.4.2"
ws "^8.21.1"
rollup@^4.43.0: rollup@^4.43.0:
version "4.57.0" version "4.57.0"
resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.57.0.tgz#9fa13c1fb779d480038f45708b5e01b9449b6853" resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.57.0.tgz#9fa13c1fb779d480038f45708b5e01b9449b6853"
@@ -712,6 +789,30 @@ source-map-js@^1.2.1:
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46"
integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==
string-width@^7.0.0, string-width@^7.2.0:
version "7.2.0"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-7.2.0.tgz#b5bb8e2165ce275d4d43476dd2700ad9091db6dc"
integrity sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==
dependencies:
emoji-regex "^10.3.0"
get-east-asian-width "^1.0.0"
strip-ansi "^7.1.0"
string-width@^8.2.1:
version "8.2.2"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-8.2.2.tgz#7310516493df575742fe98af6fae87d85d5ed0ac"
integrity sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==
dependencies:
get-east-asian-width "^1.5.0"
strip-ansi "^7.1.2"
strip-ansi@^7.1.0, strip-ansi@^7.1.2:
version "7.2.0"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.2.0.tgz#d22a269522836a627af8d04b5c3fd2c7fa3e32e3"
integrity sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==
dependencies:
ansi-regex "^6.2.2"
tailwindcss@4.1.18, tailwindcss@^4.1.18: tailwindcss@4.1.18, tailwindcss@^4.1.18:
version "4.1.18" version "4.1.18"
resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-4.1.18.tgz#f488ba47853abdb5354daf9679d3e7791fc4f4e3" resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-4.1.18.tgz#f488ba47853abdb5354daf9679d3e7791fc4f4e3"
@@ -735,6 +836,11 @@ tslib@^2.4.0:
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f"
integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
typed-query-selector@^2.12.2:
version "2.12.2"
resolved "https://registry.yarnpkg.com/typed-query-selector/-/typed-query-selector-2.12.2.tgz#65e2462ac6b0aecfae1bfac1a4f3027070dbabaa"
integrity sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==
update-browserslist-db@^1.2.0: update-browserslist-db@^1.2.0:
version "1.2.3" version "1.2.3"
resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz#64d76db58713136acbeb4c49114366cc6cc2e80d" resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz#64d76db58713136acbeb4c49114366cc6cc2e80d"
@@ -756,3 +862,49 @@ vite@^7.3.1:
tinyglobby "^0.2.15" tinyglobby "^0.2.15"
optionalDependencies: optionalDependencies:
fsevents "~2.3.3" fsevents "~2.3.3"
webdriver-bidi-protocol@0.4.2:
version "0.4.2"
resolved "https://registry.yarnpkg.com/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.2.tgz#f51bb71c2606e90e3d5727607c728b25d617b58b"
integrity sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA==
wrap-ansi@^9.0.0:
version "9.0.2"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-9.0.2.tgz#956832dea9494306e6d209eb871643bb873d7c98"
integrity sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==
dependencies:
ansi-styles "^6.2.1"
string-width "^7.0.0"
strip-ansi "^7.1.0"
ws@^8.21.1:
version "8.21.3"
resolved "https://registry.yarnpkg.com/ws/-/ws-8.21.3.tgz#660b4faddb6a3e575c86e078126919961f4de4fc"
integrity sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==
y18n@^5.0.5:
version "5.0.8"
resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55"
integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==
yargs-parser@^22.0.0:
version "22.0.0"
resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-22.0.0.tgz#87b82094051b0567717346ecd00fd14804b357c8"
integrity sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==
yargs@^18.0.0:
version "18.1.0"
resolved "https://registry.yarnpkg.com/yargs/-/yargs-18.1.0.tgz#cd7e98c703ef51695bbbf062ed58f28e94291b56"
integrity sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==
dependencies:
cliui "^9.0.1"
escalade "^3.1.1"
get-caller-file "^2.0.5"
string-width "^8.2.1"
y18n "^5.0.5"
yargs-parser "^22.0.0"
zod@^3.24.1:
version "3.25.76"
resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.76.tgz#26841c3f6fd22a6a2760e7ccb719179768471e34"
integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==