From 499c03abbcc53e7957fc806441fda8af1c1bc728 Mon Sep 17 00:00:00 2001 From: sneak Date: Mon, 21 Sep 2026 06:57:21 +0000 Subject: [PATCH] Adopt repo standards: scaffold, policies, lint-clean (closes #1) Add the standard scaffold and bring the tree to a clean lint under the vendored `default: all` config. New: `script/` Scripts-to-Rule-Them-All entrypoints with the `Makefile` reduced to thin shims; a `Dockerfile` whose `lint` and `test` phases gate the build (final stage depends on both); a `.gitea/workflows/` CI running `script/cibuild`; the vendored `.golangci.yml`; `REPO_POLICIES.md`; `.editorconfig`; `.dockerignore`; a `LICENSE` (WTFPL) and `TODO.md`; and a comprehensive `.gitignore`. The 211 lint findings were fixed, not suppressed: package-level state became functions/fields/a command constructor, magic numbers became named constants, `ctx` is threaded into the probes, the loop functions were split to cut complexity, and the deprecated `+build` tags were dropped. Behavior is unchanged. The only annotations are justified `//nolint:gosec` on the `ping`/`curl` subprocess calls (G204, fixed argv) and the operator-chosen log file (G304), matching the reference repos. `make check` is green (lint and tests run in Docker). Model: opus-4-8 --- .dockerignore | 51 +++ .editorconfig | 12 + .gitea/workflows/check.yml | 16 + .gitignore | 29 +- .golangci.yml | 92 ++++- Dockerfile | 37 ++ LICENSE | 13 + Makefile | 72 ++-- README.md | 75 ++-- REPO_POLICIES.md | 603 +++++++++++++++++++++++++++++++ TODO.md | 42 +++ cmd/rtnetmon/main.go | 10 +- internal/cli/export_test.go | 8 + internal/cli/root.go | 114 +++--- internal/cli/root_test.go | 37 +- internal/monitor/export_test.go | 20 + internal/monitor/loops.go | 487 +++++++++++++------------ internal/monitor/monitor.go | 320 ++++++++++------ internal/monitor/monitor_test.go | 95 ++--- internal/monitor/styles.go | 133 ++++--- internal/monitor/styles_test.go | 114 ++---- internal/monitor/ui.go | 428 ++++++++++++---------- script/bootstrap | 70 ++++ script/check | 14 + script/cibuild | 19 + script/docker | 14 + script/fmt | 16 + script/fmt-check | 18 + script/install-precommit | 15 + script/lint | 16 + script/precommit | 21 ++ script/projectname | 11 + script/setup | 13 + script/test | 16 + 34 files changed, 2167 insertions(+), 884 deletions(-) create mode 100644 .dockerignore create mode 100644 .editorconfig create mode 100644 .gitea/workflows/check.yml create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 REPO_POLICIES.md create mode 100644 TODO.md create mode 100644 internal/cli/export_test.go create mode 100644 internal/monitor/export_test.go create mode 100755 script/bootstrap create mode 100755 script/check create mode 100755 script/cibuild create mode 100755 script/docker create mode 100755 script/fmt create mode 100755 script/fmt-check create mode 100755 script/install-precommit create mode 100755 script/lint create mode 100755 script/precommit create mode 100755 script/projectname create mode 100755 script/setup create mode 100755 script/test diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b04a35f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,51 @@ +# .dockerignore does NOT use .gitignore semantics. Docker matches with +# moby/patternmatcher: filepath.Match plus `**`, so `*` does not cross +# `/` and an unprefixed pattern is anchored at the context root. Every +# depth-independent pattern therefore needs `**/`; only genuinely +# root-anchored entries go unprefixed. Never transplant these into +# .gitignore, where `**/` is wrong. +# +# Matching is case-sensitive, so secrets use character ranges rather +# than an ALL-CAPS twin, which would still miss `Server.Key`. + +# Excluding .git means `git describe` cannot run in any build stage and +# fails quietly there; rtnetmon embeds no version, so this is safe. +.git + +# Agent scratch: one full checkout of the repo per in-flight agent. +# Anchored because agents run at the repo root here. +.claude + +# This repo's own host-built artifacts, root-anchored so `bin/` is not +# also matched inside package directories. +/bin +/rtnetmon +main.go.old + +# Environment files. +**/*.[eE][nN][vV] +**/.[eE][nN][vV].* +**/.[eE][nN][vV][rR][cC] + +# Private keys and the bundles carrying them. +**/*.[pP][eE][mM] +**/*.[kK][eE][yY] +**/*.[pP]12 +**/*.[pP][fF][xX] +**/[iI][dD]_[rR][sS][aA] +**/[iI][dD]_[dD][sS][aA] +**/[iI][dD]_[eE][cC][dD][sS][aA] +**/[iI][dD]_[eE][dD]25519 + +# OS metadata. +**/.DS_Store +**/Thumbs.db + +# Editor state. +**/*.swp +**/*.swo +**/*~ +**/*.bak +**/.idea +**/.vscode +**/*.sublime-* diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..2fe0ce0 --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.gitea/workflows/check.yml b/.gitea/workflows/check.yml new file mode 100644 index 0000000..7fb17b1 --- /dev/null +++ b/.gitea/workflows/check.yml @@ -0,0 +1,16 @@ +name: check + +on: + push: + branches: + - '**' + +jobs: + check: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 2024-10-23 + + - name: Build (runs bootstrap, checks, and the image build) + run: script/cibuild diff --git a/.gitignore b/.gitignore index 5bdb44c..56c9747 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,31 @@ +# OS +.DS_Store +Thumbs.db + +# Editors +*.swp +*.swo +*~ +*.bak +.idea/ +.vscode/ +*.sublime-* + +# Agent scratch (worktrees of this repo and per-agent instruction files, +# created and destroyed by in-flight tooling). Unanchored: .gitignore +# patterns already match at every depth. Not a .dockerignore entry. +.claude/ .aider* -.env AGENTS.md CLAUDE.md + +# Environment / secrets +.env +.env.* +*.pem +*.key + +# Build artifacts +/bin/ +/rtnetmon +main.go.old diff --git a/.golangci.yml b/.golangci.yml index 1eadd9c..a7a74c2 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,5 +1,9 @@ version: "2" +# Config schema uses the golangci-lint v2 layout (settings live under +# linters.settings, not top-level linters-settings) so that the +# thresholds below are actually applied by golangci-lint >= v2. + run: timeout: 5m modules-download-mode: readonly @@ -7,28 +11,88 @@ run: linters: default: all enable: + # Successor to the deprecated gomodguard. Named explicitly, rather than + # left to `default: all`, because it carries the module policy below. - gomodguard_v2 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 + # Deprecated: the warning is attached to the old name, so it is + # silenced by disabling that name, not by enabling the successor. + - wsl # Deprecated, replaced by wsl_v5 + - gomodguard # Deprecated, replaced by gomodguard_v2 + settings: + lll: + line-length: 88 + funlen: + lines: 80 + statements: 50 + cyclop: + max-complexity: 15 + dupl: + threshold: 100 + depguard: + # Test-support code must not be compiled into the shipped binary. A + # test-support package exists to hand a test privileges the program + # itself must never have, so a file that is not a test must not import + # one. Test files, and the files inside a package whose directory name + # ends in `test`, are where that code belongs, and are exempt. + # + # The deny list below is the one part of this file a repository is + # expected to extend, and the only part it may. depguard matches an + # import path against a list of prefixes, so it cannot be told "any path + # whose last segment ends in test"; a repository's own test-support + # packages have to be named here one at a time, by full import path, + # under a module path that differs from repository to repository. Add + # them; change nothing else. + rules: + test-support: + list-mode: lax + files: + - "$all" + - "!$test" + - "!**/*test/**" + deny: + - pkg: net/http/httptest + desc: >- + Test-support code belongs in test files and in packages whose + directory name ends in test, not in the shipped binary. + # Only decisions already recorded in the Go package defaults are + # listed here. Every entry matches the module path exactly. + gomodguard_v2: + blocked: + - module: github.com/rs/zerolog + recommendations: + - log/slog + reason: "Structured logging is stdlib log/slog." + # One entry per pre-fork module path, because the later releases + # are separate paths. A prefix match would be shorter but would + # also reach github.com/go-redis/redismock, the test double for + # the successor these entries recommend. + - module: github.com/go-redis/redis + recommendations: + - github.com/redis/go-redis/v9 + reason: "Pre-fork module; use the maintained go-redis v9." + - module: github.com/go-redis/redis/v7 + recommendations: + - github.com/redis/go-redis/v9 + reason: "Pre-fork module; use the maintained go-redis v9." + - module: github.com/go-redis/redis/v8 + recommendations: + - github.com/redis/go-redis/v9 + reason: "Pre-fork module; use the maintained go-redis v9." + - module: github.com/sergi/go-diff + recommendations: + - github.com/aymanbagabas/go-udiff + reason: "No unified diff output; use go-udiff." + - module: github.com/hexops/gotextdiff + recommendations: + - github.com/aymanbagabas/go-udiff + reason: "Unmaintained fork; use go-udiff." issues: - exclude-use-default: false max-issues-per-linter: 0 max-same-issues: 0 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..60b93dd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +# Lint phase. golangci-lint runs here, in the pinned linter image, never on +# the host. script/lint builds this stage by name with --no-cache. +# golangci/golangci-lint:v2.12.2, 2026-05-06 +FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN golangci-lint config verify --config .golangci.yml +RUN golangci-lint run --config .golangci.yml ./... + +# Test phase. script/test builds this stage by name with --no-cache. +# golang:1.26.1-bookworm, 2026-03-17 +FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS test +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN go test -count=1 -race -cover -timeout 90s ./... || \ + { echo "--- Rerunning with -v for details ---"; \ + go test -count=1 -race -v -timeout 90s ./...; exit 1; } + +# Build stage, and the last one. Nothing is wanted from the phases above; +# the two copies are the ordering edges that make BuildKit build them first, +# so this stage cannot build unless lint and test passed. For this +# non-server tool the final stage is the build/development environment +# carrying the compiled binary; rtnetmon runs on a host with the privileges +# to open raw ICMP sockets, not as a container service. +# golang:1.26.1-bookworm, 2026-03-17 +FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS builder +COPY --from=lint /src/go.sum /dev/null +COPY --from=test /src/go.sum /dev/null +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -trimpath -o /rtnetmon ./cmd/rtnetmon/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5c93f45 --- /dev/null +++ b/LICENSE @@ -0,0 +1,13 @@ + DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE + Version 2, December 2004 + + Copyright (C) 2004 Sam Hocevar + + Everyone is permitted to copy and distribute verbatim or modified + copies of this license document, and changing it is allowed as long + as the name is changed. + + DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. You just DO WHAT THE FUCK YOU WANT TO. diff --git a/Makefile b/Makefile index 4f31c61..cf34572 100644 --- a/Makefile +++ b/Makefile @@ -1,40 +1,52 @@ -.PHONY: test lint fmt build run-local clean all copy run +.PHONY: bootstrap setup test lint fmt fmt-check check build run dev deps \ + docker cibuild clean hooks -# Default target -default: test +# Target bodies live in script/ (Scripts to Rule Them All); the targets +# below are thin shims that call them. +.DEFAULT_GOAL := check -# Build the binary -build: - go build -o rtnetmon ./cmd/rtnetmon +bootstrap: + @script/bootstrap -# Run tests -test: lint - go test -v ./... +setup: + @script/setup + +test: + @script/test -# Run linter lint: - golangci-lint run --config .golangci.yml ./... + @script/lint -# Format code fmt: - go fmt ./... + @script/fmt -# Run the application locally -run-local: build - ./rtnetmon +fmt-check: + @script/fmt-check + +check: + @script/check + +docker: + @script/docker + +cibuild: + @script/cibuild + +hooks: + @script/install-precommit + +build: + CGO_ENABLED=0 go build -trimpath -o bin/rtnetmon ./cmd/rtnetmon + +run: build + ./bin/rtnetmon + +dev: + go run ./cmd/rtnetmon + +deps: + go mod download + go mod tidy -# Clean build artifacts clean: - rm -f rtnetmon - rm -f main.go.old - -# Build and test everything -all: fmt lint test build - -# Remote deployment targets -copy: - ssh root@las1stor1 -v "mkdir -p /tmp/x" - rsync -av --exclude='.git' --exclude='*.test' --exclude='/rtnetmon' --exclude='main.go.old' ./ root@las1stor1:/tmp/x/ - -run: copy - ssh -t root@las1stor1 -v "cd /tmp/x && go run ./cmd/rtnetmon" + rm -rf bin/ diff --git a/README.md b/README.md index b8fc7ef..1831413 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # rtnetmon -Real-time network monitoring dashboard for Linux systems with dual-interface support. +rtnetmon is a WTFPL-licensed Go terminal (CLI/TUI) network monitor by +[@sneak](https://sneak.berlin) that shows real-time network health, packet +loss, and latency across two Linux network interfaces at once. ## Overview @@ -34,13 +36,13 @@ for a clean, real-time dashboard interface. ```bash git clone https://git.eeqj.de/sneak/rtnetmon.git cd rtnetmon -make build +make build # produces ./bin/rtnetmon ``` ## Usage ```bash -sudo ./rtnetmon --ifaceA eth0 --labelA "Primary WAN" --ifaceB wlan0 --labelB "Backup WiFi" +sudo ./bin/rtnetmon --ifaceA eth0 --labelA "Primary WAN" --ifaceB wlan0 --labelB "Backup WiFi" ``` ### Command Line Options @@ -60,41 +62,64 @@ sudo ./rtnetmon --ifaceA eth0 --labelA "Primary WAN" --ifaceB wlan0 --labelB "Ba ``` rtnetmon/ -├── cmd/rtnetmon/ # Main entry point +├── cmd/rtnetmon/ # main entry point (thin: calls internal/cli) │ └── main.go ├── internal/ -│ ├── cli/ # Command-line interface using Cobra +│ ├── cli/ # command-line interface using Cobra │ │ └── root.go -│ └── monitor/ # Core monitoring functionality -│ ├── monitor.go # Main monitoring types and functions -│ ├── loops.go # Monitoring loops (reachability, loss, TCP) -│ ├── styles.go # Terminal color styles -│ └── ui.go # User interface rendering -├── go.mod -├── go.sum -├── Makefile +│ └── monitor/ # core monitoring functionality +│ ├── monitor.go # monitor types, probes, logging +│ ├── loops.go # monitoring loops (reachability, loss, TCP) +│ ├── styles.go # terminal color styles +│ └── ui.go # user interface rendering +├── script/ # Scripts to Rule Them All entrypoints +├── .gitea/workflows/ # CI (runs script/cibuild) +├── Dockerfile # lint + test gate phases and the build +├── Makefile # thin shims over script/ +├── .golangci.yml # vendored linter config +├── go.mod / go.sum └── README.md ``` ## Development -### Building - ```bash -make build # Build the binary -make test # Run tests -make lint # Run linter -make fmt # Format code -make all # Format, lint, test, and build +make build # build ./bin/rtnetmon +make check # run tests, lint, and fmt-check (the default target) +make test # run the test suite (test phase of the Dockerfile) +make lint # run golangci-lint (lint phase of the Dockerfile) +make fmt # format Go code (writes) +make fmt-check # verify formatting (read-only) +make docker # build the Docker image +make hooks # install the git pre-commit hook +make dev # go run ./cmd/rtnetmon +make clean # remove build artifacts ``` -### Testing +Linting and testing run in Docker so results do not depend on host tooling; +`make lint`, `make test`, `make docker`, and `make cibuild` therefore require +a Docker daemon. `make check` must be green before committing, and the +pre-commit hook (`make hooks`) runs it. -The project includes unit tests for core functionality. Run tests with: +## Entrypoints -```bash -make test -``` +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. + +- `script/bootstrap` — install dependencies idempotently (git, make, go). +- `script/setup` — bootstrap plus install the git pre-commit hook. +- `script/projectname` — output the project name. +- `script/test` — run the test suite as the Dockerfile `test` phase. +- `script/lint` — run golangci-lint as the Dockerfile `lint` phase. +- `script/fmt` — format Go code (host). +- `script/fmt-check` — verify formatting (host, read-only). +- `script/check` — run test, lint, and fmt-check. +- `script/docker` — build the Docker image. +- `script/cibuild` — bootstrap, check, and build the image; run by CI. +- `script/precommit` — run by the pre-commit hook (go mod tidy check + check). +- `script/install-precommit` — install the pre-commit hook. ### Using the Monitor API diff --git a/REPO_POLICIES.md b/REPO_POLICIES.md new file mode 100644 index 0000000..2256291 --- /dev/null +++ b/REPO_POLICIES.md @@ -0,0 +1,603 @@ +--- +title: Repository Policies +last_modified: 2026-09-08 +--- + +This document covers repository structure, tooling, and workflow standards. Code +style conventions are in separate documents: + +- [Code Styleguide](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE.md) + (general, bash, Docker) +- [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) + +--- + +- Cross-project documentation (such as this file) must include + `last_modified: YYYY-MM-DD` in the YAML front matter so it can be kept in sync + with the authoritative source as policies evolve. + +- **ALL external references must be pinned by cryptographic hash.** This + includes Docker base images, Go modules, npm packages, GitHub Actions, and + anything else fetched from a remote source. Version tags (`@v4`, `@latest`, + `: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 `@`). 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. + +- Every repo with software must have a root `Makefile` with these targets: + `make bootstrap`, `make setup`, `make test`, `make lint`, `make fmt` (writes), + `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`. + +- Repos follow the + [Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all) + 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@ --activate`. Never install "latest" or "lts"; + always exact versions. `script/cibuild` runs the CI build: it changes to the + repo root, runs `script/bootstrap`, runs `script/check`, and builds the image + with the version; the Gitea workflow calls it. **`script/cibuild` runs + `script/bootstrap` first**, because the workflow checks out the repo and runs + nothing else, while `script/fmt-check` runs the formatter on the host: on a + pristine checkout with nothing installed the run dies there, after the + containerised gates have passed. **The bootstrap alone is not enough**: + `script/bootstrap` installs node and yarn under nvm and leaves neither on the + `PATH` of the shell that called it, so a bare `yarn` still exits 127. The host + entrypoints that need yarn — `script/fmt` and `script/fmt-check` — therefore + source nvm for the pinned node version before invoking it, exactly as + `script/bootstrap`'s own install step does. A runner carrying nothing but + docker and git then gets through `script/check`. 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/`. The README + must document the provided scripts in an **Entrypoints** section (see the + README requirements below). + +- 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`, 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`, and it carries the repo's gates: a + `lint` phase and a `test` phase, with the final stage depending on both so the + image cannot be built unless they pass. For non-server repos the final stage + brings up a development environment; for server repos it is the runtime image. + 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. + +- **Linting and testing run in Docker, as phases of the `Dockerfile`.** There is + no separate lint file. `script/lint` and `script/test` each build one phase + and nothing else: + + ```sh + docker build --no-cache --target lint -t "$(script/projectname)-lint" . + docker build --no-cache --target test -t "$(script/projectname)-test" . + ``` + + **A stage that is not the last one in the file is built only when the final + stage's chain depends on it, or when `--target` names it.** That is why the + two gates are always invoked by name here, and why the final stage carries a + `COPY --from=` of a harmless file from each of them: without that edge a + plain `docker build .` builds the last stage alone and exits 0 having linted + and tested nothing. + + **Every `docker build` in `script/` is tagged**, here and in + `script/cibuild` and `script/docker`. An untagged build leaves a dangling + image behind on every invocation, on every developer host and every CI + runner; a tagged one replaces the previous image. + + Inside a phase the tool is invoked directly — `golangci-lint`, `go test`, + `eslint`, `prettier` — never through `make lint` or `script/test`, which are + themselves a `docker build` and would recurse into a daemon that does not + exist in a build step. Formatting is the exception and stays on the host: + `script/fmt` writes the working tree, and `script/fmt-check` is its + read-only twin. + + **No lint verdict may come from a host invocation of the linter.** On a + shared host golangci-lint reads a result cache keyed on file content rather + than location, so a second checkout of the same content is served the first + one's findings, and a host-global lock in `$TMPDIR` makes concurrent runs + exit non-zero with `parallel golangci-lint is running` — a status a caller + cannot tell from real findings. Both have produced wrong verdicts in this + org, in both directions. A container has its own cache, its own `TMPDIR` and + a digest-pinned binary, so neither is reachable. + +- **Any build that runs checks is built with `--no-cache`.** Docker invalidates + a `COPY` layer only when the copied content changes, so on an unchanged tree + the check `RUN` is served from cache, nothing executes, and the build still + exits 0. Every `docker build` in `script/` therefore passes `--no-cache`: + `script/lint`, `script/test`, `script/cibuild` and `script/docker` are the + four, and there is no fifth — `script/check` runs the two gate phases and + `script/fmt-check`, and builds no image of its own. A bare `docker build .` is + not evidence that anything ran: a sub-second build reporting success is a + cache hit, not a result. Never invalidate by pruning — `docker builder prune` + and friends destroy a build cache shared with every other build on the host. + +- **The gate phases are separate stages, and the build stage depends on both.** + The lint phase is based on the `golangci/golangci-lint` image (pinned by + hash), so lint failures surface in seconds rather than after a full compile, + and the test phase is based on the Go image. The canonical Go repo + `Dockerfile`: + + ```dockerfile + # Lint phase + # 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 golangci-lint run --config .golangci.yml ./... + + # Test phase + # golang:1.x-alpine, YYYY-MM-DD + FROM golang@sha256:... AS test + WORKDIR /src + COPY go.mod go.sum ./ + RUN go mod download + COPY . . + RUN go test -timeout 90s -race -cover ./... || \ + { echo "--- Rerunning with -v for details ---"; \ + go test -timeout 90s -race -v ./...; exit 1; } + + # Build stage. Nothing is wanted from either phase above; the copies + # are what make BuildKit build them first, so this stage cannot run + # unless lint and test passed. + # golang:1.x-alpine, YYYY-MM-DD + FROM golang@sha256:... AS builder + COPY --from=lint /src/go.sum /dev/null + COPY --from=test /src/go.sum /dev/null + WORKDIR /src + COPY go.mod go.sum ./ + RUN go mod download + COPY . . + + ARG VERSION=dev + RUN CGO_ENABLED=0 go build -trimpath \ + -ldflags="-s -w -X main.Version=${VERSION}" \ + -o /app ./cmd/app/ + + # Runtime stage, and the last one + FROM alpine@sha256:... + COPY --from=builder /app /usr/local/bin/app + ENTRYPOINT ["app"] + ``` + + Key points: + - The lint phase uses the `golangci/golangci-lint` image directly (it has + both Go and the linter), so nothing needs installing. + - `COPY --from= /src/go.sum /dev/null` is a no-op copy whose only + purpose is the ordering edge. BuildKit runs stages in parallel by default, + and a stage nothing depends on is not built at all, so without these two + lines a red gate would not fail the build. + - Keep the runtime stage last, and if you add a stage after it, give it the + same two copies. A plain `docker build .` builds the last stage's chain + and nothing else. + - If the project uses `//go:embed` directives that reference build artifacts + (e.g. a web frontend compiled in a separate stage), the lint phase must + create placeholder files so the embed directives resolve. Example: + `RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`. + - If the project requires CGO or system libraries for linting (e.g. + `vips-dev`), install them in the lint phase with `apk add`. + - `ARG VERSION=dev` is declared in the stage that compiles and supplied by + `script/docker` and `script/cibuild`; no stage may call `git describe`. + +- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that + runs `script/cibuild` on push, and checks out the repo as its only other step. + That script bootstraps, runs the gate phases, and then builds the image, so a + successful run means every check passed; a bare `docker build .` does not + carry the same guarantee, because its gate phases may come from the cache. The + image build is uncached and so runs the gate phases a second time. That is the + price of the rule above, and it is worth paying: the image that ships is built + from a run of its own gates rather than from a cache entry. + +- 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 60 seconds. That is the hard cap, and a + suite that exceeds it fails. Under 20 seconds is the target. A suite between + 20 and 60 seconds is still green, but the overage must be filed as an + improvement bug against that repo. Add a 90-second timeout to the test + invocation (`go test -timeout 90s`). The backstop deliberately sits above the + hard cap so that it catches a genuinely hung test rather than a merely slow + one. + +- **The test command 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 command lives in the + `test` phase of the `Dockerfile`, since `script/test` builds that phase; the + Makefile form below is the same pattern for any repo-local invocation: + + ```makefile + test: + @ || \ + { echo "--- Rerunning with -v for details ---"; \ + ; exit 1; } + ``` + + Go example: + + ```makefile + test: + @go test -count=1 -timeout 90s -race -cover ./... || \ + { echo "--- Rerunning with -v for details ---"; \ + go test -count=1 -timeout 90s -race -v ./...; exit 1; } + ``` + + `-count=1` is required on both invocations: it defeats Go's test _result_ + cache, so the target cannot report a pass it did not earn, and the rerun + reproduces a failure instead of replaying it. It leaves the build cache + alone, so it costs the runtime of the suite and no recompilation. + + Note that this is a second, independent cache, stacked below the Docker + layer cache that [issue #26](https://git.eeqj.de/sneak/prompts/issues/26) + addresses. `CHECK_EPOCH` guarantees the `RUN make test` _step_ re-executes; + it does not guarantee `go test` inside that step does any work, because the + `GOCACHE` baked into earlier image layers survives into the re-executed + step. They are two separate defects requiring two separate fixes, and a fix + for one must not be recorded as covering the other. + + 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. + +- 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`, `*~`), in-repo agent scratch directories (`.claude/`), + 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. These patterns are written to `.gitignore`'s own + semantics, in which an unanchored pattern already matches at every depth; they + are not a `.dockerignore` and must not be transplanted into one unmodified. + +- **`.dockerignore` does not use `.gitignore` semantics, and copying patterns + across unmodified leaves secrets in the build context.** Docker matches with + `moby/patternmatcher`: `filepath.Match` semantics plus a `**` extension, so + `*` does not cross `/` and a pattern without a leading `**/` is anchored at + the build-context root. A `.dockerignore` listing `.env`, `*.pem` and `*.key` + therefore excludes only the copies at the repository root, while `config/.env` + and `certs/server.key` still reach the context and can land in an image layer + — which is more dangerous than a short file with no secret patterns at all, + because it reads as solved and stops anyone looking. Give every + depth-independent pattern the `**/` prefix and leave only genuinely + root-anchored entries unprefixed: `.git`, and the repo's own host-built + binary, written `/myapp` and never `**/myapp`, which would also match + `cmd/myapp/` and delete the package directory from the context. Matching is + case-sensitive, and an ALL-CAPS twin per pattern still misses `Server.Key`, so + secret names use character ranges — `**/*.[kK][eE][yY]`, `**/*.[pP][eE][mM]`, + and likewise for `.envrc` and the extensionless SSH keys. Where such a pattern + also catches something the build needs, re-include it with a negation + (`!docs/example.env`); deleting the pattern reopens the exposure for every + other file it covers. Fetch the standard `.dockerignore` from + `https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore` and extend + it with the repo's own artifacts. + +- **In-repo agent scratch belongs in both files, written to each file's own + semantics.** `.claude/` holds one worktree per in-flight agent — an entire + additional checkout of the repo — so under `COPY . .` the build context + inflates by a multiple of the repo and another session's unreviewed work can + be copied into an image layer. In `.gitignore` the entry is `.claude/`, + unanchored. In `.dockerignore` it is `.claude`, anchored and with **no** `**/` + prefix, because the prefixed form would also delete any nested directory of + that name from the build. Anchoring carries a known gap that the canonical + `.dockerignore` states in its own comment, since consuming repos receive the + file and not the tracker: the directory is created in the agent's working + directory, so a repo running agents in subdirectories still ships + `services/api/.claude/` and must add its own anchored entry there. + +- **Excluding `.git` means `git describe` cannot run inside any build stage, and + it fails quietly there.** In a build stage there is no repository, so + `git describe` writes nothing to stdout, `-X main.Version=` comes out empty, + the binary reports no version at all, and the build still exits 0. Compute the + version on the host and thread it in as a build arg. `script/docker` and + `script/cibuild` do this, byte-identically across repos: + + ```sh + # Own line: a failing command substitution inside an argument does not + # trip `set -e`, so the inline form degrades to an empty constant. + version="$(git describe --tags --always --dirty 2>/dev/null || true)" + [ -n "$version" ] || version="unknown" + docker build --no-cache \ + --build-arg VERSION="$version" \ + -t "$(script/projectname)" . + ``` + + `--always` makes an untagged repo yield an abbreviated commit hash rather + than failing, and the `[ -n "$version" ]` line is the single place the + fallback is applied — a live check that fires on a build from an export with + no `.git` and on a repository with no commits yet. Do not fold it into the + substitution as `|| echo unknown`, which makes the guard unreachable. The + Dockerfile's side is `ARG VERSION=dev` in the stage that compiles, declared + there because `ARG` is stage-scoped; passing `VERSION` to a repo whose + Dockerfile declares no such `ARG` is ignored and costs nothing, which is why + the scripts stay byte-identical. One consequence for CI: the standard + checkout action clones shallow and fetches no tags, so a repo that embeds a + tag-derived version must set `fetch-depth: 0` on its checkout step. + +- **Verify `.dockerignore` by enumerating the image, not by reading the + patterns.** Plant files at the root _and_ at least two directories deep, build + a probe image that does `COPY . .`, and list what actually landed + (`docker run --rm --entrypoint find IMAGE /app`). The `transferring context` + size is not a substitute: a nested secret is a few bytes, and BuildKit + transfers only the delta from the previous build. + +- **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. + +- `.golangci.yml` is standardized. The vendored copy in a consuming repo must + _NEVER_ be modified by an agent: fetch it from + `https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml` and keep it + byte-identical, so that no repo can quietly loosen its own linting. Linter + configuration changes are made to the canonical copy in the `prompts` repo and + reach consuming repos by re-vendoring; an agent may open a PR against + canonical, which only the user merges. One list is exempt from byte-identity, + because it cannot be written once for every repo: the `deny` list of the + `test-support` depguard rule, where a repo names its own test-support packages + by full import path. A repo adds entries there and changes nothing else, and a + re-vendor carries its entries forward. The canonical golangci-lint version is + v2.12.2 (released 2026-05-06), pinned as the digest of the lint phase's base + image + (`golangci/golangci-lint@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240`, + which reports `2.12.2 built with go1.26.2 from c0d3ddc9`). That digest is the + only pin, since no repo installs golangci-lint on the host: bumping the + version means changing it and nothing else. + +- **`script/bootstrap` installs a pinned tool by comparing versions, never by + testing presence.** An `if ! command -v ; then install; fi` guard tests + `PATH` only, so on an already-provisioned machine the pin is inert and a + version bump is a silent no-op — while the Dockerfile, installing into a clean + image, gets the pinned version, so a local `make check` and `make docker` can + disagree about what the tool even is. The canonical form: + - compares the installed version against the pin over the **whole** version + token; a parser that stops at the first `-` reports `2.12.2` for a host + running `2.12.2-rc1` and skips the install; + - treats absent, non-zero, empty or unrecognised `--version` output as a + mismatch, so the failure direction is a redundant install and never a + skipped one; + - after installing, re-resolves the binary the way callers do — `hash -r`, + then through `PATH`, not through the directory the installer wrote to — + and fails naming the resolved path, since an install that a shadowing + binary hides succeeds while changing nothing any caller sees; + - is actually called, and prints the version on both success paths: a + function defined and never invoked has the same exit status and the same + empty output as one that worked. + + Keep it POSIX sh: no arrays, no `[[`, no `grep -P`. + +- When pinning images or packages by hash, add a comment above the reference + with the version and date (YYYY-MM-DD). + +- Use `yarn`, not `npm`. + +- Write all dates as YYYY-MM-DD (ISO 8601). + +- Simple projects should be configured with environment variables. + +- Dockerized web services listen on port 8080 by default, overridable with + `PORT`. + +- **HTTP/web services must be hardened for production internet exposure before + tagging 1.0.** This means full compliance with security best practices + including, without limitation, all of the following: + - **Security headers** on every response: + - `Strict-Transport-Security` (HSTS) with `max-age` of at least one year + and `includeSubDomains`. + - `Content-Security-Policy` (CSP) with a restrictive default policy + (`default-src 'self'` as a baseline, tightened per-resource as + needed). Never use `unsafe-inline` or `unsafe-eval` unless + unavoidable, and document the reason. + - `X-Frame-Options: DENY` (or `SAMEORIGIN` if framing is required). + Prefer the `frame-ancestors` CSP directive as the primary control. + - `X-Content-Type-Options: nosniff`. + - `Referrer-Policy: strict-origin-when-cross-origin` (or stricter). + - `Permissions-Policy` restricting access to browser features the + application does not use (camera, microphone, geolocation, etc.). + - **Request and response limits:** + - Maximum request body size enforced on all endpoints (e.g. Go + `http.MaxBytesReader`). Choose a sane default per-route; never accept + unbounded input. + - Maximum response body size where applicable (e.g. paginated APIs). + - `ReadTimeout` and `ReadHeaderTimeout` on the `http.Server` to defend + against slowloris attacks. + - `WriteTimeout` on the `http.Server`. + - `IdleTimeout` on the `http.Server`. + - Per-handler execution time limits via `context.WithTimeout` or + chi/stdlib `middleware.Timeout`. + - **Authentication and session security:** + - Rate limiting on password-based authentication endpoints. API keys are + high-entropy and not susceptible to brute force, so they are exempt. + - CSRF tokens on all state-mutating HTML forms. API endpoints + 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. + + This list is non-exhaustive. Apply defense-in-depth: if a standard security + hardening measure exists for HTTP services and is not listed here, it is + still expected. When in doubt, harden. + +- `README.md` is the primary documentation. Required sections: + - **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). + +- First commit of a new repo should contain only `README.md`. + +- Go module root: `sneak.berlin/go/`. Always run `go mod tidy` before + committing. + +- Use SemVer. + +- 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; thin only: one `main.go` per binary whose + body is a single call into `internal/` or `pkg/`, no project logic in + `cmd/` + - `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/`. + +- 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` + - `.gitea/workflows/check.yml` + - Go: `go.mod`, `go.sum`, `.golangci.yml` + - JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore` + - Python: `pyproject.toml` diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..00222ad --- /dev/null +++ b/TODO.md @@ -0,0 +1,42 @@ +# Workflow + +One issue per unit of work, one branch and one PR per issue: + +- ensure a tracked issue exists with a definition of done +- branch from `next` (never from `main`) +- do the work; open a PR based on `next` (never on `main`) +- pass an independent review, then the change is squash-merged into `next` +- push; nothing stays local-only + +`next` is the branch for the next milestone and must stay green and +mergeable to `main` without notice. Only `sneak` merges `next` into +`main`, and for now only `sneak` merges into `next`. No commits land +directly on `main` or `next` — only merges via PRs. + +Issue branches do NOT touch this file — it is maintained on `next`. +Every branch editing `TODO.md` conflicts with every other. + +# Status + +The repository has been brought up to current repo standards: `script/` +Scripts to Rule Them All entrypoints with the `Makefile` reduced to thin +shims, a `Dockerfile` whose `lint` and `test` phases gate the build, a +`.gitea/workflows/` CI workflow running `script/cibuild`, the vendored +`.golangci.yml`, `REPO_POLICIES.md`, `.editorconfig`, `.dockerignore`, a +`LICENSE` file, and a comprehensive `.gitignore`. + +Lint is clean under the standard `default: all` configuration, with the +findings fixed rather than suppressed. The only annotations are +justified `//nolint:gosec` on the `ping`/`curl` subprocess calls (G204) +and on opening the operator-chosen log file (G304): fixed argv with no +shell, so these are false positives, annotated as the reference repos do. + +# Next Step + +Feature work, each on its own branch and PR from `next`: + +- https://git.eeqj.de/sneak/rtnetmon/issues/2 — macOS support: + VPN-aware interface detection and a single-interface UI when only one + interface exists. +- https://git.eeqj.de/sneak/rtnetmon/issues/3 — show Starlink status + lines when Starlink is the non-VPN gateway. diff --git a/cmd/rtnetmon/main.go b/cmd/rtnetmon/main.go index 3334586..9ab1c90 100644 --- a/cmd/rtnetmon/main.go +++ b/cmd/rtnetmon/main.go @@ -1,20 +1,18 @@ //go:build linux -// +build linux -// netmon – dual-interface network dashboard (curses) -// WTFPL – 2025-05-16 sneak@sneak.berlin +// Command rtnetmon is a dual-interface real-time network monitoring +// dashboard for Linux. WTFPL, sneak@sneak.berlin. package main import ( "log" - "os" "git.eeqj.de/sneak/rtnetmon/internal/cli" ) func main() { - if err := cli.Execute(); err != nil { + err := cli.Execute() + if err != nil { log.Fatal(err) - os.Exit(1) } } diff --git a/internal/cli/export_test.go b/internal/cli/export_test.go new file mode 100644 index 0000000..316dfe7 --- /dev/null +++ b/internal/cli/export_test.go @@ -0,0 +1,8 @@ +//go:build linux + +package cli + +import "github.com/spf13/cobra" + +// NewRootCmd exposes newRootCmd for external tests. +func NewRootCmd() *cobra.Command { return newRootCmd() } diff --git a/internal/cli/root.go b/internal/cli/root.go index bd45ef9..456cff5 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -1,6 +1,6 @@ //go:build linux -// +build linux +// Package cli wires command-line flags to the network monitor and runs it. package cli import ( @@ -15,8 +15,8 @@ import ( "git.eeqj.de/sneak/rtnetmon/internal/monitor" ) -// Config holds the application configuration -type Config struct { +// config holds the application configuration. +type config struct { IfaceA string LabelA string IfaceB string @@ -25,94 +25,104 @@ type Config struct { LogFile string } -var ( - cfg Config - rootCmd = &cobra.Command{ - Use: "rtnetmon", - Short: "Real-time network monitoring dashboard", - Long: `rtnetmon is a dual-interface network monitoring dashboard that provides -real-time visibility into network health, packet loss, and latency.`, - RunE: runMonitor, - } -) - -// Default hosts for monitoring -var ( - defaultReachabilityHosts = []string{ +// defaultReachabilityHosts is the default reachability host list. +func defaultReachabilityHosts() []string { + return []string{ "8.8.8.8", "8.8.4.4", "google.com", "github.com", "console.aws.amazon.com", "console.cloud.google.com", "fast.com", "datavi.be", "captive.apple.com", } +} - defaultPacketLossHosts = []string{ - "github.com", "google.com", "8.8.8.8", "captive.apple.com", "62.115.190.68", +// defaultPacketLossHosts is the default packet-loss host list. +func defaultPacketLossHosts() []string { + return []string{ + "github.com", "google.com", "8.8.8.8", + "captive.apple.com", "62.115.190.68", } +} - defaultTCPHosts = []string{ +// defaultTCPHosts is the default TCP connect host:port list. +func defaultTCPHosts() []string { + return []string{ "datavi.be:443", "fast.com:443", "console.aws.amazon.com:443", "console.cloud.google.com:443", "captive.apple.com:80", "google.com:443", } -) - -func init() { - // Define flags - rootCmd.Flags().StringVar(&cfg.IfaceA, "ifaceA", "gu0", "primary network interface") - rootCmd.Flags().StringVar(&cfg.LabelA, "labelA", "gu LAN - VPN outbound", "label for ifaceA") - rootCmd.Flags().StringVar(&cfg.IfaceB, "ifaceB", "backhaul0", "secondary network interface") - rootCmd.Flags().StringVar(&cfg.LabelB, "labelB", "Cox cable direct", "label for ifaceB") - rootCmd.Flags().StringSliceVar(&cfg.Hosts, "hosts", defaultReachabilityHosts, "comma-separated reachability hosts") - rootCmd.Flags().StringVar(&cfg.LogFile, "logfile", "/tmp/rtnetmon.log", "path to log file") - - // Bind flags to viper - _ = viper.BindPFlag("ifaceA", rootCmd.Flags().Lookup("ifaceA")) - _ = viper.BindPFlag("labelA", rootCmd.Flags().Lookup("labelA")) - _ = viper.BindPFlag("ifaceB", rootCmd.Flags().Lookup("ifaceB")) - _ = viper.BindPFlag("labelB", rootCmd.Flags().Lookup("labelB")) - _ = viper.BindPFlag("hosts", rootCmd.Flags().Lookup("hosts")) - _ = viper.BindPFlag("logfile", rootCmd.Flags().Lookup("logfile")) } -func runMonitor(cmd *cobra.Command, args []string) error { +// newRootCmd builds the cobra root command with its flags bound. +func newRootCmd() *cobra.Command { + cfg := &config{} + cmd := &cobra.Command{ + Use: "rtnetmon", + Short: "Real-time network monitoring dashboard", + Long: `rtnetmon is a dual-interface network monitoring dashboard that provides +real-time visibility into network health, packet loss, and latency.`, + RunE: func(_ *cobra.Command, _ []string) error { + return runMonitor(cfg) + }, + } + registerFlags(cmd, cfg) + + return cmd +} + +// registerFlags defines and viper-binds the command's flags. +func registerFlags(cmd *cobra.Command, cfg *config) { + f := cmd.Flags() + f.StringVar(&cfg.IfaceA, "ifaceA", "gu0", "primary network interface") + f.StringVar(&cfg.LabelA, "labelA", "gu LAN - VPN outbound", "label for ifaceA") + f.StringVar(&cfg.IfaceB, "ifaceB", "backhaul0", "secondary network interface") + f.StringVar(&cfg.LabelB, "labelB", "Cox cable direct", "label for ifaceB") + f.StringSliceVar(&cfg.Hosts, "hosts", defaultReachabilityHosts(), + "comma-separated reachability hosts") + f.StringVar(&cfg.LogFile, "logfile", "/tmp/rtnetmon.log", "path to log file") + + for _, name := range []string{ + "ifaceA", "labelA", "ifaceB", "labelB", "hosts", "logfile", + } { + _ = viper.BindPFlag(name, f.Lookup(name)) + } +} + +// runMonitor constructs and runs the monitor from cfg. +func runMonitor(cfg *config) error { monitor.Logf(cfg.LogFile, "Starting rtnetmon") - monitor.Logf(cfg.LogFile, "Monitoring interfaces %s and %s", cfg.IfaceA, cfg.IfaceB) + monitor.Logf(cfg.LogFile, "Monitoring interfaces %s and %s", + cfg.IfaceA, cfg.IfaceB) - // Create the monitor - mon := monitor.NewMonitor(cfg.IfaceA, cfg.LabelA, cfg.IfaceB, cfg.LabelB, cfg.LogFile) + mon := monitor.NewMonitor(cfg.IfaceA, cfg.LabelA, cfg.IfaceB, cfg.LabelB, + cfg.LogFile) - // Add reachability hosts for _, host := range cfg.Hosts { mon.AddReachabilityHost(host) } - // Add packet loss hosts - for _, host := range defaultPacketLossHosts { + for _, host := range defaultPacketLossHosts() { mon.AddPacketLossHost(host) } - // Add TCP hosts - for _, host := range defaultTCPHosts { + for _, host := range defaultTCPHosts() { mon.AddTCPHost(host) } - // Create context with signal handling ctx, cancel := context.WithCancel(context.Background()) defer cancel() - // Handle signals sig := make(chan os.Signal, 1) signal.Notify(sig, os.Interrupt, syscall.SIGTERM) + go func() { s := <-sig monitor.Logf(cfg.LogFile, "Signal received: %v", s) cancel() }() - // Run the monitor return mon.Run(ctx) } -// Execute runs the root command +// Execute builds the root command and runs it. func Execute() error { - return rootCmd.Execute() + return newRootCmd().Execute() } diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go index 6f0d373..4e6ade9 100644 --- a/internal/cli/root_test.go +++ b/internal/cli/root_test.go @@ -1,36 +1,25 @@ //go:build linux -// +build linux -package cli +package cli_test import ( "testing" + + "git.eeqj.de/sneak/rtnetmon/internal/cli" ) -// TestExecute tests that the CLI can be initialized -func TestExecute(t *testing.T) { - // This is a simple compilation test to ensure the CLI package compiles - // We can't easily test the full Execute() function as it starts the UI +func TestNewRootCmd(t *testing.T) { + t.Parallel() - // Test that rootCmd is properly initialized - if rootCmd == nil { - t.Fatal("rootCmd is nil") + cmd := cli.NewRootCmd() + if cmd.Use != "rtnetmon" { + t.Errorf("Use = %q, want %q", cmd.Use, "rtnetmon") } - if rootCmd.Use != "rtnetmon" { - t.Errorf("Expected rootCmd.Use to be 'rtnetmon', got '%s'", rootCmd.Use) - } - - // Test that default configuration is set - if len(defaultReachabilityHosts) == 0 { - t.Error("defaultReachabilityHosts is empty") - } - - if len(defaultPacketLossHosts) == 0 { - t.Error("defaultPacketLossHosts is empty") - } - - if len(defaultTCPHosts) == 0 { - t.Error("defaultTCPHosts is empty") + names := []string{"ifaceA", "labelA", "ifaceB", "labelB", "hosts", "logfile"} + for _, name := range names { + if cmd.Flags().Lookup(name) == nil { + t.Errorf("flag %q not registered", name) + } } } diff --git a/internal/monitor/export_test.go b/internal/monitor/export_test.go new file mode 100644 index 0000000..aff1b4c --- /dev/null +++ b/internal/monitor/export_test.go @@ -0,0 +1,20 @@ +//go:build linux + +package monitor + +// Test-only accessors exposing unexported state for white-box assertions. + +// ReachabilityHosts returns the configured reachability hosts. +func (m *Monitor) ReachabilityHosts() []string { return m.reachabilityHosts } + +// PacketLossHosts returns the configured packet-loss hosts. +func (m *Monitor) PacketLossHosts() []string { return m.packetLossHosts } + +// TCPHosts returns the configured TCP hosts. +func (m *Monitor) TCPHosts() []string { return m.tcpHosts } + +// InterfaceA returns the first monitored interface. +func (m *Monitor) InterfaceA() *InterfaceStatus { return m.interfaceA } + +// InterfaceB returns the second monitored interface. +func (m *Monitor) InterfaceB() *InterfaceStatus { return m.interfaceB } diff --git a/internal/monitor/loops.go b/internal/monitor/loops.go index d624c11..9671d62 100644 --- a/internal/monitor/loops.go +++ b/internal/monitor/loops.go @@ -1,278 +1,319 @@ //go:build linux -// +build linux package monitor import ( "context" + crand "crypto/rand" "math" - "math/rand" + "math/big" "strings" "sync" "time" ) -// UIUpdateChan is used to signal UI updates -var UIUpdateChan = make(chan struct{}, 100) +// Probe scheduling and change-detection thresholds. +const ( + jitterBaseMillis = 100 + jitterRangeMillis = 800 + lossChangeThreshold = 0.01 + tcpChangeThreshold = 20 // milliseconds +) -// reachLoop monitors reachability for hosts +// randomOffset returns a startup jitter to avoid clustering probes at the +// same instant across loops. crypto/rand is used so no weak PRNG is linked. +func randomOffset() time.Duration { + n, err := crand.Int(crand.Reader, big.NewInt(jitterRangeMillis)) + if err != nil { + return jitterBaseMillis * time.Millisecond + } + + return time.Duration(jitterBaseMillis+n.Int64()) * time.Millisecond +} + +// probeAll runs probe for each host concurrently and returns the results +// keyed by host. +func probeAll[T any](hosts []string, probe func(host string) T) map[string]T { + var ( + wg sync.WaitGroup + mu sync.Mutex + res = make(map[string]T, len(hosts)) + ) + + for _, h := range hosts { + wg.Add(1) + + go func(host string) { + defer wg.Done() + + v := probe(host) + + mu.Lock() + res[host] = v + mu.Unlock() + }(h) + } + + wg.Wait() + + return res +} + +// reachLoop monitors reachability for hosts on one interface. func (m *Monitor) reachLoop(ctx context.Context, st *InterfaceStatus, hosts []string) { - m.logf("Starting reachability monitoring for %s with %d hosts", st.Name, len(hosts)) - - // Add random offset to avoid clustering at 1-second intervals - randomOffset := time.Duration(100+rand.Intn(800)) * time.Millisecond - m.logf("Reachability monitoring for %s will start after %v offset", st.Name, randomOffset) - time.Sleep(randomOffset) + m.logf("Starting reachability monitoring for %s with %d hosts", + st.Name, len(hosts)) + time.Sleep(randomOffset()) tk := time.NewTicker(time.Second) defer tk.Stop() + for { select { case <-ctx.Done(): m.logf("Stopping reachability monitoring for %s", st.Name) + return case <-tk.C: - var wg sync.WaitGroup - res := make(map[string]bool, len(hosts)) - mu := sync.Mutex{} - for _, h := range hosts { - wg.Add(1) - go func(host string) { - defer wg.Done() - st.mu.Lock() - st.TotalICMPReq++ - // Increase meter value when a packet is sent - st.MeterValue++ - if st.MeterValue > m.MaxMeterValue { - st.MeterValue = m.MaxMeterValue - } - st.mu.Unlock() - - ok := m.pingOnce(st.Name, host) - mu.Lock() - res[host] = ok - mu.Unlock() - - st.mu.Lock() - if ok { - st.TotalICMPRep++ - // Decrease meter value when a packet is successfully received - st.MeterValue-- - if st.MeterValue < 0 { - st.MeterValue = 0 - } - // Only update spinner when packets are successfully received - st.Spin() - } else { - st.DroppedCount++ - st.LastDrop = time.Now() - - // Track lost packets per host - st.LostPackets[host]++ - - // Trigger UI update on ping failure - select { - case UIUpdateChan <- struct{}{}: - default: - } - } - st.mu.Unlock() - }(h) - } - wg.Wait() - - // Check if reachability status changed - statusChanged := false - st.mu.Lock() - for host, newStatus := range res { - if oldStatus, ok := st.Reachable[host]; !ok || oldStatus != newStatus { - statusChanged = true - break - } - } - - st.Reachable = res - st.LastPing = time.Now() - - // Check if all hosts are reachable - allReachable := true - for _, ok := range res { - if !ok { - allReachable = false - break - } - } - - // If all hosts are reachable, gradually decay the meter value - if allReachable && st.MeterValue > 0 { - st.MeterValue-- - } - - st.mu.Unlock() - - // Always trigger UI update when reachability status changes - if statusChanged { - select { - case UIUpdateChan <- struct{}{}: - default: - } - } + m.reachTick(ctx, st, hosts) } } } -// lossLoop monitors packet loss for hosts -func (m *Monitor) lossLoop(ctx context.Context, st *InterfaceStatus, hosts []string) { - m.logf("Starting packet loss monitoring for %s with %d hosts", st.Name, len(hosts)) +// reachTick pings every host concurrently and applies the results. +func (m *Monitor) reachTick(ctx context.Context, st *InterfaceStatus, hosts []string) { + res := probeAll(hosts, func(host string) bool { + return m.reachProbe(ctx, st, host) + }) + m.reachApply(st, res) +} - // Add random offset to avoid clustering at periodic intervals - randomOffset := time.Duration(100+rand.Intn(800)) * time.Millisecond - m.logf("Packet loss monitoring for %s will start after %v offset", st.Name, randomOffset) - time.Sleep(randomOffset) +// reachProbe pings a single host and updates per-host counters. +func (m *Monitor) reachProbe( + ctx context.Context, st *InterfaceStatus, host string, +) bool { + st.mu.Lock() + st.TotalICMPReq++ + st.MeterValue = min(st.MeterValue+1, m.MaxMeterValue) + st.mu.Unlock() + + ok := m.pingOnce(ctx, st.Name, host) + + st.mu.Lock() + defer st.mu.Unlock() + + if ok { + st.TotalICMPRep++ + st.MeterValue = max(st.MeterValue-1, 0) + st.Spin() + + return true + } + + st.DroppedCount++ + st.LastDrop = time.Now() + st.LostPackets[host]++ + + m.notifyUI() + + return false +} + +// reachApply stores the round's results and decays the meter when clean. +func (m *Monitor) reachApply(st *InterfaceStatus, res map[string]bool) { + st.mu.Lock() + changed := reachChanged(st.Reachable, res) + st.Reachable = res + st.LastPing = time.Now() + + if allReachable(res) && st.MeterValue > 0 { + st.MeterValue-- + } + st.mu.Unlock() + + if changed { + m.notifyUI() + } +} + +// reachChanged reports whether any host's reachability differs from before. +func reachChanged(old, cur map[string]bool) bool { + for host, newStatus := range cur { + if oldStatus, ok := old[host]; !ok || oldStatus != newStatus { + return true + } + } + + return false +} + +// allReachable reports whether every host in the map is reachable. +func allReachable(res map[string]bool) bool { + for _, ok := range res { + if !ok { + return false + } + } + + return true +} + +// lossLoop monitors packet loss for hosts on one interface. +func (m *Monitor) lossLoop(ctx context.Context, st *InterfaceStatus, hosts []string) { + m.logf("Starting packet loss monitoring for %s with %d hosts", + st.Name, len(hosts)) + time.Sleep(randomOffset()) tk := time.NewTicker(m.PacketLossPeriod) defer tk.Stop() + for { select { case <-ctx.Done(): m.logf("Stopping packet loss monitoring for %s", st.Name) + return case <-tk.C: - var wg sync.WaitGroup - res := make(map[string]float64, len(hosts)) - mu := sync.Mutex{} - for _, h := range hosts { - wg.Add(1) - go func(host string) { - defer wg.Done() - lp := m.lossPercent(st.Name, host) - mu.Lock() - res[host] = lp - mu.Unlock() - - st.mu.Lock() - if lp == 0 { - // Only update spinner when there's 0% packet loss - st.Spin() - } else { - // Calculate approximate number of lost packets based on loss percentage - lostPackets := int(math.Ceil(float64(m.PacketLossPings) * lp)) - - // Update dropped count with the number of lost packets - st.DroppedCount += lostPackets - - // Update last drop time if packets were lost - if lostPackets > 0 { - st.LastDrop = time.Now() - } - - // Track lost packets per host - st.LostPackets[host] += lostPackets - - // Trigger UI update on packet loss - select { - case UIUpdateChan <- struct{}{}: - default: - } - } - st.mu.Unlock() - }(h) - } - wg.Wait() - - // Check if loss status changed - statusChanged := false - st.mu.Lock() - for host, newLoss := range res { - if oldLoss, ok := st.Loss[host]; !ok || math.Abs(oldLoss-newLoss) > 0.01 { - statusChanged = true - break - } - } - - for k, v := range res { - st.Loss[k] = v - } - st.mu.Unlock() - - // Always trigger UI update when loss status changes - if statusChanged { - select { - case UIUpdateChan <- struct{}{}: - default: - } - } + m.lossTick(ctx, st, hosts) } } } -// tcpLoop monitors TCP connectivity for hosts +// lossTick measures loss for every host concurrently and applies results. +func (m *Monitor) lossTick(ctx context.Context, st *InterfaceStatus, hosts []string) { + res := probeAll(hosts, func(host string) float64 { + return m.lossProbe(ctx, st, host) + }) + m.lossApply(st, res) +} + +// lossProbe measures loss for a host and updates per-host counters. +func (m *Monitor) lossProbe( + ctx context.Context, st *InterfaceStatus, host string, +) float64 { + lp := m.lossPercent(ctx, st.Name, host) + + st.mu.Lock() + defer st.mu.Unlock() + + if lp == 0 { + st.Spin() + + return lp + } + + lostPackets := int(math.Ceil(float64(m.PacketLossPings) * lp)) + st.DroppedCount += lostPackets + + if lostPackets > 0 { + st.LastDrop = time.Now() + } + + st.LostPackets[host] += lostPackets + + m.notifyUI() + + return lp +} + +// lossApply stores the round's loss results. +func (m *Monitor) lossApply(st *InterfaceStatus, res map[string]float64) { + st.mu.Lock() + changed := lossChanged(st.Loss, res) + + for k, v := range res { + st.Loss[k] = v + } + st.mu.Unlock() + + if changed { + m.notifyUI() + } +} + +// lossChanged reports whether any host's loss moved beyond the threshold. +func lossChanged(old, cur map[string]float64) bool { + for host, newLoss := range cur { + if oldLoss, ok := old[host]; !ok || math.Abs(oldLoss-newLoss) > lossChangeThreshold { + return true + } + } + + return false +} + +// tcpLoop monitors TCP connectivity for hosts on one interface. func (m *Monitor) tcpLoop(ctx context.Context, st *InterfaceStatus, hosts []string) { m.logf("Starting TCP monitoring for %s with %d hosts", st.Name, len(hosts)) - - // Add random offset to avoid clustering at 1-second intervals - randomOffset := time.Duration(100+rand.Intn(800)) * time.Millisecond - m.logf("TCP monitoring for %s will start after %v offset", st.Name, randomOffset) - time.Sleep(randomOffset) + time.Sleep(randomOffset()) tk := time.NewTicker(time.Second) defer tk.Stop() + for { select { case <-ctx.Done(): m.logf("Stopping TCP monitoring for %s", st.Name) + return case <-tk.C: - statusChanged := false - - for _, hp := range hosts { - ms := float64(m.tcpDuration(st.Name, hp).Milliseconds()) - st.mu.Lock() - - // Check if TCP latency significantly changed - hist := st.TCP[hp] - if len(hist) > 0 { - lastMs := hist[len(hist)-1] - if math.Abs(lastMs-ms) > 20 { // 20ms threshold for significant change - statusChanged = true - } - } else { - // First measurement - statusChanged = true - } - - if ms < float64(m.TCPTimeout.Milliseconds()) { - // Only update spinner on successful TCP connections - st.Spin() - } else { - // Trigger UI update on TCP timeout - select { - case UIUpdateChan <- struct{}{}: - default: - } - } - - if len(hist) >= m.StatsHistory { - hist = hist[1:] - } - st.TCP[hp] = append(hist, ms) - - // Update lost packets for the host (without port) - hostName := strings.Split(hp, ":")[0] - if ms >= float64(m.TCPTimeout.Milliseconds()) { - st.LostPackets[hostName]++ - } - - st.mu.Unlock() - } - - // Always trigger UI update when TCP status changes significantly - if statusChanged { - select { - case UIUpdateChan <- struct{}{}: - default: - } - } + m.tcpTick(st, hosts) } } } + +// tcpTick measures TCP latency for each host and redraws on change. +func (m *Monitor) tcpTick(st *InterfaceStatus, hosts []string) { + changed := false + + for _, hp := range hosts { + if m.tcpProbe(st, hp) { + changed = true + } + } + + if changed { + m.notifyUI() + } +} + +// tcpProbe measures one host's latency, records it, and reports whether the +// latency changed significantly. +func (m *Monitor) tcpProbe(st *InterfaceStatus, hp string) bool { + ms := float64(m.tcpDuration(st.Name, hp).Milliseconds()) + + st.mu.Lock() + defer st.mu.Unlock() + + hist := st.TCP[hp] + changed := tcpSignificant(hist, ms) + + if ms < float64(m.TCPTimeout.Milliseconds()) { + st.Spin() + } else { + m.notifyUI() + } + + if len(hist) >= m.StatsHistory { + hist = hist[1:] + } + + st.TCP[hp] = append(hist, ms) + + host := strings.Split(hp, ":")[0] + if ms >= float64(m.TCPTimeout.Milliseconds()) { + st.LostPackets[host]++ + } + + return changed +} + +// tcpSignificant reports whether ms differs meaningfully from the last +// sample (or there is no prior sample). +func tcpSignificant(hist []float64, ms float64) bool { + if len(hist) == 0 { + return true + } + + return math.Abs(hist[len(hist)-1]-ms) > tcpChangeThreshold +} diff --git a/internal/monitor/monitor.go b/internal/monitor/monitor.go index 2f97fff..721f951 100644 --- a/internal/monitor/monitor.go +++ b/internal/monitor/monitor.go @@ -1,11 +1,14 @@ //go:build linux -// +build linux +// Package monitor implements the dual-interface real-time network +// monitoring dashboard: ICMP reachability, packet loss, TCP latency, and +// the terminal UI that renders them. package monitor import ( "context" "encoding/json" + "errors" "fmt" "math" "net" @@ -19,16 +22,48 @@ import ( tcell "github.com/gdamore/tcell/v2" ) -// Non-configurable constants (meter characters) +// Meter glyphs used to render the ASCII loss meter. const ( - // ASCII characters for the meter MeterStart = '[' MeterEnd = ']' MeterFill = '=' MeterEmpty = ' ' ) -// Monitor represents the network monitoring system +// Default monitor timing configuration. +const ( + defaultICMPTimeout = 500 * time.Millisecond + defaultTCPTimeout = 500 * time.Millisecond + defaultPacketLossPings = 20 + defaultPacketLossPeriod = 5 * time.Second + defaultStatsHistory = 300 + defaultScreenRefresh = 500 * time.Millisecond +) + +// Default display geometry (column widths and meter sizing). +const ( + defaultMeterWidth = 7 + defaultMeterFillWidth = 5 + defaultMaxMeterValue = 10 + defaultHostWidth = 30 + defaultNumWidth = 7 + defaultStdWidth = 8 + defaultNWidth = 6 + defaultLostWidth = 7 +) + +// Miscellaneous timing constants. +const ( + ipInfoTimeout = 2 * time.Second + lossQueryTimeout = 3 * time.Second + uiUpdateBuffer = 100 + logFileMode = 0o600 +) + +// errNoIPv4 is returned when an interface has no usable IPv4 address. +var errNoIPv4 = errors.New("no IPv4 address on interface") + +// Monitor represents the network monitoring system. type Monitor struct { // Configuration ICMPTimeout time.Duration @@ -63,95 +98,95 @@ type Monitor struct { // Runtime state screen tcell.Screen startTime time.Time + uiUpdate chan struct{} // Synchronization mu sync.RWMutex } -// NewMonitor creates a new Monitor instance with default settings +// NewMonitor creates a new Monitor instance with default settings. func NewMonitor(ifaceA, labelA, ifaceB, labelB, logFile string) *Monitor { return &Monitor{ - // Default timing configuration - ICMPTimeout: 500 * time.Millisecond, - TCPTimeout: 500 * time.Millisecond, - PacketLossPings: 20, - PacketLossPeriod: 5 * time.Second, - StatsHistory: 300, - ScreenRefresh: 500 * time.Millisecond, + ICMPTimeout: defaultICMPTimeout, + TCPTimeout: defaultTCPTimeout, + PacketLossPings: defaultPacketLossPings, + PacketLossPeriod: defaultPacketLossPeriod, + StatsHistory: defaultStatsHistory, + ScreenRefresh: defaultScreenRefresh, - // Default display configuration - MeterWidth: 7, - MeterFillWidth: 5, - MaxMeterValue: 10, - HostWidth: 30, - NumWidth: 7, - StdWidth: 8, - NWidth: 6, - LostWidth: 7, + MeterWidth: defaultMeterWidth, + MeterFillWidth: defaultMeterFillWidth, + MaxMeterValue: defaultMaxMeterValue, + HostWidth: defaultHostWidth, + NumWidth: defaultNumWidth, + StdWidth: defaultStdWidth, + NWidth: defaultNWidth, + LostWidth: defaultLostWidth, - // Initialize host lists reachabilityHosts: []string{}, packetLossHosts: []string{}, tcpHosts: []string{}, - // Initialize interfaces interfaceA: NewInterfaceStatus(ifaceA, labelA), interfaceB: NewInterfaceStatus(ifaceB, labelB), - // Logging - logFile: logFile, - + logFile: logFile, startTime: time.Now(), + uiUpdate: make(chan struct{}, uiUpdateBuffer), } } -// AddReachabilityHost adds a host for reachability monitoring +// AddReachabilityHost adds a host for reachability monitoring. func (m *Monitor) AddReachabilityHost(host string) { m.mu.Lock() defer m.mu.Unlock() + m.reachabilityHosts = append(m.reachabilityHosts, host) } -// AddPacketLossHost adds a host for packet loss monitoring +// AddPacketLossHost adds a host for packet loss monitoring. func (m *Monitor) AddPacketLossHost(host string) { m.mu.Lock() defer m.mu.Unlock() + m.packetLossHosts = append(m.packetLossHosts, host) } -// AddTCPHost adds a host:port for TCP connectivity monitoring +// AddTCPHost adds a host:port for TCP connectivity monitoring. func (m *Monitor) AddTCPHost(hostPort string) { m.mu.Lock() defer m.mu.Unlock() + m.tcpHosts = append(m.tcpHosts, hostPort) } -// Run starts the monitoring system +// Run starts the monitoring system. func (m *Monitor) Run(ctx context.Context) error { m.logf("Starting monitor run") - - // Initialize screen m.logf("Initializing screen") + scr, err := tcell.NewScreen() if err != nil { m.logf("Error creating screen: %v", err) + return fmt.Errorf("error creating screen: %w", err) } - if err = scr.Init(); err != nil { + + err = scr.Init() + if err != nil { m.logf("Error initializing screen: %v", err) + return fmt.Errorf("error initializing screen: %w", err) } + m.screen = scr m.logf("Screen initialized") - // Create cancellable context ctx, cancel := context.WithCancel(ctx) defer cancel() - // Handle keyboard events go m.keyboardEventLoop(ctx, cancel) - // Start monitoring goroutines m.logf("Starting monitoring goroutines") m.mu.RLock() reachHosts := append([]string{}, m.reachabilityHosts...) @@ -166,7 +201,6 @@ func (m *Monitor) Run(ctx context.Context) error { go m.tcpLoop(ctx, m.interfaceA, tcpHosts) go m.tcpLoop(ctx, m.interfaceB, tcpHosts) - // Run UI loop m.logf("Starting UI loop") m.uiLoop(ctx) m.logf("UI loop exited, monitor ending") @@ -174,36 +208,49 @@ func (m *Monitor) Run(ctx context.Context) error { return nil } -// keyboardEventLoop handles keyboard input -func (m *Monitor) keyboardEventLoop(ctx context.Context, cancel context.CancelFunc) { - m.logf("Starting keyboard event loop") - for { - if ev := m.screen.PollEvent(); ev != nil { - m.logf("Event received: %T", ev) - if ke, ok := ev.(*tcell.EventKey); ok { - m.logf("Key event: %v, rune: %c", ke.Key(), ke.Rune()) - if ke.Key() == tcell.KeyCtrlC || ke.Rune() == 'q' { - m.logf("Quit key detected") - cancel() - return - } - } - - // Trigger UI update on any event - select { - case UIUpdateChan <- struct{}{}: - default: - } - } +// notifyUI requests a screen redraw without blocking if one is pending. +func (m *Monitor) notifyUI() { + select { + case m.uiUpdate <- struct{}{}: + default: } } -// logf logs a formatted message -func (m *Monitor) logf(format string, v ...interface{}) { +// keyboardEventLoop handles keyboard input. The context is accepted for a +// uniform loop signature; shutdown is driven by cancel on the quit key and +// by the screen being finalized (PollEvent then returns nil). +func (m *Monitor) keyboardEventLoop(_ context.Context, cancel context.CancelFunc) { + m.logf("Starting keyboard event loop") + + for { + ev := m.screen.PollEvent() + if ev == nil { + continue + } + + m.logf("Event received: %T", ev) + + if ke, ok := ev.(*tcell.EventKey); ok { + m.logf("Key event: %v, rune: %c", ke.Key(), ke.Rune()) + + if ke.Key() == tcell.KeyCtrlC || ke.Rune() == 'q' { + m.logf("Quit key detected") + cancel() + + return + } + } + + m.notifyUI() + } +} + +// logf logs a formatted message. +func (m *Monitor) logf(format string, v ...any) { Logf(m.logFile, format, v...) } -// InterfaceStatus holds the runtime status for a network interface +// InterfaceStatus holds the runtime status for a network interface. type InterfaceStatus struct { Name, Label, IPInfo string Reachable map[string]bool @@ -218,7 +265,7 @@ type InterfaceStatus struct { mu sync.RWMutex } -// NewInterfaceStatus creates a new interface status +// NewInterfaceStatus creates a new interface status. func NewInterfaceStatus(name, label string) *InterfaceStatus { return &InterfaceStatus{ Name: name, @@ -232,137 +279,164 @@ func NewInterfaceStatus(name, label string) *InterfaceStatus { } } -// ipInfoResp holds the IP info response +// ipInfoResp holds the ipinfo.io response. type ipInfoResp struct { IP string `json:"ip"` Hostname string `json:"hostname"` Org string `json:"org"` } -// fetchIPInfo fetches IP information for an interface +// fetchIPInfo fetches public IP information as seen from an interface. func fetchIPInfo(iface string) string { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + ctx, cancel := context.WithTimeout(context.Background(), ipInfoTimeout) defer cancel() - out, _ := exec.CommandContext(ctx, "curl", "-s", "--interface", iface, "--max-time", "2", "ipinfo.io").Output() + + cmd := exec.CommandContext( //nolint:gosec // G204: fixed argv, operator CLI input + ctx, "curl", "-s", "--interface", iface, "--max-time", "2", "ipinfo.io") + + out, _ := cmd.Output() + var r ipInfoResp + _ = json.Unmarshal(out, &r) if r.IP == "" { return "(ipinfo error)" } + return fmt.Sprintf("%s [%s] %s", r.IP, r.Hostname, r.Org) } -// pingOnce performs a single ping -func (m *Monitor) pingOnce(iface, host string) bool { - ctx, cancel := context.WithTimeout(context.Background(), m.ICMPTimeout) +// pingOnce performs a single ping over the named interface. +func (m *Monitor) pingOnce(ctx context.Context, iface, host string) bool { + ctx, cancel := context.WithTimeout(ctx, m.ICMPTimeout) defer cancel() - return exec.CommandContext(ctx, "ping", "-I", iface, "-c1", "-W1", host).Run() == nil + + cmd := exec.CommandContext( //nolint:gosec // G204: fixed argv, operator CLI input + ctx, "ping", "-I", iface, "-c1", "-W1", host) + + return cmd.Run() == nil } -// lossPercent calculates packet loss percentage -func (m *Monitor) lossPercent(iface, host string) float64 { - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) +// lossPercent measures the packet loss fraction (0..1) for a host. +func (m *Monitor) lossPercent(ctx context.Context, iface, host string) float64 { + ctx, cancel := context.WithTimeout(ctx, lossQueryTimeout) defer cancel() - out, err := exec.CommandContext(ctx, "ping", "-q", "-i", "0.05", - "-c", fmt.Sprint(m.PacketLossPings), "-W1", "-I", iface, host).CombinedOutput() + + cmd := exec.CommandContext( //nolint:gosec // G204: fixed argv, operator CLI input + ctx, "ping", "-q", "-i", "0.05", + "-c", strconv.Itoa(m.PacketLossPings), "-W1", "-I", iface, host) + + out, err := cmd.CombinedOutput() if err != nil { return 1.0 } + for _, ln := range strings.Split(string(out), "\n") { - if strings.Contains(ln, "packet loss") { - for _, f := range strings.Fields(ln) { - if strings.HasSuffix(f, "%") { - p, _ := strconv.ParseFloat(strings.TrimSuffix(f, "%"), 64) - return p / 100.0 - } + if !strings.Contains(ln, "packet loss") { + continue + } + + for _, f := range strings.Fields(ln) { + before, ok := strings.CutSuffix(f, "%") + if !ok { + continue } + + p, _ := strconv.ParseFloat(before, 64) + + return p / percentFull } } + return 1.0 } -// localAddr gets the local address for an interface +// localAddr returns the first IPv4 address bound to an interface. func localAddr(iface string) (net.Addr, error) { ifi, err := net.InterfaceByName(iface) if err != nil { - return nil, err + return nil, fmt.Errorf("interface %s: %w", iface, err) } + add, _ := ifi.Addrs() for _, a := range add { if ipnet, ok := a.(*net.IPNet); ok && ipnet.IP.To4() != nil { return &net.TCPAddr{IP: ipnet.IP}, nil } } - return nil, fmt.Errorf("no IPv4 on %s", iface) + + return nil, fmt.Errorf("%w: %s", errNoIPv4, iface) } -// tcpDuration measures TCP connection duration +// tcpDuration measures how long a TCP connection to hp takes over iface. func (m *Monitor) tcpDuration(iface, hp string) time.Duration { la, err := localAddr(iface) if err != nil { return m.TCPTimeout } + d := net.Dialer{Timeout: m.TCPTimeout, LocalAddr: la} st := time.Now() + c, err := d.Dial("tcp", hp) if err != nil { return m.TCPTimeout } - c.Close() + + _ = c.Close() + return time.Since(st) } -// MinMaxAvgStd calculates min, max, average and standard deviation -func MinMaxAvgStd(xs []float64) (min, max, avg, std float64) { +// MinMaxAvgStd returns the minimum, maximum, mean and standard deviation. +func MinMaxAvgStd(xs []float64) (float64, float64, float64, float64) { if len(xs) == 0 { - return + return 0, 0, 0, 0 } - min, max = xs[0], xs[0] + + mn, mx := xs[0], xs[0] + var sum float64 + for _, v := range xs { - if v < min { - min = v - } - if v > max { - max = v - } + mn = min(mn, v) + mx = max(mx, v) sum += v } - avg = sum / float64(len(xs)) - var vs float64 + + avg := sum / float64(len(xs)) + + var variance float64 + for _, v := range xs { - d := v - avg - vs += d * d + variance += (v - avg) * (v - avg) } - std = math.Sqrt(vs / float64(len(xs))) - return + + return mn, mx, avg, math.Sqrt(variance / float64(len(xs))) } -// Spin advances the spinner frame +// Spin advances the spinner frame. func (st *InterfaceStatus) Spin() { - st.SpinFrame = (st.SpinFrame + 1) % len(Spins) + st.SpinFrame = (st.SpinFrame + 1) % len(spins()) } -// IsHealthy checks if the interface is healthy +// IsHealthy reports whether the interface currently looks healthy. func (st *InterfaceStatus) IsHealthy(tcpTimeout time.Duration) bool { st.mu.RLock() defer st.mu.RUnlock() - // Check if there are any currently unreachable hosts for _, ok := range st.Reachable { if !ok { return false } } - // Check if there's any current packet loss for _, lp := range st.Loss { if lp > 0 { return false } } - // Check if there are any TCP timeouts for _, hist := range st.TCP { if len(hist) == 0 || hist[len(hist)-1] >= float64(tcpTimeout.Milliseconds()) { return false @@ -372,23 +446,29 @@ func (st *InterfaceStatus) IsHealthy(tcpTimeout time.Duration) bool { return true } -// Spinners and braille characters -var ( - Spins = []rune{'|', '/', '-', '\\'} - BrailleSpins = []rune{ - '⠉', '⠘', '⠰', '⠠', '⠄', '⠆', '⠇', '⠋', - } -) +// spins returns the ASCII spinner frames. +func spins() []rune { + return []rune{'|', '/', '-', '\\'} +} -// Logf is a simple logging function -func Logf(logFile string, format string, v ...interface{}) { +// brailleSpins returns the braille clock spinner frames. +func brailleSpins() []rune { + return []rune{'⠉', '⠘', '⠰', '⠠', '⠄', '⠆', '⠇', '⠋'} +} + +// Logf appends a timestamped formatted message to logFile, if set. +func Logf(logFile, format string, v ...any) { if logFile == "" { return } - f, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + + f, err := os.OpenFile( //nolint:gosec // G304: operator --logfile path + logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, logFileMode) if err != nil { return } - defer f.Close() - fmt.Fprintf(f, time.Now().Format("2006-01-02 15:04:05.000 ")+format+"\n", v...) + defer func() { _ = f.Close() }() + + _, _ = fmt.Fprintf(f, + time.Now().Format("2006-01-02 15:04:05.000 ")+format+"\n", v...) } diff --git a/internal/monitor/monitor_test.go b/internal/monitor/monitor_test.go index 0c6cc8f..a0e92c7 100644 --- a/internal/monitor/monitor_test.go +++ b/internal/monitor/monitor_test.go @@ -1,125 +1,110 @@ //go:build linux -// +build linux -package monitor +package monitor_test import ( "testing" + + "git.eeqj.de/sneak/rtnetmon/internal/monitor" ) -// TestNewMonitor tests the creation of a new Monitor func TestNewMonitor(t *testing.T) { - // Test that we can create a monitor without errors - mon := NewMonitor("test0", "Test Interface A", "test1", "Test Interface B", "/tmp/test.log") + t.Parallel() - if mon == nil { - t.Fatal("NewMonitor returned nil") - } + mon := monitor.NewMonitor("test0", "Test Interface A", "test1", + "Test Interface B", "/tmp/test.log") - // Check interfaces - if mon.interfaceA == nil { + if mon.InterfaceA() == nil { t.Fatal("interfaceA is nil") } - if mon.interfaceB == nil { + if mon.InterfaceB() == nil { t.Fatal("interfaceB is nil") } - if mon.interfaceA.Name != "test0" { - t.Errorf("Expected interfaceA.Name to be 'test0', got '%s'", mon.interfaceA.Name) + if mon.InterfaceA().Name != "test0" { + t.Errorf("interfaceA.Name = %q, want %q", mon.InterfaceA().Name, "test0") } - if mon.interfaceB.Name != "test1" { - t.Errorf("Expected interfaceB.Name to be 'test1', got '%s'", mon.interfaceB.Name) + if mon.InterfaceB().Name != "test1" { + t.Errorf("interfaceB.Name = %q, want %q", mon.InterfaceB().Name, "test1") } - // Check default configuration if mon.ICMPTimeout.Milliseconds() != 500 { - t.Errorf("Expected ICMPTimeout to be 500ms, got %dms", mon.ICMPTimeout.Milliseconds()) + t.Errorf("ICMPTimeout = %dms, want 500ms", mon.ICMPTimeout.Milliseconds()) } if mon.PacketLossPings != 20 { - t.Errorf("Expected PacketLossPings to be 20, got %d", mon.PacketLossPings) + t.Errorf("PacketLossPings = %d, want 20", mon.PacketLossPings) } - // Check that host lists are initialized - if mon.reachabilityHosts == nil { + if mon.ReachabilityHosts() == nil { t.Error("reachabilityHosts is nil") } - if mon.packetLossHosts == nil { + if mon.PacketLossHosts() == nil { t.Error("packetLossHosts is nil") } - if mon.tcpHosts == nil { + if mon.TCPHosts() == nil { t.Error("tcpHosts is nil") } } -// TestAddHosts tests adding hosts to the monitor func TestAddHosts(t *testing.T) { - mon := NewMonitor("test0", "Test A", "test1", "Test B", "") + t.Parallel() + + mon := monitor.NewMonitor("test0", "Test A", "test1", "Test B", "") - // Test adding reachability hosts mon.AddReachabilityHost("8.8.8.8") mon.AddReachabilityHost("google.com") - if len(mon.reachabilityHosts) != 2 { - t.Errorf("Expected 2 reachability hosts, got %d", len(mon.reachabilityHosts)) + if len(mon.ReachabilityHosts()) != 2 { + t.Errorf("reachability hosts = %d, want 2", len(mon.ReachabilityHosts())) } - // Test adding packet loss hosts mon.AddPacketLossHost("github.com") - if len(mon.packetLossHosts) != 1 { - t.Errorf("Expected 1 packet loss host, got %d", len(mon.packetLossHosts)) + if len(mon.PacketLossHosts()) != 1 { + t.Errorf("packet loss hosts = %d, want 1", len(mon.PacketLossHosts())) } - // Test adding TCP hosts mon.AddTCPHost("google.com:443") mon.AddTCPHost("github.com:443") - if len(mon.tcpHosts) != 2 { - t.Errorf("Expected 2 TCP hosts, got %d", len(mon.tcpHosts)) + if len(mon.TCPHosts()) != 2 { + t.Errorf("tcp hosts = %d, want 2", len(mon.TCPHosts())) } } -// TestMinMaxAvgStd tests the statistics calculation function func TestMinMaxAvgStd(t *testing.T) { + t.Parallel() + tests := []struct { name string data []float64 wantMin, wantMax, wantAvg float64 }{ - { - name: "empty slice", - data: []float64{}, - wantMin: 0, wantMax: 0, wantAvg: 0, - }, - { - name: "single value", - data: []float64{5.0}, - wantMin: 5.0, wantMax: 5.0, wantAvg: 5.0, - }, - { - name: "multiple values", - data: []float64{1.0, 2.0, 3.0, 4.0, 5.0}, - wantMin: 1.0, wantMax: 5.0, wantAvg: 3.0, - }, + {"empty slice", []float64{}, 0, 0, 0}, + {"single value", []float64{5.0}, 5.0, 5.0, 5.0}, + {"multiple values", []float64{1.0, 2.0, 3.0, 4.0, 5.0}, 1.0, 5.0, 3.0}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - min, max, avg, _ := MinMaxAvgStd(tt.data) + t.Parallel() - if min != tt.wantMin { - t.Errorf("MinMaxAvgStd() min = %v, want %v", min, tt.wantMin) + mn, mx, avg, _ := monitor.MinMaxAvgStd(tt.data) + if mn != tt.wantMin { + t.Errorf("min = %v, want %v", mn, tt.wantMin) } - if max != tt.wantMax { - t.Errorf("MinMaxAvgStd() max = %v, want %v", max, tt.wantMax) + + if mx != tt.wantMax { + t.Errorf("max = %v, want %v", mx, tt.wantMax) } + if avg != tt.wantAvg { - t.Errorf("MinMaxAvgStd() avg = %v, want %v", avg, tt.wantAvg) + t.Errorf("avg = %v, want %v", avg, tt.wantAvg) } }) } diff --git a/internal/monitor/styles.go b/internal/monitor/styles.go index 6417cf9..423db33 100644 --- a/internal/monitor/styles.go +++ b/internal/monitor/styles.go @@ -1,23 +1,62 @@ //go:build linux -// +build linux package monitor import tcell "github.com/gdamore/tcell/v2" -// Color styles -var ( - CBrightGreen = tcell.StyleDefault.Foreground(tcell.ColorGreen).Bold(true) - CGreen = tcell.StyleDefault.Foreground(tcell.ColorGreen) - CDimGreen = tcell.StyleDefault.Foreground(tcell.ColorGreen).Dim(true) - CYellow = tcell.StyleDefault.Foreground(tcell.ColorYellow) - COrange = tcell.StyleDefault.Foreground(tcell.ColorOrange) - CRed = tcell.StyleDefault.Foreground(tcell.ColorRed) - CBrightRed = tcell.StyleDefault.Foreground(tcell.ColorRed).Bold(true) - CDefault = tcell.StyleDefault +// percentFull is the top of the 0..100 percentage scale. +const percentFull = 100.0 - // Rainbow colors for the spinner - CRainbow = []tcell.Style{ +// Meter fill thresholds, expressed as percent-good (100 = best, 0 = worst). +const ( + meterExcellent = 90 + meterGood = 75 + meterFair = 60 + meterMediocre = 40 + meterPoor = 20 +) + +// TCP latency thresholds, in milliseconds. +const ( + latencyGood = 50 + latencyModerate = 100 + latencyHigh = 200 +) + +// lossWarnPercent is the packet-loss level above which the display warns. +const lossWarnPercent = 5 + +func styleBrightGreen() tcell.Style { + return tcell.StyleDefault.Foreground(tcell.ColorGreen).Bold(true) +} + +func styleGreen() tcell.Style { + return tcell.StyleDefault.Foreground(tcell.ColorGreen) +} + +func styleDimGreen() tcell.Style { + return tcell.StyleDefault.Foreground(tcell.ColorGreen).Dim(true) +} + +func styleYellow() tcell.Style { + return tcell.StyleDefault.Foreground(tcell.ColorYellow) +} + +func styleOrange() tcell.Style { + return tcell.StyleDefault.Foreground(tcell.ColorOrange) +} + +func styleRed() tcell.Style { + return tcell.StyleDefault.Foreground(tcell.ColorRed) +} + +func styleBrightRed() tcell.Style { + return tcell.StyleDefault.Foreground(tcell.ColorRed).Bold(true) +} + +// rainbow returns the color cycle used for spinners and the clock. +func rainbow() []tcell.Style { + return []tcell.Style{ tcell.StyleDefault.Foreground(tcell.ColorRed), tcell.StyleDefault.Foreground(tcell.ColorOrange), tcell.StyleDefault.Foreground(tcell.ColorYellow), @@ -25,63 +64,55 @@ var ( tcell.StyleDefault.Foreground(tcell.ColorBlue), tcell.StyleDefault.Foreground(tcell.ColorPurple), } -) +} -// MeterColorForValue returns the appropriate color style for a meter value -// value: current value of the meter -// maxValue: maximum value of the meter -// reverse: if true, low values are good (green) and high values are bad (red) -// -// if false, high values are good (green) and low values are bad (red) +// MeterColorForValue returns the color style for a meter value. When reverse +// is true, low values are good (green) and high values bad (red); otherwise +// high values are good. func MeterColorForValue(value, maxValue int, reverse bool) tcell.Style { - // Calculate percentage - percentage := float64(value) / float64(maxValue) * 100 - - // For reverse mode (low is good), invert the percentage + percentage := float64(value) / float64(maxValue) * percentFull if reverse { - percentage = 100 - percentage + percentage = percentFull - percentage } - // Return color based on percentage (after any reversal) - // Now high percentage always means "good" and low percentage means "bad" switch { - case percentage >= 90: - return CBrightGreen - case percentage >= 75: - return CGreen - case percentage >= 60: - return CDimGreen - case percentage >= 40: - return CYellow - case percentage >= 20: - return COrange + case percentage >= meterExcellent: + return styleBrightGreen() + case percentage >= meterGood: + return styleGreen() + case percentage >= meterFair: + return styleDimGreen() + case percentage >= meterMediocre: + return styleYellow() + case percentage >= meterPoor: + return styleOrange() default: - return CRed + return styleRed() } } -// StyleLatency returns the appropriate style for latency values +// StyleLatency returns the style for a TCP latency in milliseconds. func StyleLatency(ms float64) tcell.Style { switch { - case ms < 50: - return CBrightGreen - case ms < 100: - return CGreen - case ms < 200: - return CYellow + case ms < latencyGood: + return styleBrightGreen() + case ms < latencyModerate: + return styleGreen() + case ms < latencyHigh: + return styleYellow() default: - return CRed + return styleRed() } } -// StyleLoss returns the appropriate style for packet loss percentages +// StyleLoss returns the style for a packet-loss percentage. func StyleLoss(p float64) tcell.Style { switch { case p == 0: - return CBrightGreen - case p < 5: - return CYellow + return styleBrightGreen() + case p < lossWarnPercent: + return styleYellow() default: - return CBrightRed + return styleBrightRed() } } diff --git a/internal/monitor/styles_test.go b/internal/monitor/styles_test.go index 325082a..a3da137 100644 --- a/internal/monitor/styles_test.go +++ b/internal/monitor/styles_test.go @@ -1,104 +1,48 @@ //go:build linux -// +build linux -package monitor +package monitor_test import ( "testing" tcell "github.com/gdamore/tcell/v2" + + "git.eeqj.de/sneak/rtnetmon/internal/monitor" ) -// TestMeterColorForValue tests the MeterColorForValue function func TestMeterColorForValue(t *testing.T) { - tests := []struct { - name string - value int - maxValue int - reverse bool - wantColor tcell.Style - }{ - // Reverse mode tests (low is good, high is bad) - { - name: "reverse mode: 0% (best)", - value: 0, - maxValue: 10, - reverse: true, - wantColor: CBrightGreen, - }, - { - name: "reverse mode: 10% (good)", - value: 1, - maxValue: 10, - reverse: true, - wantColor: CBrightGreen, - }, - { - name: "reverse mode: 50% (medium)", - value: 5, - maxValue: 10, - reverse: true, - wantColor: CYellow, - }, - { - name: "reverse mode: 90% (bad)", - value: 9, - maxValue: 10, - reverse: true, - wantColor: CRed, - }, - { - name: "reverse mode: 100% (worst)", - value: 10, - maxValue: 10, - reverse: true, - wantColor: CRed, - }, + t.Parallel() - // Normal mode tests (high is good, low is bad) - { - name: "normal mode: 0% (worst)", - value: 0, - maxValue: 10, - reverse: false, - wantColor: CRed, - }, - { - name: "normal mode: 10% (bad)", - value: 1, - maxValue: 10, - reverse: false, - wantColor: CRed, - }, - { - name: "normal mode: 50% (medium)", - value: 5, - maxValue: 10, - reverse: false, - wantColor: CYellow, - }, - { - name: "normal mode: 90% (good)", - value: 9, - maxValue: 10, - reverse: false, - wantColor: CBrightGreen, - }, - { - name: "normal mode: 100% (best)", - value: 10, - maxValue: 10, - reverse: false, - wantColor: CBrightGreen, - }, + green := tcell.StyleDefault.Foreground(tcell.ColorGreen).Bold(true) + yellow := tcell.StyleDefault.Foreground(tcell.ColorYellow) + red := tcell.StyleDefault.Foreground(tcell.ColorRed) + + tests := []struct { + name string + value, maxValue int + reverse bool + want tcell.Style + }{ + {"reverse 0% best", 0, 10, true, green}, + {"reverse 10% good", 1, 10, true, green}, + {"reverse 50% medium", 5, 10, true, yellow}, + {"reverse 90% bad", 9, 10, true, red}, + {"reverse 100% worst", 10, 10, true, red}, + {"normal 0% worst", 0, 10, false, red}, + {"normal 10% bad", 1, 10, false, red}, + {"normal 50% medium", 5, 10, false, yellow}, + {"normal 90% good", 9, 10, false, green}, + {"normal 100% best", 10, 10, false, green}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := MeterColorForValue(tt.value, tt.maxValue, tt.reverse) - if got != tt.wantColor { + t.Parallel() + + got := monitor.MeterColorForValue(tt.value, tt.maxValue, tt.reverse) + if got != tt.want { t.Errorf("MeterColorForValue(%d, %d, %v) = %v, want %v", - tt.value, tt.maxValue, tt.reverse, got, tt.wantColor) + tt.value, tt.maxValue, tt.reverse, got, tt.want) } }) } diff --git a/internal/monitor/ui.go b/internal/monitor/ui.go index 2734f8b..660160a 100644 --- a/internal/monitor/ui.go +++ b/internal/monitor/ui.go @@ -1,5 +1,4 @@ //go:build linux -// +build linux package monitor @@ -13,283 +12,331 @@ import ( tcell "github.com/gdamore/tcell/v2" ) -// Frame counter for the timestamp spinner (ticks once per second) -var ( - timestampSpinFrame = 0 - lastTimestampSpinUpdate = time.Now() +// Fixed rows at the top of the display. +const ( + rowTopRule = 0 + rowClock = 1 + rowBottomRule = 2 + rowRuntime = 3 + rowFirstIface = 5 ) -// Put writes text to the screen at the specified position with style +// Column offsets and spacing within the drawn output. +const ( + colLeft = 0 + colSpinner = 3 // after the "== " prefix + colMeter = 5 // after the spinner + colGap = 1 // single-space gap between fields + clockGap = 5 // space between the two clocks + lineStep = 1 + blockGap = 2 // blank line plus the following line + headerAdvance = 4 // rule + content + rule + blank line +) + +// ICMP summary table geometry. +const ( + icmpLabelWidth = 8 + icmpValWidth = 9 + icmpColGap = 4 +) + +// Put writes text to the screen at the specified position with style. func Put(scr tcell.Screen, x, y int, txt string, st tcell.Style) { for i, r := range txt { scr.SetContent(x+i, y, r, nil, st) } } -// HLine creates a horizontal line of the specified width +// HLine returns a horizontal line of the specified width. func HLine(w int) string { return strings.Repeat("=", w) } -// DrawRainbowText draws text with rainbow colors +// DrawRainbowText draws text cycling through the given color styles. func DrawRainbowText(scr tcell.Screen, x, y int, text string, colors []tcell.Style) { for i, char := range text { - // Cycle through the rainbow colors - colorIndex := i % len(colors) - style := colors[colorIndex] - // Draw the character with the current rainbow color + style := colors[i%len(colors)] scr.SetContent(x+i, y, char, nil, style) } } -// CreateMeter creates a visual meter using ASCII characters +// msStr formats a millisecond value as a compact "%.0fms" string. +func msStr(v float64) string { + return fmt.Sprintf("%.0fms", v) +} + +// CreateMeter creates a visual meter using ASCII characters. func (m *Monitor) CreateMeter(value int) (string, tcell.Style) { - if value > m.MaxMeterValue { - value = m.MaxMeterValue - } - if value < 0 { - value = 0 - } + value = max(min(value, m.MaxMeterValue), 0) - // Calculate the number of fill characters to show - fillCount := value * m.MeterFillWidth / m.MaxMeterValue - if fillCount > m.MeterFillWidth { - fillCount = m.MeterFillWidth - } + fillCount := min(value*m.MeterFillWidth/m.MaxMeterValue, m.MeterFillWidth) - // Build the meter string var b strings.Builder + b.WriteRune(MeterStart) - // Add fill characters - for i := 0; i < fillCount; i++ { + for range fillCount { b.WriteRune(MeterFill) } - // Add empty spaces - for i := 0; i < m.MeterFillWidth-fillCount; i++ { + for range m.MeterFillWidth - fillCount { b.WriteRune(MeterEmpty) } b.WriteRune(MeterEnd) - // Get the appropriate style for this meter value - // Using reverse=true because for packet loss, low values are good + // reverse=true: for packet loss, low values are good. style := MeterColorForValue(value, m.MaxMeterValue, true) return b.String(), style } -// DrawInterface draws the interface status on the screen +// DrawInterface draws the interface status on the screen, returning the next +// free row. func (m *Monitor) DrawInterface(scr tcell.Screen, y, w int, st *InterfaceStatus) int { - Put(scr, 0, y, HLine(w), CDefault) + Put(scr, colLeft, y, HLine(w), tcell.StyleDefault) - // header line healthy := st.IsHealthy(m.TCPTimeout) - style := CBrightGreen + y = m.drawHeader(scr, y, w, st, healthy) + + m.mu.RLock() + tcpHosts := append([]string{}, m.tcpHosts...) + m.mu.RUnlock() + + st.mu.RLock() + defer st.mu.RUnlock() + + y = drawReachability(scr, y, st) + y = drawPacketLoss(scr, y, st) + y = m.drawTCPTable(scr, y, st, tcpHosts) + y = drawICMPStats(scr, y, st) + + return y +} + +// drawHeader renders the interface title, spinner and meter line. +func (m *Monitor) drawHeader( + scr tcell.Screen, y, w int, st *InterfaceStatus, healthy bool, +) int { + style := styleBrightGreen() if !healthy { - style = CBrightRed + style = styleBrightRed() } + st.mu.RLock() header := fmt.Sprintf("%s: %s — %s", st.Name, st.Label, st.IPInfo) - spinChar := Spins[st.SpinFrame] + spinChar := spins()[st.SpinFrame] spinnerFrame := st.SpinFrame meterValue := st.MeterValue st.mu.RUnlock() - // Create the meter with appropriate style meter, meterStyle := m.CreateMeter(meterValue) + colors := rainbow() + spinnerStyle := colors[spinnerFrame%len(colors)] - // Get rainbow color for spinner (cycle through colors) - spinnerStyle := CRainbow[spinnerFrame%len(CRainbow)] + Put(scr, colLeft, y+lineStep, "== ", tcell.StyleDefault) + Put(scr, colSpinner, y+lineStep, string(spinChar)+" ", spinnerStyle) + Put(scr, colMeter, y+lineStep, meter+" ", meterStyle) + Put(scr, colMeter+m.MeterWidth+colGap, y+lineStep, header, style) + Put(scr, colLeft, y+blockGap, HLine(w), tcell.StyleDefault) - Put(scr, 0, y+1, "== ", CDefault) - Put(scr, 3, y+1, string(spinChar)+" ", spinnerStyle) // Colorful spinner - Put(scr, 5, y+1, meter+" ", meterStyle) // Colored meter - Put(scr, 5+m.MeterWidth+1, y+1, header, style) // Move the header after the meter - Put(scr, 0, y+2, HLine(w), CDefault) - y += 4 + return y + headerAdvance +} - /* reachability */ - st.mu.RLock() +// drawReachability renders the reachability summary. The caller holds +// st.mu.RLock. +func drawReachability(scr tcell.Screen, y int, st *InterfaceStatus) int { total := len(st.Reachable) good := 0 + for _, ok := range st.Reachable { if ok { good++ } } + age := time.Since(st.LastPing).Round(time.Second) - reachStyle := CBrightGreen + + reachStyle := styleBrightGreen() if good != total { - reachStyle = CBrightRed + reachStyle = styleBrightRed() } - // Get last drop time and age dropTimeStr := "never" dropAgeStr := "N/A" + if !st.LastDrop.IsZero() { dropTimeStr = st.LastDrop.Format("15:04:05") dropAgeStr = time.Since(st.LastDrop).Round(time.Second).String() } - // Simplified reachability line without drop info - Put(scr, 0, y, fmt.Sprintf("Reachable: %d/%d (at %s, age %s)", + Put(scr, colLeft, y, fmt.Sprintf("Reachable: %d/%d (at %s, age %s)", good, total, st.LastPing.Format("15:04:05"), age), reachStyle) - y++ + y += lineStep if good == total { - Put(scr, 0, y, "Unreachable: none", CDefault) + Put(scr, colLeft, y, "Unreachable: none", tcell.StyleDefault) } else { - var down []string + down := make([]string, 0, len(st.Reachable)) + for h, ok := range st.Reachable { if !ok { down = append(down, h) } } - // Sort for consistent display + sort.Strings(down) - // Add drop info to the end of the unreachable line - Put(scr, 0, y, fmt.Sprintf("Unreachable: %s (last drop at %s, age %s)", - strings.Join(down, ", "), dropTimeStr, dropAgeStr), CBrightRed) + Put(scr, colLeft, y, fmt.Sprintf("Unreachable: %s (last drop at %s, age %s)", + strings.Join(down, ", "), dropTimeStr, dropAgeStr), styleBrightRed()) } - y += 2 - /* packet loss */ - Put(scr, 0, y, "Packet Loss:", CDefault) - y++ + return y + blockGap +} - // Get all hosts and sort them for consistent display order - var lossHosts []string +// drawPacketLoss renders the per-host packet-loss list. The caller holds +// st.mu.RLock. +func drawPacketLoss(scr tcell.Screen, y int, st *InterfaceStatus) int { + Put(scr, colLeft, y, "Packet Loss:", tcell.StyleDefault) + y += lineStep + + lossHosts := make([]string, 0, len(st.Loss)) for host := range st.Loss { lossHosts = append(lossHosts, host) } + sort.Strings(lossHosts) - // Find the maximum length of host names for alignment maxHostLen := 0 for _, host := range lossHosts { - if len(host) > maxHostLen { - maxHostLen = len(host) - } + maxHostLen = max(maxHostLen, len(host)) } - // Add 1 for the colon - maxHostLen += 1 + maxHostLen++ // room for the colon for _, host := range lossHosts { - p := st.Loss[host] * 100 - // Use the maxHostLen for consistent alignment - Put(scr, 0, y, fmt.Sprintf("%-*s %5.0f%%", maxHostLen, host+":", p), StyleLoss(p)) - y++ + p := st.Loss[host] * percentFull + Put(scr, colLeft, y, fmt.Sprintf("%-*s %5.0f%%", maxHostLen, host+":", p), + StyleLoss(p)) + y += lineStep } + dAge := "N/A" if !st.LastDrop.IsZero() { dAge = time.Since(st.LastDrop).Round(time.Second).String() } - Put(scr, 0, y, fmt.Sprintf("Dropped: %d (last at %s, age %s)", - st.DroppedCount, st.LastDrop.Format("15:04:05"), dAge), CDefault) - y += 2 - /* TCP table */ - Put(scr, 0, y, "TCP Connect Stats:", CDefault) - y++ - // header row + Put(scr, colLeft, y, fmt.Sprintf("Dropped: %d (last at %s, age %s)", + st.DroppedCount, st.LastDrop.Format("15:04:05"), dAge), tcell.StyleDefault) + + return y + blockGap +} + +// drawTCPTable renders the TCP connect-stats table. The caller holds +// st.mu.RLock. +func (m *Monitor) drawTCPTable( + scr tcell.Screen, y int, st *InterfaceStatus, tcpHosts []string, +) int { + Put(scr, colLeft, y, "TCP Connect Stats:", tcell.StyleDefault) + y += lineStep + headerRow := fmt.Sprintf("%-*s %*s %*s %*s %*s %*s %*s %*s", m.HostWidth, "Host", m.NumWidth, "last", m.NumWidth, "min", m.NumWidth, "avg", m.NumWidth, "max", m.StdWidth, "stddev", m.NWidth, "n", m.LostWidth, "lost") - Put(scr, 0, y, headerRow, CDefault) - y++ - - m.mu.RLock() - tcpHosts := append([]string{}, m.tcpHosts...) - m.mu.RUnlock() + Put(scr, colLeft, y, headerRow, tcell.StyleDefault) + y += lineStep for _, hp := range tcpHosts { hist := st.TCP[hp] if len(hist) == 0 { continue } - last := hist[len(hist)-1] - mi, ma, av, sd := MinMaxAvgStd(hist) - // Get host name without port for lost packets lookup - hostName := strings.Split(hp, ":")[0] - lost := st.LostPackets[hostName] - - // Add lost column - row := fmt.Sprintf("%-*s %*s %*s %*s %*s %*s %*d %*d", - m.HostWidth, hp, - m.NumWidth, fmt.Sprintf("%.0fms", last), - m.NumWidth, fmt.Sprintf("%.0fms", mi), - m.NumWidth, fmt.Sprintf("%.0fms", av), - m.NumWidth, fmt.Sprintf("%.0fms", ma), - m.StdWidth, fmt.Sprintf("%.0fms", sd), - m.NWidth, len(hist), - m.LostWidth, lost, - ) - Put(scr, 0, y, row, CDefault) - // colourise individual numbers - Put(scr, m.HostWidth+1, y, fmt.Sprintf("%*s", m.NumWidth, fmt.Sprintf("%.0fms", last)), StyleLatency(last)) - Put(scr, m.HostWidth+1+m.NumWidth+1, y, fmt.Sprintf("%*s", m.NumWidth, fmt.Sprintf("%.0fms", mi)), StyleLatency(mi)) - Put(scr, m.HostWidth+1+m.NumWidth*2+2, y, fmt.Sprintf("%*s", m.NumWidth, fmt.Sprintf("%.0fms", av)), StyleLatency(av)) - Put(scr, m.HostWidth+1+m.NumWidth*3+3, y, fmt.Sprintf("%*s", m.NumWidth, fmt.Sprintf("%.0fms", ma)), StyleLatency(ma)) - Put(scr, m.HostWidth+1+m.NumWidth*4+4, y, fmt.Sprintf("%*s", m.StdWidth, fmt.Sprintf("%.0fms", sd)), CDefault) - // Colorize the lost packets column - lostStyle := CDefault - if lost > 0 { - lostStyle = CRed - } - Put(scr, m.HostWidth+1+m.NumWidth*4+4+m.StdWidth+1+m.NWidth+1, y, fmt.Sprintf("%*d", m.LostWidth, lost), lostStyle) - y++ - } - y++ - - /* ICMP stats - combined into a single line with headers */ - // Calculate total lost packets - lost := st.TotalICMPReq - st.TotalICMPRep - if lost < 0 { - lost = 0 + m.drawTCPRow(scr, y, st, hp, hist) + y += lineStep } - // Create styles based on values - lostStyle := CDefault - if lost > 0 { - lostStyle = CRed - } - - // Define column widths and positions - const valWidth = 9 - - // Format the values with right alignment - reqVal := fmt.Sprintf("%9d", st.TotalICMPReq) - repVal := fmt.Sprintf("%9d", st.TotalICMPRep) - lostVal := fmt.Sprintf("%9d", lost) - - // Header texts with the same width as values for right alignment - reqHeader := fmt.Sprintf("%9s", "Requests") - repHeader := fmt.Sprintf("%9s", "Replies") - lostHeader := fmt.Sprintf("%9s", "Lost") - - // Draw the header line - Put(scr, 0, y, " ", CDefault) - Put(scr, 8, y, reqHeader, CDefault) - Put(scr, 8+valWidth+4, y, repHeader, CDefault) - Put(scr, 8+2*(valWidth+4), y, lostHeader, CDefault) - y++ - - // Draw the values line - Put(scr, 0, y, "ICMP: ", CDefault) - Put(scr, 8, y, reqVal, CDefault) - Put(scr, 8+valWidth+4, y, repVal, CDefault) - Put(scr, 8+2*(valWidth+4), y, lostVal, lostStyle) - - y += 2 - st.mu.RUnlock() - return y + return y + lineStep } -// uiLoop runs the UI event loop +// drawTCPRow renders one TCP host's statistics row. The caller holds +// st.mu.RLock. +func (m *Monitor) drawTCPRow( + scr tcell.Screen, y int, st *InterfaceStatus, hp string, hist []float64, +) { + last := hist[len(hist)-1] + mi, ma, av, sd := MinMaxAvgStd(hist) + + host := strings.Split(hp, ":")[0] + lost := st.LostPackets[host] + + row := fmt.Sprintf("%-*s %*s %*s %*s %*s %*s %*d %*d", + m.HostWidth, hp, + m.NumWidth, msStr(last), + m.NumWidth, msStr(mi), + m.NumWidth, msStr(av), + m.NumWidth, msStr(ma), + m.StdWidth, msStr(sd), + m.NWidth, len(hist), + m.LostWidth, lost, + ) + Put(scr, colLeft, y, row, tcell.StyleDefault) + + // Overlay the numeric columns colored by value, at the same offsets the + // base row above laid them out. + x := m.HostWidth + colGap + Put(scr, x, y, fmt.Sprintf("%*s", m.NumWidth, msStr(last)), StyleLatency(last)) + x += m.NumWidth + colGap + Put(scr, x, y, fmt.Sprintf("%*s", m.NumWidth, msStr(mi)), StyleLatency(mi)) + x += m.NumWidth + colGap + Put(scr, x, y, fmt.Sprintf("%*s", m.NumWidth, msStr(av)), StyleLatency(av)) + x += m.NumWidth + colGap + Put(scr, x, y, fmt.Sprintf("%*s", m.NumWidth, msStr(ma)), StyleLatency(ma)) + x += m.NumWidth + colGap + Put(scr, x, y, fmt.Sprintf("%*s", m.StdWidth, msStr(sd)), tcell.StyleDefault) + x += m.StdWidth + colGap + x += m.NWidth + colGap // n column already drawn by the base row + + lostStyle := tcell.StyleDefault + if lost > 0 { + lostStyle = styleRed() + } + + Put(scr, x, y, fmt.Sprintf("%*d", m.LostWidth, lost), lostStyle) +} + +// drawICMPStats renders the ICMP request/reply/lost summary. The caller +// holds st.mu.RLock. +func drawICMPStats(scr tcell.Screen, y int, st *InterfaceStatus) int { + lost := max(st.TotalICMPReq-st.TotalICMPRep, 0) + + lostStyle := tcell.StyleDefault + if lost > 0 { + lostStyle = styleRed() + } + + reqCol := icmpLabelWidth + repCol := reqCol + icmpValWidth + icmpColGap + lostCol := repCol + icmpValWidth + icmpColGap + + Put(scr, reqCol, y, fmt.Sprintf("%*s", icmpValWidth, "Requests"), tcell.StyleDefault) + Put(scr, repCol, y, fmt.Sprintf("%*s", icmpValWidth, "Replies"), tcell.StyleDefault) + Put(scr, lostCol, y, fmt.Sprintf("%*s", icmpValWidth, "Lost"), tcell.StyleDefault) + y += lineStep + + reqStr := fmt.Sprintf("%*d", icmpValWidth, st.TotalICMPReq) + repStr := fmt.Sprintf("%*d", icmpValWidth, st.TotalICMPRep) + lostStr := fmt.Sprintf("%*d", icmpValWidth, lost) + + Put(scr, colLeft, y, "ICMP: ", tcell.StyleDefault) + Put(scr, reqCol, y, reqStr, tcell.StyleDefault) + Put(scr, repCol, y, repStr, tcell.StyleDefault) + Put(scr, lostCol, y, lostStr, lostStyle) + + return y + blockGap +} + +// uiLoop runs the UI event loop. func (m *Monitor) uiLoop(ctx context.Context) { m.logf("UI loop started") + defer func() { m.logf("UI loop cleanup") m.screen.Clear() @@ -298,67 +345,53 @@ func (m *Monitor) uiLoop(ctx context.Context) { m.logf("Screen finalized") }() - // Load PST location pstLoc, err := time.LoadLocation("America/Los_Angeles") if err != nil { m.logf("Error loading PST location: %v", err) + pstLoc = time.UTC } - // Function to draw the screen + timestampSpinFrame := 0 + lastTimestampSpinUpdate := time.Now() + drawScreen := func() { w, _ := m.screen.Size() m.screen.Clear() - // Draw the top horizontal line - Put(m.screen, 0, 0, HLine(w), CDefault) + Put(m.screen, colLeft, rowTopRule, HLine(w), tcell.StyleDefault) - // Get current time and format it now := time.Now() timeStr := now.Format(time.RFC1123Z) - - // Format time in PST pstTimeStr := now.In(pstLoc).Format(time.RFC1123Z) - // Update the timestamp spinner once per second if time.Since(lastTimestampSpinUpdate) >= time.Second { - timestampSpinFrame = (timestampSpinFrame + 1) % len(BrailleSpins) + timestampSpinFrame = (timestampSpinFrame + 1) % len(brailleSpins()) lastTimestampSpinUpdate = now } - // Get braille spinner character - brailleChar := BrailleSpins[timestampSpinFrame] + brailleChar := brailleSpins()[timestampSpinFrame] + colors := rainbow() - // Draw the header with timestamp and spinner - Put(m.screen, 0, 1, "== ", CDefault) - Put(m.screen, 3, 1, string(brailleChar)+" ", CDefault) + Put(m.screen, colLeft, rowClock, "== ", tcell.StyleDefault) + Put(m.screen, colSpinner, rowClock, string(brailleChar)+" ", tcell.StyleDefault) + DrawRainbowText(m.screen, colMeter, rowClock, timeStr, colors) + DrawRainbowText(m.screen, colMeter+len(timeStr)+clockGap, rowClock, + pstTimeStr, colors) + Put(m.screen, colLeft, rowBottomRule, HLine(w), tcell.StyleDefault) - // Draw the timestamp with rainbow colors - DrawRainbowText(m.screen, 5, 1, timeStr, CRainbow) + runtime := time.Since(m.startTime).Round(time.Second).String() + Put(m.screen, colLeft, rowRuntime, "Runtime: "+runtime, tcell.StyleDefault) - // Add 5 space gap and PST time with rainbow colors - DrawRainbowText(m.screen, 5+len(timeStr)+5, 1, pstTimeStr, CRainbow) - - // Draw the bottom horizontal line - Put(m.screen, 0, 2, HLine(w), CDefault) - - // Draw the runtime - Put(m.screen, 0, 3, "Runtime: "+time.Since(m.startTime).Round(time.Second).String(), CDefault) - - // Draw interfaces - y := 5 + y := rowFirstIface y = m.DrawInterface(m.screen, y, w, m.interfaceA) _ = m.DrawInterface(m.screen, y, w, m.interfaceB) - // Show the screen m.screen.Show() } - // Initial draw drawScreen() - // Even without a ticker, ensure we update at least every second - // This is a backup in case there are no spinner updates backupTicker := time.NewTicker(time.Second) defer backupTicker.Stop() @@ -366,12 +399,11 @@ func (m *Monitor) uiLoop(ctx context.Context) { select { case <-ctx.Done(): m.logf("Context cancelled, exiting UI loop") + return - case <-UIUpdateChan: - // Update on spinner ticks (no rate limiting) + case <-m.uiUpdate: drawScreen() case <-backupTicker.C: - // Fallback to ensure we update at least once per second drawScreen() } } diff --git a/script/bootstrap b/script/bootstrap new file mode 100755 index 0000000..7a3adbd --- /dev/null +++ b/script/bootstrap @@ -0,0 +1,70 @@ +#!/bin/sh +# script/bootstrap: install all dependencies needed to build and develop +# this repo, idempotently. Assumes nothing is present (not git, make, or +# go). Base tooling comes from nix, apt, brew, or apk (detected in that +# order; apt runs noninteractive). golangci-lint is deliberately not +# installed: linting runs only in docker, via script/lint and the +# Dockerfile lint phase. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +PKGMGR="" +SUDO="" + +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 +pkg_install() { + detect_pkgmgr + case "$PKGMGR" in + nix) nix-env -iA "nixpkgs.$1" ;; + apt) $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 +} + +main() { + cd "$ROOT" + + if missing git; then pkg_install git git git git; fi + if missing make; then pkg_install gnumake make make make; fi + if missing go; then pkg_install go golang go go; fi + + # docker is platform-specific and out of scope for a package-manager + # bootstrap, but script/lint and script/test need it. + if missing docker; then + echo "bootstrap: docker not found; script/lint and script/test require it" >&2 + fi + + go mod download + + echo "bootstrap complete" +} + +main "$@" diff --git a/script/check b/script/check new file mode 100755 index 0000000..68e22d1 --- /dev/null +++ b/script/check @@ -0,0 +1,14 @@ +#!/bin/sh +# script/check: run all checks (test, lint, fmt-check). Our 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 "$@" diff --git a/script/cibuild b/script/cibuild new file mode 100755 index 0000000..94b1a35 --- /dev/null +++ b/script/cibuild @@ -0,0 +1,19 @@ +#!/bin/sh +# script/cibuild: run the CI build. Runs script/bootstrap first (a pristine +# checkout has nothing installed, and script/fmt-check runs gofmt on the +# host), then script/check, then builds the image with --no-cache so the +# shipped image is built from a fresh run of its own gate phases. The Gitea +# workflow runs this on push. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" + +main() { + cd "$ROOT" + "$SCRIPT_DIR/bootstrap" + "$SCRIPT_DIR/check" + docker build --no-cache -t "$("$SCRIPT_DIR/projectname")" . +} + +main "$@" diff --git a/script/docker b/script/docker new file mode 100755 index 0000000..c643893 --- /dev/null +++ b/script/docker @@ -0,0 +1,14 @@ +#!/bin/sh +# script/docker: build the Docker image tagged with the project name. +# --no-cache so the lint and test gate phases actually run. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" + +main() { + cd "$ROOT" + docker build --no-cache -t "$("$SCRIPT_DIR/projectname")" . +} + +main "$@" diff --git a/script/fmt b/script/fmt new file mode 100755 index 0000000..709b790 --- /dev/null +++ b/script/fmt @@ -0,0 +1,16 @@ +#!/bin/sh +# script/fmt: format all Go code (writes). Formatting is the one gate that +# runs on the host rather than in docker. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +main() { + cd "$ROOT" + gofmt -s -w . + if command -v goimports >/dev/null 2>&1; then + goimports -w . + fi +} + +main "$@" diff --git a/script/fmt-check b/script/fmt-check new file mode 100755 index 0000000..d25106f --- /dev/null +++ b/script/fmt-check @@ -0,0 +1,18 @@ +#!/bin/sh +# script/fmt-check: check Go formatting (read-only). Same scope as +# script/fmt, but fails instead of writing. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +main() { + cd "$ROOT" + out="$(gofmt -s -l .)" + if [ -n "$out" ]; then + echo "gofmt needed on:" + echo "$out" + exit 1 + fi +} + +main "$@" diff --git a/script/install-precommit b/script/install-precommit new file mode 100755 index 0000000..7b119e9 --- /dev/null +++ b/script/install-precommit @@ -0,0 +1,15 @@ +#!/bin/sh +# script/install-precommit: install the git pre-commit hook that runs +# script/precommit. Our extension to scripts-to-rule-them-all. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +main() { + cd "$ROOT" + printf '#!/bin/sh\nset -e\nscript/precommit\n' > .git/hooks/pre-commit + chmod +x .git/hooks/pre-commit + echo "pre-commit hook installed: runs script/precommit" +} + +main "$@" diff --git a/script/lint b/script/lint new file mode 100755 index 0000000..8996765 --- /dev/null +++ b/script/lint @@ -0,0 +1,16 @@ +#!/bin/sh +# script/lint: run the linter. golangci-lint is never installed on the host; +# it runs only in docker, as the `lint` phase of the Dockerfile. --no-cache +# forces the phase to re-execute, so a cached layer cannot report a pass it +# did not earn. The build is tagged so no dangling image is left behind. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +main() { + cd "$ROOT" + docker build --no-cache --target lint \ + -t "$(script/projectname)-lint" . +} + +main "$@" diff --git a/script/precommit b/script/precommit new file mode 100755 index 0000000..95033c9 --- /dev/null +++ b/script/precommit @@ -0,0 +1,21 @@ +#!/bin/sh +# script/precommit: run by the git pre-commit hook; fails the commit if +# checks fail. Our extension to scripts-to-rule-them-all. Go repo extra: +# `go mod tidy` must not change go.mod/go.sum. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" + +main() { + cd "$ROOT" + go mod tidy + git diff --exit-code -- go.mod go.sum || { + echo "precommit: go mod tidy changed go.mod/go.sum;" \ + "stage the changes and retry" >&2 + exit 1 + } + "$SCRIPT_DIR/check" +} + +main "$@" diff --git a/script/projectname b/script/projectname new file mode 100755 index 0000000..399f7dd --- /dev/null +++ b/script/projectname @@ -0,0 +1,11 @@ +#!/bin/sh +# script/projectname: output the name of this project. Our extension to +# scripts-to-rule-them-all. Scripts that need the name (e.g. script/docker) +# call this so they stay identical across repos. +set -eu + +main() { + echo "rtnetmon" +} + +main "$@" diff --git a/script/setup b/script/setup new file mode 100755 index 0000000..662b750 --- /dev/null +++ b/script/setup @@ -0,0 +1,13 @@ +#!/bin/sh +# script/setup: make a fresh clone ready for development: install +# dependencies (script/bootstrap) and the git pre-commit hook. +set -eu + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" + +main() { + "$SCRIPT_DIR/bootstrap" + "$SCRIPT_DIR/install-precommit" +} + +main "$@" diff --git a/script/test b/script/test new file mode 100755 index 0000000..08a5bc5 --- /dev/null +++ b/script/test @@ -0,0 +1,16 @@ +#!/bin/sh +# script/test: run the test suite as the `test` phase of the Dockerfile, +# with --no-cache so the tests actually re-run. The build is tagged so no +# dangling image is left behind. The 90s per-package timeout matches the +# org-wide backstop in REPO_POLICIES.md. +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" + +main() { + cd "$ROOT" + docker build --no-cache --target test \ + -t "$(script/projectname)-test" . +} + +main "$@"