diff options
Diffstat (limited to 'docs/design')
| -rw-r--r-- | docs/design/2026-06-25-testinfra-validation.org | 238 | ||||
| -rw-r--r-- | docs/design/2026-06-25-zfs-vm-test-coverage.org | 139 | ||||
| -rw-r--r-- | docs/design/2026-06-29-waybar-network-module-spec.org | 2094 | ||||
| -rw-r--r-- | docs/design/2026-06-29-waybar-timer-module-spec.org | 217 | ||||
| -rw-r--r-- | docs/design/2026-06-29-zfs-pre-snapshot-installer.org | 106 | ||||
| -rw-r--r-- | docs/design/2026-06-30-captive-portal-login.org | 89 | ||||
| -rw-r--r-- | docs/design/2026-07-02-waybar-expansion-animation-feasibility.org | 53 | ||||
| -rw-r--r-- | docs/design/maintenance-console-design-ideas.org | 527 | ||||
| -rw-r--r-- | docs/design/system-monitor-design-ideas.org | 1008 |
9 files changed, 4471 insertions, 0 deletions
diff --git a/docs/design/2026-06-25-testinfra-validation.org b/docs/design/2026-06-25-testinfra-validation.org new file mode 100644 index 0000000..5c82aa2 --- /dev/null +++ b/docs/design/2026-06-25-testinfra-validation.org @@ -0,0 +1,238 @@ +#+TITLE: Design: Testinfra Post-Install Validation for archsetup +#+AUTHOR: Craig Jennings +#+DATE: 2026-06-25 +#+STATUS: Accepted (2026-06-25) + +* Problem + +The VM integration harness (=scripts/testing/run-test.sh=) runs archsetup in a +QEMU VM, then verifies the result two ways: + +1. Parses archsetup's own install log for its Error Summary and the + =ARCHSETUP_EXECUTION_COMPLETE= marker (did the script finish, did it log + errors). +2. Runs =run_all_validations= from =scripts/testing/lib/validation.sh= — a + hand-rolled, shell-based post-install assertion sweep of ~26 checks over SSH. + +The shell sweep works, but each check is 6-40 lines of =ssh_cmd= + +=validation_pass/fail= + =attribute_issue= boilerplate, the pass/fail counters +are hand-maintained globals, and the reporting is bespoke. Adding or reading a +check is heavier than it should be, and growing the suite (archsetup configures +far more than the 26 checks cover) compounds that weight. + +This doc proposes porting the post-install validation to Testinfra (Python + +pytest) for more expressive checks and better reporting, then growing coverage. + +* Decision + +Port the post-install validation layer to Testinfra + pytest, reaching parity +with the existing =validation.sh= sweep, then expand coverage. Recorded +rationale: the up-front port cost (parity rewrite + a test-only dependency) is +an accepted trade — the priority is a robust, well-reported, growing validation +suite over feature speed. The framework swap alone buys ergonomics and +reporting, not coverage, so it is paired with real new coverage (below). + +This replaces the shell sweep; it does not touch archsetup's own install-log +parsing (that stays as a separate signal). The full coverage expansion (P4) +lands in this task too, sequenced strictly after the parity cutover so the +parity verification stays clean. + +* Current harness (what exists today) + +** Flow (run-test.sh) +1. Revert VM to base snapshot, boot, wait for SSH. +2. =capture_pre_install_state=. +3. Bundle + copy archsetup + dotfiles into the VM, run archsetup in background, + poll to completion. +4. =capture_post_install_state=. +5. =run_all_validations= (the shell sweep). +6. =analyze_log_diff= + =generate_issue_report= (issue attribution). +7. Explicit pass/fail exit code; cleanup. + +** The shell sweep (validation.sh) +~26 checks under =run_all_validations=: user created / shell / groups, dotfiles, +yay, pacman working, window manager, firewall, DNS, avahi, fail2ban, +NetworkManager, emacs, git config, dev tools, zfs, boot config, autologin, +gnome-keyring, terminus font, mkinitcpio hooks, initramfs consolefont, nvme +module, archsetup log, state markers. + +** Issue attribution +=attribute_issue <msg> <bucket>= sorts each failure into one of three arrays — +=ARCHSETUP_ISSUES=, =BASE_INSTALL_ISSUES=, =UNKNOWN_ISSUES= — and +=generate_issue_report= writes them out (base-install issues route to the +archzfs inbox). This is domain logic Testinfra has no equivalent for; the port +must preserve it. + +** Connection +=ssh_cmd= uses =sshpass -p "$ROOT_PASSWORD" ssh ... -p "$SSH_PORT" root@$VM_IP=, +with =VM_IP=localhost=, =SSH_PORT=2222=, =ROOT_PASSWORD=archsetup=. + +* Design + +** Where Testinfra fits +Replace the =run_all_validations= call (step 5) with a pytest invocation against +the running VM. Steps 1-4 and 6-7 are unchanged; =analyze_log_diff= stays. +Testinfra connects over the same SSH the harness already exposes. + +** Connection model +Testinfra's paramiko/ssh backend targets the live VM via its host spec: + +#+begin_src sh +pytest scripts/testing/tests/ \ + --hosts="ssh://root@localhost:2222" \ + --ssh-config=<generated> \ + --json-report --json-report-file="$TEST_RESULTS_DIR/testinfra.json" +#+end_src + +Password auth: generate a throwaway ssh-config (or reuse sshpass via a +=--ssh-identity= once archsetup drops the key, but at validation time we only +have the root password). Simplest: a tiny generated ssh config + sshpass +wrapper, or switch the test VM to a known test key injected pre-run. Open +question below. + +** Test layout +#+begin_example +scripts/testing/tests/ + conftest.py # host fixture, markers, attribution hook, report glue + test_users.py # user created / shell / groups + test_dotfiles.py # stow symlinks, readable by user + test_packages.py # yay, pacman working, dev tools, key packages + test_services.py # firewall, dns, avahi, fail2ban, networkmanager + test_boot.py # zfs, mkinitcpio hooks, nvme, consolefont, terminus + test_desktop.py # window manager, autologin, gnome-keyring + test_archsetup.py # install log, state markers + test_hardening.py # NEW: sshd drop-in, sysctl, /etc fstab perms, backups +#+end_example + +** Example tests (parity) +#+begin_src python +def test_ufw_enabled(host): + assert host.service("ufw").is_enabled + +def test_user_cjennings_exists(host): + u = host.user("cjennings") + assert u.exists + assert u.shell == "/usr/bin/zsh" + +def test_zshrc_stowed_and_readable(host): + f = host.file("/home/cjennings/.zshrc") + assert f.is_symlink + assert ".dotfiles/" in f.linked_to + assert f.exists # not broken + assert host.run("sudo -u cjennings test -r %s" % f.path).rc == 0 + +def test_mkinitcpio_systemd_hook(host): + # non-ZFS systems delegate fsck from udev to systemd + conf = host.file("/etc/mkinitcpio.conf").content_string + assert "systemd" in conf +#+end_src + +Compare =test_ufw_enabled= (1 line) to the current =validate_firewall= (8 lines +of ssh_cmd + branch + counters). + +** Preserving issue attribution +Map the three buckets to pytest markers and collect them in a =conftest.py= +hook: + +#+begin_src python +@pytest.mark.attribution("archsetup") # or "base_install" / "unknown" +def test_ufw_enabled(host): ... +#+end_src + +A =pytest_runtest_makereport= hook records each failure under its marker's +bucket and writes the same three-way report =generate_issue_report= produces +(base-install failures still route to the archzfs inbox). Default bucket = +archsetup when unmarked. + +** Tiered strategy +Markers =@pytest.mark.smoke= (user, key packages, dotfiles present) and +=@pytest.mark.integration= (services, configs, boot). =pytest -m smoke= for a +fast gate, full run otherwise. Drop the task's original X11/startx end-to-end +slice — the fleet is Wayland/Hyprland and headless GUI e2e is flaky and +expensive; a Wayland-session smoke check can be reconsidered later as its own +task. + +** Reporting +=pytest-json-report= (or junit-xml) → =$TEST_RESULTS_DIR/=, surfaced in the +test report alongside the install-log analysis. pytest's own per-test +pass/fail/skip output replaces the hand-maintained counters. + +* Coverage + +** Parity (port all current checks) +All ~26 =validation.sh= checks, grouped per the layout above. + +** Expansion (new — the coverage win) +archsetup configures much that isn't validated today. Candidates: +- sshd hardening drop-in (=/etc/ssh/sshd_config.d/10-hardening.conf=, + PermitRootLogin prohibit-password). +- =backup_system_file= behavior — assert =.archsetup.bak= exists for files + archsetup edited in place (fstab, mkinitcpio.conf, sudoers, …). +- pacman.conf (ParallelDownloads, Color, multilib) and makepkg.conf (MAKEFLAGS, + OPTIONS) settings actually applied. +- systemd-resolved DNS-over-TLS drop-in; NetworkManager wifi-privacy. +- fail2ban jail.local present; reflector config; sysctl printk; /etc/issue + emptied; vconsole font; fstab /efi fmask/dmask perms. +- sanoid / zfs-replicate units (ZFS hosts). + +* Dependencies + +Add =python-pytest=, =python-pytest-testinfra= (pulls paramiko), and a JSON +reporter to =make deps= (test host only — not installed by archsetup itself). +Note: the existing unit suites run under =python3 -m unittest=; the integration +layer runs under pytest. Two runners, both Python; =make test-unit= unchanged, +=make test= gains the pytest step. + +* Goss comparison (the task asked) + +- *Goss* — YAML-declarative health specs, a single Go binary executed *on the + target*. Fast, no Python. But the spec must be pushed into the VM and run + there, the assertions are less programmable, and it adds a Go binary to the + flow. +- *Testinfra* — Python, runs *on the host* over SSH (nothing installed in the + VM), assertions are full Python with rich built-in modules + (File/Package/Service/User/Command), integrates with pytest's tooling. + +Choose Testinfra: it runs from the host (the VM stays clean), it's far more +programmable for the conditional checks archsetup needs (DESKTOP_ENV branches, +ZFS-vs-not), and it aligns with the repo's existing Python test tooling. + +* Migration plan (phased, TDD where the helper logic is ours) + +- *P1 — Scaffold.* conftest.py (host fixture + connection), the attribution + marker + report hook, and 3 parity checks (firewall, user, dotfiles). Wire a + pytest step into run-test.sh behind a flag so the shell sweep still runs. +- *P2 — Full parity.* Port all ~26 checks; diff a real VM run's results against + the shell sweep to confirm no check was lost. +- *P3 — Cut over.* Make pytest the primary sweep in run-test.sh; keep + =analyze_log_diff= and the install-log signal. +- *P4 — Expand.* Add the new coverage (hardening, backups, applied settings). +- *P5 — Retire.* Remove =run_all_validations= from validation.sh (keep the + capture/analyze helpers that pytest doesn't replace). + +* Acceptance criteria + +- =make test= runs archsetup in a VM, then a pytest sweep over SSH, and a real + run reports parity with (or a superset of) the current shell checks. +- Failures still sort into archsetup / base-install / unknown, with base-install + issues routed to the archzfs inbox as today. +- =make deps= installs the test dependencies; the VM has nothing extra installed. +- A documented =pytest -m smoke= fast path exists. + +* Resolved decisions (2026-06-25) + +1. *Auth at validation time — inject a throwaway test key.* Pre-run, generate + an ephemeral keypair, push the pubkey into the VM's + =/root/.ssh/authorized_keys= over the existing sshpass channel, and point + Testinfra at the private key via a generated ssh-config. No password in the + pytest invocation; paramiko key auth just works; the keypair is discarded + after the run. (Chosen over wrapping sshpass around Testinfra, which is + awkward since Testinfra spawns its own ssh connections.) +2. *Cut over — run both through parity, then switch.* Keep the shell sweep + running alongside pytest through P2 so a real VM run can diff pytest's + results against the shell sweep and prove no check was dropped. pytest + becomes primary at P3; =run_all_validations= is deleted at P5 after the + expanded suite proves out. +3. *Expansion scope — full, in this task, after cutover.* All of P4 lands here, + sequenced strictly after the P3 parity cutover so the parity diff is clean + before new checks are added. diff --git a/docs/design/2026-06-25-zfs-vm-test-coverage.org b/docs/design/2026-06-25-zfs-vm-test-coverage.org new file mode 100644 index 0000000..d9625e0 --- /dev/null +++ b/docs/design/2026-06-25-zfs-vm-test-coverage.org @@ -0,0 +1,139 @@ +#+TITLE: Design: ZFS VM Test Coverage + Bare-Metal Runner Migration +#+AUTHOR: Craig Jennings +#+DATE: 2026-06-25 +#+STATUS: Draft — for review + +* Problem + +Two gaps, one root: + +1. *The ZFS install path is untested in automation.* The VM harness + (=make test=) uses a single non-ZFS base image, so every ZFS-conditional + check skips (mkinitcpio udev hook on ZFS, sanoid, zfs-scrub timer, the whole + ZFS branch of archsetup). ZFS is exercised *only* by =run-test-baremetal.sh= + against real hardware. + +2. *=run-test-baremetal.sh= is latently broken by the sshd hardening.* It SSHes + to the target as root *by password* throughout the run, exactly the pattern + archsetup's =PermitRootLogin prohibit-password= (shipped 2026-06-24) kills + mid-install. The VM runner already hit and fixed this (=inject_root_key= + + key auth, commit f50fc1d); the bare-metal runner never got that fix, so it + almost certainly aborts mid-install now, the same way the VM runner did. + +The fix for both is the same shape: a ZFS base VM gives a safe, repeatable, +snapshot-rollback ZFS target (no sacrificial hardware), which both fills the +coverage gap *and* provides a target to migrate + validate the bare-metal +runner against. This also unblocks P5 (deleting the dead shell-sweep functions +from validation.sh), which is gated on the bare-metal runner leaving the shell +sweep. + +* Decision + +Build a ZFS base VM via archangel, add a filesystem-profile selector to the VM +harness so =make test= can target zfs or non-zfs, then migrate +=run-test-baremetal.sh= to key auth + the Testinfra sweep and validate it +against the ZFS VM. Finish by deleting the now-dead shell-sweep functions (P5). + +Explicitly rejected: loosening =PermitRootLogin= (or adding a skip-hardening +test flag). That trades a real security feature for harness convenience and +would mean never validating the hardened config. Key auth is the correct fix, +already proven in the VM runner. + +* Current state (grounded) + +- =create-base-vm.sh= boots an =archangel-*.iso=, copies =archsetup-test.conf= + into the live env, runs =archangel --config-file /root/archsetup-test.conf= + (the base-OS install — partitioning/filesystem live here), powers off, and + snapshots =clean-install= onto =vm-images/archsetup-base.qcow2=. +- =run-test.sh= hardcodes that one image + snapshot, and copies + =scripts/testing/archsetup-vm.conf= (DESKTOP_ENV=hyprland, non-ZFS) into the + VM as the archsetup config. +- =run-test-baremetal.sh= takes =--host= / =--password=, SSHes as root by + password, rolls back ZFS =@genesis= snapshots, transfers + runs archsetup, + then calls =run_all_validations= / =validate_all_services= (overriding + =VM_IP= to the target). It is the only remaining caller of the shell sweep. +- Key auth machinery already exists and is reusable: =inject_root_key= and + =SSH_KEY_OPT= in =vm-utils.sh=, and =run_testinfra_validation= in + =testinfra.sh= (drives connection from a generated ssh-config keyed on + =VM_IP= / =SSH_PORT=). + +* Design + +** A. ZFS archangel base +Add a ZFS archangel config (a =archsetup-test-zfs.conf= or equivalent) that +installs a ZFS root. Confirm archangel supports a ZFS-root config (it's a +separate project — verify its config options first). Unencrypted ZFS for the +test VM (skip the passphrase prompt; encryption isn't what we're validating). + +** B. Per-profile base images + selector +- =create-base-vm.sh= takes a profile (e.g. =FS_PROFILE=zfs|ext4=, default + current/non-ZFS), picks the matching archangel config, and writes a + profile-named image: =vm-images/archsetup-base.qcow2= (default) vs + =vm-images/archsetup-base-zfs.qcow2=. Same =clean-install= snapshot name. +- =run-test.sh= + Makefile take the same =FS_PROFILE= and select the image (via + =init_vm_paths=). The archsetup run config (=archsetup-vm.conf=) is *shared* — + archsetup auto-detects ZFS from the live root, so no per-profile run config is + needed. =make test FS_PROFILE=zfs=. + +** C. Bare-metal runner migration +Mirror the VM runner's fix in =run-test-baremetal.sh=: +- After the first successful SSH to =TARGET_HOST=, call =inject_root_key= (it + authorizes a key over the password session; set =VM_IP=TARGET_HOST=, + =SSH_PORT=22= so the helpers + ssh-config target the real host). +- Replace =run_all_validations= / =validate_all_services= with + =run_testinfra_validation= (now authoritative). +- Everything downstream already routes through =$SSH_KEY_OPT= (the vm-utils + helpers) and the ssh-config, so it survives the hardening. + +** D. Validate +- =make test FS_PROFILE=zfs= → the ZFS-conditional pytest checks now *run* + (not skip): mkinitcpio uses the udev hook, sanoid installed, zfs-scrub timer, + zfs root. Fix any real ZFS-path findings archsetup has. +- Point =run-test-baremetal.sh= at the ZFS VM (or real hardware) → confirm the + key-auth migration carries it through the hardening to a green pytest sweep. + +** E. Delete the shell sweep (P5) +Once both runners use =run_testinfra_validation=, delete the dead functions from +=validation.sh= (run_all_validations, validate_all_services, the ~26 validate_* +checks, validate_service*, run_full_validation, validation_pass/fail/warn/skip). +Keep the live helpers: ssh_cmd, attribute_issue, capture_pre/post_install_state, +analyze_log_diff, categorize_errors, generate_issue_report, VALIDATION_*. + +* Phases +- *P-A* archangel ZFS config (verify archangel ZFS support first). +- *P-B* create-base-vm.sh + run-test.sh + Makefile profile selector; build the + ZFS base image + snapshot. +- *P-C* =make test FS_PROFILE=zfs= green (ZFS-conditional tests run; fix + findings). VM-validatable here. +- *P-D* migrate run-test-baremetal.sh to key auth + Testinfra; validate against + the ZFS VM. +- *P-E* delete the dead shell-sweep functions (the standing P5 follow-up). + +* Open questions +1. *Does archangel support a ZFS-root config out of the box?* RESOLVED (yes). + ZFS is archangel's *default* filesystem (=FILESYSTEM=zfs=, validated by + =installer/lib/config.sh:validate_filesystem=), with =NO_ENCRYPT=yes= for an + unattended unencrypted install and a ready =installer/velox-zfs.conf.example= + to model. No archangel work needed. +2. *Two images vs one image + two snapshots?* RESOLVED — two images. ZFS vs + btrfs are different on-disk layouts; cleaner than juggling snapshots on one + disk. =btrfs= keeps the legacy unsuffixed =archsetup-base.qcow2=; =zfs= gets + =archsetup-base-zfs.qcow2=. +3. *Profile on run-test.sh vs a separate run-test-zfs.sh?* RESOLVED — + =FS_PROFILE= env param on the existing runner + Makefile, no duplicate + harness. +4. *Disk size / RAM for the ZFS VM* — start at the 4G RAM / 50G disk defaults; + bump =VM_RAM= only if the ZFS install OOMs (decide at P-C build time). +5. *Should the bare-metal runner stay at all once a ZFS VM exists*, or does the + ZFS VM profile make it redundant for everything except real-hardware smoke? + Defer until after P-D. + +* Design corrections (found during P-A/P-B grounding) +- The "non-ZFS" base is *btrfs*, not ext4 — =archsetup-test.conf= sets + =FILESYSTEM=btrfs=. The profile axis is zfs vs btrfs throughout. +- *No =archsetup-vm-zfs.conf= is needed.* archsetup reads no filesystem key; it + auto-detects ZFS from the live root via =is_zfs_root()= (=findmnt -n -o FSTYPE + /=, archsetup:688). The ZFS branch (sanoid, zfs-scrub timer, mkinitcpio udev + hook, docker zfs storage driver) fires whenever the running root is ZFS. So + only the *archangel* base config and the base *image* differ per profile; the + archsetup run config (=archsetup-vm.conf=) is shared. diff --git a/docs/design/2026-06-29-waybar-network-module-spec.org b/docs/design/2026-06-29-waybar-network-module-spec.org new file mode 100644 index 0000000..3a1260c --- /dev/null +++ b/docs/design/2026-06-29-waybar-network-module-spec.org @@ -0,0 +1,2094 @@ +#+TITLE: Waybar Network Module — Design Spec +#+AUTHOR: Craig Jennings +#+DATE: 2026-06-29 + +* Status + +*Phases 1-3 SHIPPED* (2026-06-29 → 2026-06-30, dotfiles). The core module is live: +the =net= engine (=status/probe/list/up/down/add/edit/remove/rescan/diagnose/repair/ +doctor/portal/speedtest=), the =waybar-net= indicator (split-cadence cache, redacted +event log, display-only airplane absorption per decision 12), and the GTK4 +layer-shell panel (Connections / Diagnose / Repair / Speed test) with the settled bar +clicks (left = panel, middle = =net portal=, right = =net-fix=; airplane on +Super+Shift+A). 230+ net tests; full dotfiles suite green. Live-verified on velox. + +Built on top since the original spec: +- *Captive-portal login engine* (2026-06-30, dotfiles =a7d7559=) — =net portal= now + runs a native =portal-login= repair tier (drop DoT → recover the portal URL from + the redirect → open a throwaway browser profile → auto-restore DoT once online), + replacing the old shell-out to =captive= for the force-portal flow. =net portal + --restore= is the manual fallback. +- *Portal UX fixes from live testing* (2026-06-30, dotfiles =eef6b0b=) — removed a + polkit-gated =resolvectl flush-caches= that popped an auth dialog (the DoT-drop + restart already clears the cache); added an already-online short-circuit so a + forced run on a working connection opens nothing; suppressed Chrome's first-run + wizard; moved =net portal= off the terminal into the panel status line; hardened + the portal-URL extractor against Firefox's detection page. +- *Panel auto-hide + Close button* (2026-06-30, dotfiles =450b7f0=) — the panel + closes on focus-out (popup behavior, suppressed while a child dialog holds focus) + and carries a Close button bottom-right. + +*V2 redesign in flight* (designed 2026-06-30, not yet built — see todo.org "Network +panel redesign — no terminals, verify-everything, full failure coverage"). It +reverses two earlier choices and widens coverage: +- *No terminals anywhere.* =net-popup= is removed; every action and result renders + in the panel. This depends on a passwordless privileged path — a root-owned helper + plus a narrow NOPASSWD sudoers rule, archsetup-installed — because an in-panel + worker thread has no tty to prompt for a password. Reverses decision 11's + "privileged tiers run in a terminal". +- *New navigation* — top tabs Connections | Diagnostics | Performance. Diagnostics + merges Diagnose + Repair (sub-row Diagnose | Get Me Online | Advanced; a shared + area below shows diagnose items and streams repair progress; Advanced reveals the + individual repair buttons, renamed with tooltips). Speed test lives under + Performance. +- *Verify every action* (each mutating op confirms its effect before reporting + success) and *detect + respond to every failure mode* — the full ~44-mode catalog, + edge cases included, lives in the redesign task and supersedes the table below. + +Phase 4 (docs / rollout) and Phase 5 (VPN) remain. Review incorporated (Codex, +2026-06-30): four review rounds + Craig's cj comments are all dispositioned +([40/40], no open findings) — the fourth round reshaped the V2 panel UX (single nav +target, saved-vs-available groups, join-from-row, the auth matrix, progressive +loading, a findable diagnostics report, and the Waybar visual contract; see "V2 panel +UX"). Phases 1-3's manual live checks are under todo.org "Manual testing and +validation". + +* Goal + +One waybar network component that does the whole job: shows connection state +(including the missing "associated but no internet / captive portal" state), +manages connections from a dropdown (nmcli-backed; secrets stay in +NetworkManager's own store, no separate credential file), and runs the network +diagnostics and remediation off the same place +(captive-portal detection + forcing, bounce/reset, gateway/DNS checks, speed +test). + +It unifies three todo tasks that are really one feature: +- =[#C]= "archsetup Waybar Wi-Fi module should show no-internet state" — the + indicator state plus the 2026-06-22 roam expansion (bounce, diagnostics, speed + test off the component). +- =[#B]= "Network-manager dropdown, nmcli-backed" — the management dropdown. (The + todo task's original "GPG-stored secrets" framing is superseded: secrets stay in + NM's own store, decision 5.) +- The network diagnostics already shipped in =captive= (the hotel/captive-portal + tool, formerly =login-page=) become this module's diagnostics engine rather + than a standalone CLI. + +* Scope + +** In +- *Indicator* — wifi/ethernet icon + signal + SSID, plus an internet sub-state: + online / captive / no-internet / connecting / disconnected / airplane. +- *Absorbs the airplane module* — the airplane state + toggle move into + =custom/net= (airplane is a network concern). Once this ships, the standalone + =custom/airplane= module, the =waybar-airplane= + =airplane-mode= scripts, their + =tests/=, and the css are deleted (listed under Files touched). The + desktop-settings panel (sibling =[#B]=) no longer needs an airplane row. +- *Interface-correct* — targets the wifi (or chosen) device, not the + default-route interface, so an active USB tether or wired link can't mask + wifi state. (Same lesson =captive= fixed; the current =custom/netspeed= keys + off the default route and has the bug.) +- *Connection management (panel)* — list saved connections most-recently-used + first, live signal for in-range wifi, click to switch; add / edit / remove for + open + WPA-PSK; activate any existing saved profile (including enterprise ones + NM already stores); ethernet↔wifi and wifi↔wifi switching even when a link + appears mid-session. +- *Diagnostics (panel)* — read-only Diagnose (captive probe 204-vs-portal with + the extracted portal URL, gateway ping, DNS config) separated from mutating + Repair. Repair has tiers, lightest first: rfkill-unblock, per-connection reset + (fresh MAC), full-stack bounce (=nmcli networking off/on=, then restart + NetworkManager if that fails), and the temporary 1.1.1.1 override test. Each + Repair action confirms and verifies cleanup. +- *Speed test (panel)* — down/up/ping with a progress indicator and last-result + shown, via the already-installed =speedtest-go --json=. +- *Connection secrets* — none of our own. Settings and passwords live where NM + already keeps them: =/etc/NetworkManager/system-connections/*.nmconnection= + (root-only =0600=, the PSK/EAP secret stored inline). We read/write them through + nmcli, which handles the privilege. No separate file, no GPG, no gpg-agent — one + fewer dependency, and NM's store is already the secure-at-rest source of truth. +- *Persistence* — connectivity probe result cached in the runtime dir so the + bar reads it cheaply between probes. +- *Observability* — a redacted JSONL event log so a post-failure session can + diagnose without re-running destructive actions. + +** Out (v1, note for later) +- No replacement of NetworkManager's connection engine. NM stays the thing that + connects; we drive it via nmcli. +- No add/edit *form* for WPA-Enterprise / 802.1X in v1. The reason is effort vs + payoff: 802.1X has many interdependent fields (CA cert, client cert, identity, + anonymous identity, phase-2 auth) where a wrong entry silently fails auth, so a + trustworthy form is a lot of UI for connections Craig rarely adds (open + + WPA-PSK covers home, hotels, and phone hotspots). v1 still *activates* existing + saved enterprise profiles and points editing at =nmtui=/=nmcli=. Settled + (Craig, 2026-06-29): enterprise add/edit is vNext — 24 saved profiles on velox, + 0 enterprise, so the form would be unused UI; if one ever appears nmtui adds it + once and the module activates it thereafter. +- No per-connection captive-portal *auto-login* in v1. (That would mean storing a + portal's login form answers — room number, surname, a checkbox — and replaying + them automatically when a known portal is detected, so the page never appears. + Out for v1 because every portal's form differs and it means storing per-venue + answers; v1 just opens the portal for you.) +- No graphing/history of speed-test results beyond the last run. +- No static-IP / proxy / metered / MAC-randomization editing in v1 (activate + existing, edit elsewhere). +- No VPN / WireGuard management in v1, but it's a planned later phase (Phase 5), + not a permanent exclusion — it folds the existing archsetup wireguard tooling + into the same panel/CLI. +- The desktop-settings dropdown (sibling =[#B]=) is a separate module, but it + shares the GTK4 layer-shell panel shell built here. + +* Architecture + +Three layers. Keep the bar cheap, the panel rich, the logic in one tested place. + +1. *Engine* — a =net= Python package (src-layout, unittest), exposing a CLI. Wraps + every nmcli op and owns the diagnostics. Emits JSON. This is the testable + core (fake =nmcli= / =curl= / =speedtest-go= on PATH, like the existing + =waybar-netspeed= and =waybar-sysmon= test harnesses). Precedent: pocketbook is + Python in the dotfiles repo; =wtimer= is Python for the same testability + reason. +2. *Indicator* — a thin =waybar-net= script that calls =net status --json= and + renders icon + signal + state + tooltip. Replaces =custom/netspeed= + (throughput folds into the tooltip). +3. *Panel* — a GTK4 + gtk4-layer-shell app (mirrors pocketbook's structure) + that imports the engine. Hosts connection management, diagnostics, and the + speed test. + +How the existing pieces map in: +- =captive= (bash, shipped) — its cheap portal-detection logic is mirrored natively + in the engine for the fast status path so the bar never blocks on a subprocess, + and it still exposes a =--probe-json= mode the engine reuses. *As built (2026-06-30): + the force-portal flow is now native too* — =repair.py='s =portal-login= tier does + the DoT drop, portal-URL recovery, clean-browser launch, and auto-restore in + Python, so =net portal= no longer shells out to =captive= for it. =captive= stays a + usable standalone CLI. +- =waybar-netspeed= (sh, shipped) — retired; its throughput sampling moves into + the engine's status output and renders in the indicator tooltip only. +- =nmcli= — the connection backend for every op. + +Language note: the engine is Python; the indicator is a thin Python or sh +wrapper over =net status --json=. The bar path must stay fast (see Performance +budgets), so the indicator does no network I/O itself — it reads link state and +the cached connectivity result. + +Privileged-path model (v2, planned): repairs that need root (rfkill unblock, nmcli +modify/up, networking off/on, =systemctl restart NetworkManager/systemd-resolved=, +resolvectl dns/revert, the DoT toggle) go through a single root-owned helper +installed by archsetup, with a narrow NOPASSWD sudoers rule scoped to that helper +only (never a blanket =mv=/=systemctl= rule). =repair.py= calls =sudo <helper> +<verb>=. This is what lets every action run in-panel with no terminal: a GTK worker +thread has no tty, so without a passwordless path it can't prompt. It also fixes a +latent bug in the shipped portal flow — the detached DoT-restore watcher runs with +no tty and silently fails to restore encrypted DNS when sudo creds aren't cached. + +* Repository + dependencies + +- *Code lives in the dotfiles repo* (=~/.dotfiles=), not archsetup. The =net= + package sits in-tree like pocketbook (src-layout, unittest, Makefile target); + =waybar-net= and the =net= CLI entry live in the hyprland tier + (=hyprland/.local/bin/=). Tests under =tests/net/= and =tests/waybar-net/=. + archsetup owns only the *dependency install*, not the code. +- *archsetup installs the deps* in its Hyprland step: =gtk4-layer-shell=, + =python-gobject=, plus =nmcli=/=curl=/=resolvectl=/=rfkill= (already present via + NetworkManager/curl/systemd/util-linux). Speed test uses =speedtest-go= (AUR + =speedtest-go-bin=, already installed on velox); archsetup adds it to the AUR + list. librespeed-cli is the documented fallback if a self-hosted LibreSpeed + server is ever wanted. No =gpg= dependency (secrets live in NM's own store). +- *Daily-drivers*: a stowed-script + AUR-dep feature, so ratio needs the same + =git pull= + stow + the archsetup-added deps. Note the manual dep step in the + rollout. + +** Makefile targets (console recovery is a first-class path) +=net doctor= and the diagnostics are reachable from a bare TTY when waybar and +the GUI are down — that's the case where you most need them. The dotfiles +Makefile carries targets that wrap the =net= CLI so "get back online" is one make +command from the console: +- =make online= — =net doctor --fix= (diagnose, then apply the lightest repair: + rfkill-unblock → reset → bounce → open portal). The headline recovery target. +- =make net-doctor= — =net doctor= (read-only diagnose + recommendation). +- =make net-status= / =make net-diagnose= / =make net-portal= / =make net-reset= + / =make net-bounce= — the individual ops. +- =make test= — already runs =tests/*=; the =net= package's unittest suites are + collected the same way. +These intentionally need only nmcli/curl/rfkill (no GUI, no waybar, no Python +GTK), so they work from a TTY on a broken graphical session. + +* Connectivity model — split cadence + +The indicator polls every ~2s, but a real internet/captive probe every 2s wastes +battery and can re-trigger a captive portal. So split it: + +- *Fast path (every poll, cheap, no network)* — interface, type, SSID, signal, + IPv4 presence, throughput sample. From nmcli / sysfs only. No network I/O. +- *Slow path (cached, TTL ~45s)* — the actual internet/captive probe (the 204 + check + meta-refresh portal extraction). Result cached at + =$XDG_RUNTIME_DIR/waybar/net-connectivity.json= with a timestamp. + +The indicator reads the cache each poll. When the cache is older than the TTL, +=net status= kicks =net probe= in the background (spawn + detach, never awaited) +and renders the last cached sub-state meanwhile. A user-triggered +diagnose/reconnect refreshes the cache immediately. This keeps the bar +responsive and the portal un-poked. + +** Concurrency, atomicity, staleness +- *Single-flight* — =net probe= takes a lock file at + =$XDG_RUNTIME_DIR/waybar/net-probe.lock= (flock, non-blocking). A second probe + while one runs is a no-op, so a flapping 2s poll can't pile up overlapping + probes. +- *Atomic writes* — the cache is written to a temp file + =os.replace= (atomic + rename), so a reader never sees a half-written cache. Same pattern as =wtimer=. +- *Max probe runtime* — the probe has a hard timeout (≤ 6s total: curl + =--max-time 5= + slack). On timeout it writes an =unknown= result, never hangs. +- *Stale classes* the indicator distinguishes: fresh (< TTL), stale (TTL..3×TTL, + shown with a subdued/aging hint), expired (> 3×TTL → treat as unknown), + unknown (no cache / probe failed). The bar never shows a confident "online" + past the expired threshold. +- *Invalidation* — the cache records the iface + SSID + active-connection UUID it + was taken under; a change in any of them invalidates it immediately (a + reconnect must not show the old network's verdict). +- *Crash cleanup* — a stale lock older than the max runtime is ignored/reclaimed. + +* Performance budgets (hot path) + +The bar exec path (=waybar-net= → =net status=) must stay responsive: +- *Budget*: =net status= returns in < 100ms typical, < 250ms worst case. +- *No sleeping in the bar path.* Throughput is sampled from two reads of + =/sys/class/net/<iface>/statistics/{rx,tx}_bytes= across the *waybar poll + interval itself* (delta since the last cached sample + timestamp), not via an + in-process =sleep= like the old =waybar-netspeed=. The cache holds the prior + counters. +- *Subprocess cap*: at most one =nmcli= invocation on the hot path (a single + =nmcli -t -f ...= multi-field query), plus sysfs reads. Never a per-field + nmcli call. +- *Every subprocess has a timeout* (=nmcli --wait 2=, =subprocess timeout=). On + timeout or error the indicator emits a degraded JSON state (class + =net-degraded=, a neutral glyph) rather than blocking or crashing waybar. +- *Benchmark test*: a fake slow =nmcli= asserts =net status= still returns within + budget by falling back to the degraded state. + +* Engine — =net= CLI surface + +All subcommands take =--json= where a machine reads them. Pure formatting/state +functions under the CLI; IO (nmcli, curl, file) at the edges. Every subcommand +exits non-zero with a JSON error envelope (see JSON schemas) on failure. + +- =net status [--json] [--iface IF]= — fast link state + cached connectivity + sub-state + throughput. The indicator's source. Never does network I/O. +- =net probe [--iface IF]= — run the connectivity/captive probe now, update the + cache (single-flight, atomic), print online | captive (+ portal URL) | + no-internet | unknown. Mirrors =captive='s cheap detection natively. +- =net list [--json]= — saved connections, MRU order, active flag, plus in-range + wifi with signal. +- =net up <uuid>= / =net down [--iface IF]= — switch / disconnect. Operates on + UUID, not name (see nmcli contract). +- =net add= / =net edit <uuid>= / =net remove <uuid>= — manage connections + (open + WPA-PSK) through nmcli; the secret lands in NM's own + =.nmconnection=. Enterprise profiles are activate-only. +- =net rescan [--iface IF]= — wifi rescan. +- =net diagnose [--json]= — read-only report: gateway ping, DNS config, captive + probe. The structured contract below. Doubles as the post-failure snapshot. +- =net repair <action> [--json]= — mutating remediation, lightest first: + =rfkill= (unblock + radio on), =reset= (fresh MAC), =bounce= (full-stack: + =nmcli networking off/on=, escalating to =systemctl restart NetworkManager=), + =dns-test= (temporary 1.1.1.1 override, auto-reverted). Each confirms via the + caller and verifies cleanup. +- =net doctor [--json] [--fix]= — one-shot "get me online" mode for the console: + runs the full diagnose, then applies the lightest repair that fits (unblock + rfkill, reset, bounce, open portal) — read-only without =--fix=, acting with + it. The TTY recovery path when waybar/the GUI is down (see the Makefile + targets). +- =net portal [--restore]= — the native captive-login flow (=repair.py= =portal-login= + tier): short-circuits if already online, else drops DoT to plain DNS, recovers the + portal URL from the redirect, opens it in a throwaway browser profile, and spawns a + detached watcher that restores DoT once online. =--restore= forces the restore now. +- =net speedtest [--json]= — =speedtest-go --json= run; down/up/ping. + +* nmcli contract + +The command wrapper is the reliability boundary; SSIDs and connection names +contain spaces, colons, duplicates, hidden names, and non-ASCII. Rules: + +- *Terse, field-selected output*: =nmcli -t -f <fields> --escape yes ...= and + =nmcli -g <fields> ...= (get-values) for single-value reads. Parse with the + documented escaping (=\:= and =\\=); never naive =cut -d:=. +- *UUID is the handle.* Every saved-profile op (=up=, =down=, =modify=, =delete=) + uses the connection UUID, never the display name — names duplicate and contain + separators. =net list= surfaces UUIDs; the panel maps row → UUID. +- *Wait budgets*: activation/deactivation use =nmcli --wait <n>= with an explicit + budget (hot-path reads =--wait 2=; activation =--wait 30=). No unbounded waits. +- *Connectivity*: NM's own =nmcli networking connectivity= can return + =none/portal/limited/full/unknown=. Use it as a *cheap hint* on the fast path + when present, but the authoritative captive verdict is still our own probe + (NM's portal detection is coarser and config-dependent). +- *Parser tests* (fake nmcli fixtures): escaped colons and backslashes in SSIDs, + embedded newlines, duplicate connection names, hidden SSID (empty name), + non-ASCII SSID, the wired-appears-mid-session case, and the multi-active case + (wifi + tether both up). + +* JSON schemas + +Versioned (="v": 1=) envelopes so tests lock the contract. Sketches (fields +nullable unless noted): + +- =status=: ={v, iface, type: wifi|ethernet|none, ssid, signal, ipv4, + gateway, throughput: {rx_bps, tx_bps}, connectivity: online|captive|no-internet|unknown, + connectivity_age_s, connectivity_class: fresh|stale|expired|unknown, state: + online|captive|no-internet|connecting|disconnected|airplane|wired|degraded}=. +- =probe=: ={v, result: online|captive|no-internet|unknown, portal_url, http_code, + redirect_host, elapsed_ms, ts}=. +- =list=: ={v, connections: [{uuid, name, type, active, last_used, signal, + in_range, security}]}=. +- =diagnose=: ={v, steps: [<diagnostic step, see contract>], overall: + ok|warn|fail}=. +- =speedtest=: ={v, down_mbps, up_mbps, ping_ms, server, elapsed_ms, ts}=. +- error envelope (any command): ={v, error: {code, message, detail, partial: + bool}}= with a non-zero exit. + +* Diagnostics contract + +=net diagnose --json= returns an ordered list of steps. Each step is the unit the +panel renders and the log records: + +- =id= — stable identifier (e.g. =link=, =dhcp=, =gateway-ping=, =dns-config=, + =dns-resolve=, =http-probe=, =portal=). +- =status= — =pending | running | pass | warn | fail | skipped=. +- =title= — short human label. +- =evidence= — redacted detail (the value seen), per the redaction rules. +- =elapsed_ms=. +- =safety= — =read-only= or =mutating= (diagnose steps are all read-only). +- =next_action= — what the user/agent should do on warn/fail (e.g. "open portal", + "reset connection", "switch network"). + +Repair actions (=net repair=) carry the same shape but =safety: mutating=, plus a +=cleanup_verified: bool= field (e.g. the DNS override was reverted) and a +terminal =cleanup-unverified= status when revert can't be confirmed. + +** Diagnose vs Repair (read-only vs mutating) +The panel separates them visually and behaviorally: +- *Diagnose* — probe, gateway ping, DNS config read, captive check. No state + change, no sudo, runnable freely. +- *Repair* — reset (fresh MAC, deletes+recreates the NM profile), DNS override + test (mutates resolver, auto-reverts), portal force. Each needs an explicit + confirm, shows that it's privacy/state-changing, and verifies cleanup. A + Repair whose cleanup can't be verified ends in a visible =cleanup-unverified= + state, never a silent success. + +* Failure states, messages, recovery + +Each row below gives the *exact, final* user-facing string (not a template) with +=<placeholders>= for redacted evidence, plus the evidence field included and the +next action. The string is canonical: every surface renders the same text, so +there's one source of truth. + +Per-surface rendering of the canonical string: +- *Indicator* — the matching glyph + CSS class; the string is the tooltip + (untruncated). +- *Notification* (=notify=) — title = "Networking"; body = the failure label on + its own line, then the canonical string. +- *CLI* — the string on stderr; =--json= puts it in =error.message= with the + evidence in =error.detail= and a stable =error.code=. +- *Panel* — the string as the section banner, with the diagnostic step's evidence + shown beneath. +Evidence is always redacted per the redaction rules (SSID/host shown; PSK/EAP/ +portal tokens never). + +- *associated, no DHCP* — "Connected to <SSID>, no IP (DHCP failed)" → + evidence: SSID, iface → reset / reconnect. +- *no-internet* — "On <SSID>, no internet (gateway reachable, no route out)" → + diagnose / switch network. +- *captive* — "Captive portal at <host> — login required" → Open portal. +- *DNS hijack* — "DNS is being redirected (portal)" → Open portal. +- *DNS broken* — "DNS not resolving (hotel DNS down); 1.1.1.1 works" → use + override / report. +- *HTTP intercepted* — "Traffic is being intercepted before it leaves" → Open + portal. +- *sudo declined* — "Reset needs admin; it was declined — nothing changed" → + retry with auth. +- *command timed out* — "<op> timed out; the system was left unchanged" → retry. +- *partial mutation* — "<op> partially applied: <what>; rolled back to <state>" + → review. +- *missing speedtest-go* — "speedtest-go not installed" → install hint. +- *no wifi hardware* (desktop) — wifi rows hidden; ethernet-only view. +- *wifi rfkill-blocked* — "WiFi is blocked (rfkill)" → unblock. The indicator + detects a soft-blocked radio (=rfkill list= shows the radio off though hardware + is present) and shows this distinct from disconnected. =net repair rfkill= (and + =net doctor --fix= as its first step) runs =rfkill unblock wifi= + =nmcli radio + wifi on= and reconnects. This is the framework-laptop case: an out-of-power + shutdown sometimes leaves wifi soft-blocked at next boot, and yes — the module + recovers it (the rfkill state is the indicator; the rfkill repair / doctor is + the one-step fix). A *hard* block (physical switch) is reported as + not-recoverable-in-software with that message. +- *wifi rfkill hard-blocked* — "WiFi is blocked by the hardware switch" → + evidence: rfkill hard state → flip the physical switch. +- *wrong password / missing secret* — "Saved password for <SSID> was rejected" → + evidence: SSID, NM auth-failure reason → re-enter the password. +- *enterprise auth/cert failure* — "Enterprise login failed for <SSID> (802.1X)" + → evidence: SSID, EAP failure reason → edit the profile in nmtui/nmcli. +- *upstream / AP / provider* — "On <SSID>, link is fine but the network has no + uplink" → evidence: gateway reachable, no route out, not a portal → switch + network or contact the venue. +- *VPN-routed* — "Connected; internet is routed through a VPN (<dev>)" → + evidence: default route on a tun/wg device or non-NM DNS owner → check the VPN, + not WiFi. +- *HTTP interception, no parseable portal URL* — "A portal is intercepting + traffic but didn't give a login link" → evidence: HTTP code, redirect host → + opens neverssl + the gateway page to log in manually. +- *DNS override cleanup unverified* — "Couldn't confirm DNS was restored after the + test" → evidence: iface, attempted revert → revert DNS manually + (=resolvectl revert <iface>=). +- *VPN kill-switch blocking* — "A VPN kill-switch is blocking all traffic, and the + VPN itself is down" → evidence: a block artifact present with no tunnel up → bring + the VPN back, or clear the kill-switch (the exact root command surfaced, not + auto-run). + +*VPN kill-switch detection + correction.* A kill-switch blocks all non-VPN egress when +the tunnel drops, so the link looks up (wifi, IP, gateway) but nothing reaches the +internet. This extends the =deferred-vpn= branch: when a VPN is active and the probe +fails, run a rootless cascade to tell a working tunnel from a kill-switch that's +blocking because the tunnel is down — +- =ip rule= for wg-quick's =not fwmark 0xca6c= + =suppress_prefixlength 0= (and the + PostUp =REJECT ! -o %i= rule that makes it leak-proof); +- =wg show= for an up tunnel interface; +- =nmcli connection show= for Proton's =pvpn-killswitch= / =pvpn-ipv6leak-protection= + (device =pvpnksintrf0=); +- =nft list ruleset= / =iptables -S OUTPUT= for a drop/reject table (=killswitch=, + =protonvpn=, =oifname != "wg0" ... drop=); +- =nmcli -f connection.zone= for a firewalld =drop= zone. +Classify *kill-switch-blocking* only when a block artifact exists AND no tunnel +interface is up — that's what distinguishes it from a healthy VPN. Correction is tiered +by artifact and every option needs root, so surface the exact command rather than +auto-running it: =wg-quick down <iface>=, =nmcli connection delete pvpn-killswitch +pvpn-ipv6leak-protection=, =nft delete table inet killswitch=, or =nmcli connection +modify <con> connection.zone ''=. (Sits alongside the Phase 5 VPN work; detection can +land earlier since =deferred-vpn= already exists.) + +Each message names whether the system was left unchanged, partially changed (with +what), or fully changed, so the user knows the residue. + +* Doctor: escalation, classification, terminal states + +=net doctor= diagnoses, classifies the failure, then (with =--fix=) applies the +*lightest* repair that fits and re-checks — it never loops destructive repairs +against a failure they can't fix. Each failure resolves to one of four outcomes, +and the doctor stops at any terminal one: + +- =fixable= — a local repair should help. Escalate lightest-first: rfkill-unblock + → reset (fresh MAC) → bounce (full stack) → portal, re-probing after each, and + stop as soon as the probe returns online. +- =needs-user-action= (terminal) — no reset/bounce will help; doctor stops and + names the exact next step. Covers: wrong WPA password / missing NM secret + (enter the password), locked keyring or polkit denial (retry with auth), + enterprise 802.1X cert/identity failure (edit the profile in =nmtui=/=nmcli=), + captive portal login-required (open the portal + accept terms). Doctor must not + delete/recreate the profile against these — that loses the saved password and + makes things worse. +- =upstream-not-local= (terminal) — the local link is up but the problem is past + it: AP has no uplink, gateway down/dropping traffic, DHCP server broken, ISP + outage, portal backend failing. =diagnose= proves it (link up + IP + gateway + reachable, but no route out and not a captive redirect), and =doctor --fix= + stops after local repairs are exhausted with "local repairs tried; likely + upstream/AP/provider" + the evidence. Next action: switch networks or contact + the venue. +- =deferred/vpn= (terminal for v1) — an active VPN / policy route / non-NM + resolver owns the default route or DNS, so "no internet" may be the VPN's fault, + not WiFi's. v1 *detects* this (default route on a =tun/wg= device, or DNS owned + by something other than the NM link) and classifies it separately — "link is + fine; internet is VPN-routed" — rather than misclassifying it as a WiFi failure. + v1 does not repair it (VPN management is Phase 5); it names the VPN as the likely + owner and stops. + +** DNS handling in doctor (explicit per class) +- *Captive DNS hijack* — open the portal (the hijack clears on login). No DNS + mutation. +- *Broken resolver, 1.1.1.1 works* — the shipped =dns-test= repair is *diagnostic*: + it sets 1.1.1.1, confirms the venue resolver is the culprit, then auto-reverts + (=cleanup_verified=). Because it reverts, =doctor --fix= does not currently leave + you online in this case — it falls through to =upstream-not-local=, which + misreports a locally-fixable problem. *V2 fix (planned):* on a dns-test *pass* + (public DNS works), set a PERSISTENT resolver override and verify online, with an + offered revert — and classify it as its own outcome rather than upstream. +- *Port-53 / egress blocked* (even 1.1.1.1 fails) — terminal =upstream-not-local=; + doctor stops, since it's not locally fixable. + +* Failure-mode coverage + +*V2 note (2026-06-30):* the authoritative, exhaustive catalog (~44 modes across 10 +connectivity layers, edge cases included, each tagged fix-and-verify or report-text) +now lives in the redesign task (todo.org "Network panel redesign"). The table below is +the v1 baseline; two rows reflect intent the shipped code doesn't yet match, and the +v2 catalog closes them: =gateway unreachable= claims a bounce that doctor never +actually reaches (a no-route failure goes straight to =upstream-not-local=), and +=broken DNS, 1.1.1.1 works= auto-reverts so the user is left offline and misreported +as upstream (the v2 persistent-override fix closes this). + +For each common field failure: does =net diagnose= detect it, can =net doctor +--fix= repair it, and what terminal user action remains when it can't. (The +=needs-user-action= / =upstream-not-local= / =deferred/vpn= outcomes are defined +above.) + +| Failure mode | diagnose detects | doctor --fix | terminal user action | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| +| rfkill soft block | yes | yes (unblock) | none | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| +| rfkill hard block | yes | no | flip the physical switch | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| +| no wifi hardware | yes | n/a | use ethernet | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| +| associated, no DHCP | yes | yes (reset/bounce) | none, else switch network | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| +| gateway unreachable | yes | yes (bounce) | switch network if it | +| | | | persists | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| +| captive DNS hijack | yes | opens portal | log in at the portal | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| +| broken DNS, 1.1.1.1 works | yes | yes (temp override, | report the venue's DNS | +| | | auto-reverted) | | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| +| HTTP captive portal | yes | opens portal | log in at the portal | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| +| HTTP interception, no | yes | opens neverssl + gateway | log in manually | +| parseable URL | | | | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| +| upstream / AP outage | yes (link up, no route out) | no (stops after local) | switch network / contact | +| | | | venue | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| +| wrong WPA password / | yes | no | enter the password | +| missing secret | | | | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| +| enterprise auth / cert | yes | no | edit the profile in | +| failure | | | nmtui/nmcli | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| +| duplicate SSID / | yes (UUID-keyed) | yes (activate by UUID) | none | +| connection-name | | | | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| +| hidden SSID | yes | yes (connect by name) | enter SSID + password | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| +| multiple active links | yes | n/a | pick the interface | +| (wifi+tether) | | | | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| +| wedged NetworkManager | yes | yes (bounce → restart NM) | none, else reboot | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| +| slow / hung command | yes (degraded) | retries within budget | retry | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| +| stale / corrupt cache | yes | self-heals (atomic + | none | +| | | invalidation) | | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| +| DNS cleanup failure | yes | flags cleanup-unverified | revert DNS manually | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| +| missing speedtest backend | yes | n/a | install speedtest-go | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| +| VPN / policy-routing | yes (route/DNS ownership) | no (deferred to Phase 5) | check the VPN | +| interference | | | | +|----------------------------+-----------------------------+-----------------------------+-----------------------------| + +* Observability — logging + redaction + +- *Event log*: JSONL at =$XDG_STATE_HOME/net/events.jsonl= (fallback + =~/.local/state/net/events.jsonl=), size-rotated (e.g. 1 MB × 3). Every + mutating op and probe appends an event: =ts, op, argv (redacted), exit_code, + stderr_tail, elapsed_ms, iface, nm_uuid, probe_url_class, http_code, + redirect_host, cache_event=. +- *Redaction (always on)*: PSKs, EAP identities/passwords, NM secrets, and + portal query tokens are never logged. MAC addresses, full IPs, and SSID are + redacted when configured (=redact_mac=, =redact_ip=, =redact_ssid= in config). +- *Post-failure diagnosis*: =net doctor --json= is the snapshot + recommendation + (diagnose plus the suggested repair), =net diagnose --json= the raw report, and + the event log the history. =net doctor= is the console-recoverable entry point + (reachable as =make online= / =make net-doctor=). +- *Secret-leak tests*: assert no PSK/EAP/portal-token ever appears in any JSON + output, log line, or error message. + +** Automatic diagnostic verbose-capture (V2) + +A distinct layer from the event log above: that log records what =net= did; +this captures what the *underlying stack* did at debug verbosity during a run, so a +failed diagnosis leaves real ground-truth instead of relying on memory. Two triggers, +one mechanism: + +- *Automatic — on a failing diagnose.* When =net diagnose= ends =overall: fail=, the + next escalation (or =Get Me Online=) runs inside a verbose-capture session. +- *Manual — a debug on/off toggle in the panel's Advanced section.* "Debug on" + elevates and leaves it elevated (with a visible "debug capturing" indicator) so the + user can reproduce an intermittent problem over time; "Debug off" restores and + writes the bundle. Useful when the failure doesn't reproduce inside one diagnose. + +Mechanism (shared): +1. *Snapshot* the current log levels (=nmcli general logging=, resolved's level, + wpa_supplicant's). +2. *Elevate* the relevant components to debug at runtime, no restarts, scoped to the + domains that matter (NM: =WIFI,DHCP,DNS,CORE=; resolved; wpa_supplicant). +3. *Run* the diagnostics / repair. +4. *Capture the window*: =journalctl= for NetworkManager + systemd-resolved + + wpa_supplicant since the run started, a =dmesg= tail (driver / firmware / rfkill), + and any =curl -v= probe output. +5. *Restore* every level to its snapshot. +6. *Write a redacted support bundle* to =$XDG_STATE_HOME/net/bundles/<ts>/= and + surface it in the panel. + +Hard requirements: +- *Restore is guaranteed and idempotent.* A =try/finally= restores even on error, + and a crash-recovery guard detects "a prior run left NM/resolved/wpa_supplicant + elevated" on the next run and puts it back — the same shape as the DoT-restore + watcher. A crash must never strand the stack at debug verbosity. +- *Redaction before anything leaves.* Raw wpa_supplicant and NM debug logs carry the + PSK and EAP credentials in cleartext. The captured journal is scrubbed before the + bundle is written, shown, or shared; the secret-leak test asserts no passphrase or + EAP secret survives into a bundle. +- *Privilege via the V2 sudo-helper.* The log-level toggles need root, so they become + verbs on the passwordless helper (decision 16) — no extra prompt. + +Bonus — this closes a real detection gap, not just observability: the spec notes live +auth-failure detection is a v1 limit (it leans on a one-shot NM state-120 snapshot). +wpa_supplicant at debug during the run is exactly how a wrong-password or EAP failure +is caught properly, so the capture feeds back into classification. + +* Indicator (task #C — Phase 1, the fast win) + +** States (internet sub-state on top of link state) +- online — associated and the probe returned 204. Normal icon. +- captive — associated, probe hit a portal. Distinct glyph + warning CSS class; + tooltip names the portal host; left-click opens diagnostics with the portal + ready to open (Phase 2+; see interactions for the Phase-1 interim). +- no-internet — associated, probe failed (no portal, no 204). Distinct glyph + + warning class. +- degraded — =net status= couldn't read link state within budget (slow/failed + nmcli). Neutral glyph, =net-degraded= class. Never blocks the bar. +- rfkill-blocked — the radio is soft-blocked (=rfkill=), distinct from + disconnected. Distinct glyph; the fix is =net repair rfkill= / =net doctor=. +- connecting / disconnected / airplane / wired — as today, plus wired shown + correctly even when it appears after session start. (airplane is now this + module's state, absorbed from the retired airplane module.) + +** Glyphs +Nerd-font codepoints, final values verified live before merge (same discipline +as wtimer). Reuse the signal-strength ramp already in =waybar-netspeed=; add a +captive / no-internet / degraded overlay glyph. + +** Tooltip +SSID + signal + IPv4 + gateway + the throughput readout (absorbed from +netspeed) + the last probe result and its age (stale/expired hinted). + +** Interactions (no keyboard-modifier clicks — waybar can't qualify clicks by +modifier, so the rich actions live in the panel, not ctrl/super-click) +Clicks never block the bar: each dispatches a detached background job, single-flight +per action. *As built (settled live with Craig, 2026-06-29):* +- *left* — =net-panel= toggle (pkill-or-launch the GTK panel). +- *middle* — =net portal= (the captive-login flow). +- *right* — =net-fix= (=net doctor= with =--notify=: reports the result when the + outcome is one-way, opens a terminal only when it's fixable; the v2 redesign moves + even that into the panel). +- airplane toggle moved off the bar to Super+Shift+A. + +* Panel (tasks #B + #C diagnostics — Phases 2-3) + +GTK4 + gtk4-layer-shell, pocketbook scaffold (src-layout package, unittest, +Makefile, gtk4-layer-shell anchored dropdown under the bar). One panel shell, +reused by the future desktop-settings panel. + +Sections as built (Phases 1-3, a four-page stack switcher): +1. *Connections* — list, MRU-first, active marked, live signal bars for in-range + wifi; row click switches; buttons for add / edit / remove; a rescan control. +2. *Diagnose* (read-only) — Probe (204/captive, shows portal URL + Open), Gateway + ping, DNS config. Streaming step output (the diagnostics contract). +3. *Repair* (mutating, confirmed) — tiered lightest-first: Unblock rfkill, Reset + (fresh MAC), Bounce (full stack), DNS override test, Force portal. A "Get me + online" button runs =net doctor --fix= (the auto-escalating sequence). +4. *Speed test* — Run button, progress, down/up/ping result + last-run line. +As built, the panel also auto-hides on focus-out (popup behavior, suppressed while a +child dialog holds focus) and carries a Close button bottom-right (2026-06-30). + +*V2 nav (planned):* three top tabs — Connections | Diagnostics | Performance. +Diagnostics merges the Diagnose and Repair pages into one: a sub-row +=Diagnose= | =Get Me Online= | =Advanced= over a shared area that shows diagnose +items and streams repair progress in-panel (no terminal). =Advanced= reveals the +individual repair tiers (renamed, with tooltips) plus a *Debug capture on/off* +toggle (the manual side of the verbose-capture feature; a failing diagnose triggers +it automatically). Speed test moves under Performance. + +** V2 panel UX — the target design + +The shipped four-page stack (Connections / Diagnose / Repair / Speed test) is +*history*, not active design. V2 is the sole current target: one panel opened from +the bar, three top tabs — Connections | Diagnostics | Performance — and the page +model below is the contract for what gets built and what gets deleted, not just for +labels. + +*** Connections — saved vs available, join-from-row +Three labelled groups, never one merged list: +- *Saved* — saved NM profiles, MRU-first, rendered instantly without a scan. +- *Available now* — scan-backed in-range SSIDs with signal + security; may carry a + loading/stale hint; unsaved networks appear here. +- *Wired* — ethernet when a wired device is present. +=net list= already yields this (=connections.py= lists saved MRU-first, merges live +signal/security for in-range saved profiles, then appends unsaved in-range SSIDs with +=uuid: nil=); the panel groups and labels it. *Rescan refreshes only the +availability/signal layer* — it never gates or reloads the Saved list. + +*Progressive loading:* render the Saved group immediately on open, then overlay +availability, signal, and the unsaved Available-now rows when the scan returns. Show a +small scan-in-progress state (elapsed + last-scan age). A slow or bad radio scan must +not make the whole panel feel stuck — this is the direct answer to "why does it take +so long to see my connections?" + +*Join-from-row (no Add page):* selecting an unsaved Available-now row *is* the join +flow — SSID and security come prefilled from the scan, never retyped. Open networks +connect (confirm only if needed); WPA/WPA2/WPA3-Personal ask only for the password. +The standalone Add button + modal are deleted for visible networks. A hidden/manual +SSID join lives behind an Advanced "Join hidden network" affordance. + +*** Supported authentication classes (the join matrix) +From the scanned NM =SECURITY= value, V2 handles: +- *Inline-supported* — open, open-with-captive-portal, WPA/WPA2/WPA3-Personal + (PSK/SAE), and WPA2/WPA3 transition mode. The row shows the security label so the + user knows why a password is or isn't asked. +- *Activate-only* — 802.1X / enterprise: connect if already saved, else "edit in + nmtui/nmcli" (no add form in v1/V2, per decision 9). +- *Hidden / manual* — behind the Advanced "Join hidden network" affordance. +- *Rare / unsupported* — WEP, OWE/enhanced-open, MAC-registration, voucher, or + proxy-required: a clear in-panel explanation ("not supported here yet") plus a + non-terminal next step, never a hand-off to a terminal tool. + +*** Diagnostics owns the diagnostic story +Diagnostics holds the read-only checks, the repair stream, Get Me Online, debug +capture (Advanced), and the doctor report. A *lightweight* latency/throughput probe +runs inline as a Diagnose evidence row when internet is available (skipped offline, on +a metered/hotspot warning, or with no backend), and its result is stored in the doctor +report. The *full* speed test stays under Performance (decision 19) — which is also +the home for future throughput history, so Performance earns its tab rather than being +a lone button. + +*** Forget confirmation — future tense + verified +The destructive copy is future tense and names the scope: "This will remove the saved +NetworkManager profile and its stored password from this machine." After the op, +verify the UUID is gone, refresh the Saved list, and report "Forgot <SSID>" or "Could +not forget <SSID>; nothing changed / partial <evidence>" — the verify-every-action +decision applied to a destructive op. + +*** Findable diagnostics report +Every diagnose, repair, and speed/performance run ends with a "Copy report" / "Open +report" action in Diagnostics. The report carries the step statuses + elapsed, the +final classification, the last speed/latency result when available, scan age, +route/interface owner, the redacted event-log tail, and the bundle path when verbose +capture ran. It states explicitly whether any repair mutated state and whether +cleanup/verification passed. "Logs exist somewhere" isn't enough when the network is +already down — the report is the one artifact the user copies to hand over. + +*** Visual contract — a Waybar-attached popup +The panel reads as part of the bar, not a separate app. Match the live Waybar theme: +the dark rounded capsule (=border-radius: 1rem=), the golden border, compact monospace +text, and the =custom/net= state colors. Avoid square corners next to rounded UI, keep +cards out of cards, and use compact icon+label controls with tooltips for the advanced +repairs. Reuse any existing archsetup-owned GTK/panel conventions. (Non-blocking for +engine work; blocks final V2 UX acceptance.) + +** Panel state, cancellation, permissions +State machines for: connection-list loading, rescan-in-progress, +activation-in-progress, diagnose-running, repair-running, speedtest-running. Plus +the real terminal states on this two-machine fleet: no-wifi-hardware (desktop → +ethernet-only view) and missing speedtest-go. (No GPG-key state — there's no +credential store; secrets live in NM.) ("No NetworkManager" is not a modeled +state — NM is always present +on these machines; if nmcli is somehow absent the panel shows a single hard-error +and exits.) Long operations show elapsed time and are cancellable where the +underlying op allows (rescan, speedtest, probe); clearly non-cancellable ones +(an in-flight activation) show elapsed + a disabled control. Permission-denied +(sudo/polkit declined) is a first-class outcome with the "nothing changed" +message, never a silent failure. + +Interaction-pattern catalog (=~/code/rulesets/patterns/=) principles that apply: +- transient-state-buttons — all the network levers in one place, reachable by + one chord (the bar click), state visible. +- default-most-common-friction-proportional — connections MRU-ordered so the + common pick is first; destructive ops (remove) and privacy-changing ones + (reset, override) get a confirm, switching does not. +- one-prompt-picker-typed-prefix — if the connection picker ever goes + keyboard-driven, kind (wifi/eth/saved/in-range) + name in one typed picker. + +** Panel UX flow (settle before Phase 2) +The concrete interaction defaults, so the GTK build isn't inventing them: +- *Default focus*: the Connections section, current connection's row selected. If + the indicator opened the panel because of a captive/no-internet state, focus + Diagnose instead with the relevant action highlighted. +- *Row content*: glyph (signal bars / wired / active check) + name + a secondary + line (security type, "active"/last-used). The active row is visually pinned at + top of its group. +- *Buttons*: one *primary* per section (Connections: Connect to the selected row; + Diagnose: Run diagnose; Repair: "Get me online"; Speed test: Run). Secondary + actions (add / edit / remove / rescan; individual repair tiers) are smaller and + grouped. +- *Disabled rules*: Connect disabled on the already-active row; Repair tiers + disabled while one runs; Speed test disabled while running; add/edit disabled + for enterprise (with the "edit in nmtui/nmcli" hint). +- *Confirmations* (exact wording): Reset → "Reset <SSID>? This drops the + connection and reconnects with a new MAC."; Bounce → "Restart networking? All + links drop briefly."; DNS override → "Temporarily set DNS to 1.1.1.1 for the + test? It reverts automatically."; Remove → "Forget <SSID>? The saved password is + deleted." +- *"Get me online" reporting*: shows each escalation step live (Unblock rfkill → + Reset → Bounce → Portal) with per-step pass/fail and stops at the first that + restores internet or at a terminal state, naming the next action. +- *After close*: the bar reflects the new state immediately (signal/refresh on + next poll); a running speedtest/diagnose keeps running and notifies on finish + (panel close doesn't cancel it). +- *Keyboard*: Esc closes (wired); arrows move row focus and Enter activates a + row (GTK ListBox defaults — row-activate connects, never disconnects); Tab is + the plain GTK focus chain, widget by widget (inside a list it crawls row by + row — no section jumps); there is NO type-to-filter. Verified live via + targeted-key AT-SPI probe 2026-07-02; the earlier tab-between-sections and + type-to-filter claims were aspirational and are struck. If section-jump Tab + or filtering is ever wanted, it's a new task, not an existing behavior. + +* Connection management (nmcli) + +- Every op via nmcli per the nmcli contract above (terse, escaped, UUID-keyed, + bounded =--wait=). +- MRU ordering from NM's =connection.timestamp= (last activated), descending. +- Ethernet appears in the list whenever a wired device is present, selectable at + any time; switching just brings the chosen connection up. +- *Mutation safety + rollback*: switching keeps the current connection up until + the new one activates successfully (=nmcli --wait 30=); on failure it does not + tear down the working link, surfaces the failure, and leaves the prior + connection active. =net down= notes that NM may auto-reactivate a profile and + reports the post-op active connection so the user isn't surprised. A switch that + needs a password it doesn't have prompts (or fails with "password required"), + never silently strands. The exact NM command sequence (preflight active-state + read → activate target → verify default route → on failure, confirm prior + still up) is pinned in the engine and tested against fake nmcli. +- *Add/edit scope*: open + WPA-PSK only in v1. Existing saved profiles of any + type (including enterprise) can be *activated*; editing an enterprise profile + shows "edit via nmtui/nmcli" rather than a broken partial form. + +* Connection secrets (no separate store) + +Per Craig's call: don't build a parallel credential store. Settings and secrets +live where NetworkManager already keeps them, so there's one source of truth and +no extra dependency (no GPG, no gpg-agent, no =~/.config/net/connections=). + +- *Where secrets live*: =/etc/NetworkManager/system-connections/<name>.nmconnection=, + root-owned =0600=, with the PSK/EAP secret stored inline (the default + =secret-flags=0= "owned by NM"). That's already secure-at-rest (root-only) and + is what =nmcli= reads/writes. +- *How we touch them*: every add/edit/remove goes through =nmcli= (=connection add + / modify / delete=), which writes the =.nmconnection= with the right ownership + and perms. We never read or write =system-connections= files directly (root) and + never copy a secret out of them. +- *No export / import / sync* — there's nothing to sync. A new machine gets its + connections the way it always has (the user joins, or restores NM profiles), + not from a tool-specific vault. +- *config file*: =~/.config/net/config= still exists, but only for non-secret + preferences (speedtest server, redaction flags, probe TTL). It holds no + credentials. +- *No secret leakage*: PSK/EAP never appear in =net=' =--json= output, the event + log, or error text (tested) — even though NM is the store, our surfaces must not + echo a secret =nmcli= happens to return. + +* Speed test + +- Backend: *=speedtest-go=* (=--json=, =--server=, =--no-download/--no-upload=), + already installed on velox (AUR =speedtest-go-bin=). No new dependency for v1. + librespeed-cli is the documented fallback for a self-hosted LibreSpeed server. +- =net speedtest --json= parses speedtest-go's JSON into the =speedtest= schema. +- *Server policy*: auto-select nearest by default; allow a pinned server id in + =~/.config/net/config=. +- *Timeout + cancellation*: a hard run timeout (e.g. 60s); the panel run is + cancellable (kills the child). Offline / rate-limited / no-server errors map to + the failure-message table. +- *Tests*: fixture JSON (success) and fixture stderr (offline, no server, + malformed output) drive =net speedtest= parsing without touching the network. + +* Help + documentation + +In-app help has three layers, each reachable in the situation it's needed: + +- *CLI help (works from a dead-GUI TTY)*: =net --help= lists the subcommands in + one screen; =net <cmd> --help= documents each (flags, what it mutates, the + console-recovery targets). The Makefile targets are self-describing (=make help= + lists =online= / =net-doctor= / etc. with one-line descriptions). This is the + layer that matters most when you're at a console with no network. +- *Panel help (in the GUI)*: a small =?= affordance in the panel header opens an + inline help pane — what each section does, which Repair actions mutate state, + what the indicator glyphs/colors mean. Per-control tooltips on the less-obvious + buttons (rfkill, bounce, DNS override). No external help browser. +- *User guide (the durable doc)*: a README / docs page covering every command, + the indicator states + glyphs, the panel sections, the config file keys, the + recovery make targets, troubleshooting (the failure-message table), and + rollback. Written so a future session — or Craig six months out — can operate + and recover the module from the doc alone. + +The failure-message table above is the single source of truth for the +troubleshooting text; the guide and the panel help both render from it rather +than restating it. + +* Enhancement radar + +Low-cost adjacent affordances, each dispositioned so cheap wins aren't lost and +the v1 panel stays focused. (Several are already in v1 by virtue of other +sections; marked here so the consideration is visible.) + +| Enhancement | Disposition | Reason | +|-------------------------------------+-------------+--------------------------------------------------------| +| Open / copy portal URL | v1 | already in the captive flow; trivial Open + Copy | +|-------------------------------------+-------------+--------------------------------------------------------| +| Forget network | v1 | it's the remove op, already specced | +|-------------------------------------+-------------+--------------------------------------------------------| +| Rescan now | v1 | already a Connections control | +|-------------------------------------+-------------+--------------------------------------------------------| +| Retry with hardware MAC | v1 | captive already has --hardware-mac; expose in Repair | +|-------------------------------------+-------------+--------------------------------------------------------| +| Pin speedtest server | v1 | already a config key | +|-------------------------------------+-------------+--------------------------------------------------------| +| Copy redacted doctor report | v1 | cheap, serves the observability/support goal | +|-------------------------------------+-------------+--------------------------------------------------------| +| Show last good network / result | vNext | needs small history persistence | +|-------------------------------------+-------------+--------------------------------------------------------| +| Watch mode for net doctor | vNext | a --watch loop; handy at a TTY, not v1-critical | +|-------------------------------------+-------------+--------------------------------------------------------| +| Actionable desktop notifications | vNext | dunst supports actions; extra wiring | +|-------------------------------------+-------------+--------------------------------------------------------| +| Keyboard connection picker (fuzzel) | vNext | the typed-prefix pattern; panel covers v1 | +|-------------------------------------+-------------+--------------------------------------------------------| +| QR-code share / import WiFi | rejected | low value for a personal 2-machine setup; phones do QR | +|-------------------------------------+-------------+--------------------------------------------------------| + +* Waybar wiring + +- Replace =custom/netspeed= with =custom/net= in the bar's module list (same + slot). +- Module def: =exec: waybar-net=, =return-type: json=, =interval: 2=, a =signal= + for on-demand refresh (next free signal after wtimer's 14), =on-click=, + =on-click-right=, =on-click-middle= per the phase-aware interactions (each + dispatches a detached job, never blocks). +- Remove the old =on-click: pypr toggle network= scratchpad only once the panel + replaces it (Phase 2); Phase 1 keeps it as the interim manager. + +* Testing plan (TDD) + +- *Engine (normal)* — fake =nmcli= + =curl= + =speedtest-go= on PATH; assert + command sequences and parsed/emitted JSON for status, list, up/down, + add/edit/remove, probe, diagnose, repair, speedtest. Pure state/format + functions tested directly. JSON schemas locked by example. +- *Portal parser* — already covered in =tests/captive= (Normal/Boundary/Error + + the real SONIFI body). The engine's native probe reuses the same cases. +- *nmcli parsing* — escaped colon/backslash/newline in SSID, duplicate names, + hidden SSID, non-ASCII, wired-mid-session, multi-active (wifi+tether). +- *Failure + concurrency (the risky classes)* — slow/hung nmcli/curl/speedtest + (degraded state within budget), concurrent =net status= probe refresh + (single-flight), corrupt cache (recovered), stale cache after SSID change + (invalidated), permission denied / sudo declined, DNS-override cleanup failure + (=cleanup-unverified=), NM partial activation (rollback keeps prior link), + secret redaction, missing speedtest-go, no wifi hardware, rfkill soft/hard + block. +- *Doctor classification* — fixture-driven =net doctor= over fake nmcli/curl + asserting the right terminal classification + that =--fix= stops before + destructive repairs: auth failures (=needs-user-action=), upstream/AP failure + (=upstream-not-local=), VPN-routed failure (=deferred/vpn=), and the DNS classes + (hijack → portal, broken-but-1.1.1.1-works → offered override, egress-blocked → + upstream). Assert the failure-mode coverage table's "detects / repairs / terminal + action" holds for each row. +- *Indicator* — drive =net status --json= through =waybar-net=, assert the JSON + per state (online / captive / no-internet / degraded / wired / disconnected / + rfkill), iface override via env. +- *Panel* — pocketbook-style: backing logic (list ordering, op dispatch, + state-machine transitions), not GTK widgets. +- *NM secrets / no-leak* — add/edit writes the secret into NM via nmcli (asserted + against fake nmcli, never to a tool-owned file); assert no PSK/EAP appears in any + =--json=, log line, or error (there is no credential store to round-trip). +- *Live checklist (gated out of the suite)* — a "Manual testing and validation" + task per phase for the real-network states (captive at a hotel, no-internet, + switch under load, reset, speedtest) that can't be faked. + +** Harness + coverage gate +The concrete contract, matching the repo's existing convention (not pytest — the +dotfiles suites are =unittest=, run by =make test= as =python3 -m unittest= over +=tests/*/test_*.py=; 33 suites today): +- *Framework*: =unittest=. Each suite is =tests/<name>/test_<name>.py= + (=tests/net/=, =tests/waybar-net/=), collected by the existing =make test= loop + — no new runner, no pytest dependency. +- *Fakes on a temp PATH*: =fake-nmcli=, =fake-curl=, =fake-speedtest-go=, + =fake-rfkill=, =fake-resolvectl= live as executable stubs in =tests/<name>/= + (the =tests/layout-navigate/fake-hyprctl= pattern). A fixture file encodes the + command→canned-output map and the stub appends each invocation to a log the test + asserts against. Subprocess timeouts are simulated by a stub that sleeps past the + budget; =net status= must still return the degraded state. +- *Waybar wrappers end-to-end*: =waybar-net= is run as a subprocess with the fake + PATH and the env overrides (iface, cache path), asserting the emitted JSON — same + as =tests/waybar-netspeed=. +- *Coverage*: coverage.py is absent system-wide (and not importable), so coverage + runs in a throwaway venv (=python3 -m venv=, =pip install coverage=, =coverage + run -m unittest=, =coverage report=) — the method the wtimer suite used (95%). + Target: *branch* coverage over =net/= and the wrapper, ≥ 90% on the pure + classifier/parser modules. + +** Coverage as a gap-finder, not a number (per phase) +Line coverage alone misses the branches that matter here, so each phase ends with +a *coverage-gap pass*, not just a percentage: +- After the first green run, read the branch report and map every uncovered branch + to either a new test or a consciously-excluded live-only behavior (with a comment + or a Manual-testing entry naming it). +- *Branch coverage is required* for the pure logic: the doctor classifier (every + outcome — fixable / needs-user-action / upstream-not-local / deferred-vpn), the + cleanup-unverified path, the redaction paths, the degraded hot-path fallback, the + timeout branches, and the portal/nmcli parsers. +- A phase isn't "done" until its coverage-gap pass is recorded — uncovered logic is + either tested or explicitly excused, never silently uncovered. + +* Files touched (planned, all in =~/.dotfiles=) + +- =net/= package (src-layout, like pocketbook) — engine + panel. +- =hyprland/.local/bin/waybar-net= — the indicator (replaces =waybar-netspeed=). +- =hyprland/.local/bin/net= — engine CLI entry (console-script shim). +- =hyprland/.config/waybar/config= — swap =custom/netspeed= → =custom/net=; + remove =custom/airplane=. +- =hyprland/.config/waybar/style.css= — captive / no-internet / degraded / + rfkill classes; remove airplane classes. +- =tests/net/=, =tests/waybar-net/= — suites. +- =captive= — refactor: extract probe + reset into functions callable + non-interactively (a =--json= probe mode) so the engine reuses them. +- =~/.config/net/config= — seed config (probe TTL, speedtest server, redaction + flags). No secrets; not a credential store. +- dotfiles =Makefile= — add the console-recovery targets (=online=, =net-doctor=, + =net-status=, =net-diagnose=, =net-portal=, =net-reset=, =net-bounce=). +- *Deletions once net ships* (the airplane module is absorbed): + =hyprland/.local/bin/waybar-airplane=, =hyprland/.local/bin/airplane-mode=, + =tests/waybar-airplane/=, =tests/airplane-mode/=, and the =custom/airplane= + module + its css. +- archsetup Hyprland step — add =gtk4-layer-shell=, =python-gobject=, + =speedtest-go-bin= to the install lists (the only archsetup change; no =gpg= + added, secrets stay in NM's store). + +* Resolved decisions (Craig's calls + this response) + +1. Panel UI tech → GTK4 + gtk4-layer-shell, shared pocketbook scaffold (one + panel shell, reused by the desktop-settings sibling). +2. Engine language → Python =net= package; shells out to =captive= for the + portal-force flow, native cheap probe for the bar path. +3. Connectivity probe → split cadence (fast link poll every 2s + slow cached + internet/captive probe, TTL ~45s) with single-flight + atomic cache. +4. No keyboard-modifier clicks (waybar can't qualify them) — the panel hosts the + rich actions; bar clicks dispatch detached jobs (phase-aware). +5. No separate credential store (Craig's call, cj). Secrets live in NM's own + =system-connections= (root =0600=, inline), touched via nmcli. No GPG, no + gpg-agent, no =~/.config/net/connections=. Supersedes the earlier GPG-store + design. +6. =custom/netspeed= absorbed into =custom/net=; throughput moves to the tooltip. +7. Speed-test backend → =speedtest-go= (already installed), not a new + librespeed-cli dependency; librespeed-cli is the self-hosted fallback. +8. Code lives in the dotfiles repo; archsetup only installs deps. +9. v1 add/edit scope = open + WPA-PSK; enterprise/802.1X is activate-only, + add/edit is vNext (settled by Craig 2026-06-29 — no enterprise networks in his + history, so the form would be unused UI). +10. =net doctor= is in v1 (Craig's call, cj) — a one-shot diagnose+fix mode, + reachable from a TTY via =make online= / =make net-doctor=. (The earlier + "defer the doctor/bundle command" decision is reversed.) +11. Diagnose (read-only) and Repair (mutating, confirmed) are separated in the + panel and the CLI; Repair is tiered lightest-first (rfkill → reset → bounce). +12. =custom/net= absorbs the airplane module (Craig's call, cj). *As built + (2026-06-29, option 1): display-only.* net shows the airplane state (reads + the airplane-mode state file); the =airplane-mode= low-power toggle is kept + (radios + CPU + brightness + services is not a network concern) and moved to + =custom/net='s right-click + signal 15. Only the redundant display pieces — + =waybar-airplane=, =custom/airplane=, and the retired =waybar-netspeed= — + plus their tests/css were deleted. The earlier "delete airplane-mode" framing + is superseded. +13. Repair includes a full-stack bounce and an rfkill-unblock (Craig's calls, + cj) — the latter recovers the framework-laptop post-power-loss soft-block. +14. VPN / WireGuard is a planned Phase 5 (Craig's call, cj), not a permanent + exclusion. + +V2 redesign decisions (Craig, 2026-06-30): + +15. *No terminals anywhere in the module* — =net-popup= is removed; every action and + result renders in the panel. No terminal is ever used to report information to the + user or to collect input from them: every prompt, confirmation, repair stream, and + result lives in the panel UI (Craig, cj, 2026-06-30). Reverses the part of decision + 11 that ran privileged repairs in a terminal "so sudo/polkit can prompt". (Unrelated + to the doctor's "terminal states" — that word means a final outcome, not a tty. The + one open question is the dead-GUI console-recovery path; see the VERIFY in todo.org.) +16. *Passwordless privileged path* — a root-owned helper + a narrow NOPASSWD sudoers + rule scoped to it, archsetup-installed, run as =sudo <helper> <verb>=. This gates + decision 15 (a worker thread can't prompt). Absorbs the earlier DoT-toggle + follow-up and fixes the detached-restore-watcher bug. +17. *Verify every action* — each mutating op (repair, connect, forget, add, DNS + override) re-checks its effect and surfaces pass/fail in the panel. +18. *Detect + respond to every failure mode, edges included* — the full ~44-mode + catalog (todo.org redesign task) is the contract; auto-fix where safe, else report + the exact in-panel text. Includes IPv6-only awareness and multi-homing, which need + diagnose to stop being IPv4-only and single-iface. +19. *Navigation* — top tabs Connections | Diagnostics | Performance; Diagnostics + merges Diagnose + Repair (Diagnose | Get Me Online | Advanced over a shared + streaming area); Speed test under Performance. +20. *Automatic diagnostic verbose-capture* (Craig, 2026-06-30) — on a failing + diagnose, elevate the underlying stack (NM / resolved / wpa_supplicant) to debug, + capture the journal + dmesg window, restore (guaranteed + crash-guarded), and + write a redacted bundle. Plus a manual Debug on/off toggle in Advanced. Restore + bulletproof, secrets scrubbed before the bundle, log-level toggles via the V2 + helper. See Observability. + +* Implementation phases + +*Phases 1-3 are SHIPPED* (2026-06-29 → 2026-06-30, dotfiles); their acceptance +criteria passed and the work is live on velox. Phase 4 (docs/rollout) and Phase 5 +(VPN) remain. The V2 redesign phases at the end are designed, not yet built. + +- *Phase 1 — Indicator + console recovery (task #C).* =net status= + =net probe= + (native cheap probe, reusing captive's logic) + the =captive= probe refactor + + =waybar-net= + the split-cadence cache (single-flight, atomic, stale classes) + + CSS states (incl. rfkill) + performance budget. Plus the CLI-only recovery path: + =net repair= tiers (rfkill / reset / bounce), =net doctor [--fix]=, and the + Makefile targets (=make online= etc.) — all testable without the GTK panel. + Absorbs the airplane state and removes the standalone airplane module. Interim + left-click keeps the existing scratchpad until the panel lands. + - *Acceptance*: fresh-login waybar smoke test shows correct state on + online/captive/no-internet/wired/rfkill; =net status= stays within budget + under a fake slow nmcli (degraded state); =net doctor --fix= recovers a + soft-blocked radio from a TTY; the live captive checklist passes at a real + portal; the airplane state works and the old airplane module is gone; + reverting = swap =custom/netspeed= + =custom/airplane= back. +- *Phase 2 — Panel shell + connection management (task #B core).* GTK4 + layer-shell scaffold + =net list/up/down/add/edit/remove/rescan= + MRU list + + mutation safety/rollback + panel state machines. + - *Acceptance*: switch wifi↔wifi and ethernet↔wifi without stranding; a failed + switch leaves the prior link up; add/edit open + WPA-PSK writes the secret to + NM; remove confirms; panel states render for loading/rescan/activation. +- *Phase 3 — Diagnostics + speed test in the panel.* Wire =net diagnose= / + =net repair= / =net doctor= / =net portal= / =net speedtest= into the Diagnose + vs Repair sections; the "Get me online" button; portal Open button; speedtest + progress + cancel. + - *Acceptance*: diagnose runs read-only; each repair tier confirms + verifies + cleanup (DNS override reverts, shown); speedtest result parses from + speedtest-go and a fixture-driven failure shows the right message. +- *Phase 4 — Docs + rollout.* In-app help (=net --help= / per-command help, the + panel help affordance), README/user-guide (commands, panel, config, + troubleshooting, the make targets, rollback), and the manual dep step on ratio. + - *Acceptance*: =net --help= and each subcommand's help are complete; the + user-guide covers every command + the recovery targets; ratio rollout + documented. +- *Phase 5 — VPN / WireGuard (future).* Fold the existing archsetup wireguard + tooling into the same panel + CLI (=net vpn ...=). Out of the v1 milestone; + specced separately when picked up. + +V2 redesign phases (designed 2026-06-30, dependency order): +- *V2.1 — Sudo helper + NOPASSWD sudoers (gates everything).* Root-owned helper + dispatching net's fixed privileged verbs, archsetup-installed, narrow sudoers. + Also fixes the detached DoT-restore-watcher bug. + - *Acceptance*: every repair runs passwordless in-panel on a non-NOPASSWD machine; + the sudoers rule is scoped to the helper only. +- *V2.2 — Merged Diagnostics panel + nav restructure.* Connections | Diagnostics | + Performance; the Diagnostics sub-row + shared streaming area; Advanced reveal + + tooltips; delete =net-popup=. + - *Acceptance*: no terminal opens for any action; repair progress streams in the + panel; Speed test lives under Performance. +- *V2.3 — IPv6-aware and multi-homing-aware diagnose.* Stop treating no-IPv4 as a + failure when online over IPv6; identify which interface owns the default route. +- *V2.4 — Close every detect/correct gap in the catalog, with post-action + verification.* Work the redesign-task catalog mode by mode. +- *V2.5 — Automatic diagnostic verbose-capture.* Snapshot/elevate/capture/restore + around a failing diagnose + the Advanced Debug on/off toggle; guaranteed + + crash-guarded restore; redacted support bundle; helper log-level verbs. + - *Acceptance*: a failing diagnose leaves a redacted bundle (NM/resolved/ + wpa_supplicant journal + dmesg) and restores every log level; a crash mid-capture + is detected and restored on the next run; the secret-leak test finds no PSK/EAP in + a bundle; the toggle elevates and restores on demand. + +* Open items / risks + +- gtk4-layer-shell dropdown anchoring under a waybar module needs the same + positioning work pocketbook solved; reuse it. (Phase 2.) +- The =captive= refactor must keep the standalone CLI behavior identical while + exposing a non-interactive =--json= probe; covered by the existing + =tests/captive= suite plus new probe-mode tests. (Phase 1.) +- speedtest-go server selection variance (nearest-server flor) — pin a server in + config if results are noisy. (Phase 3.) +- The background-probe kick from =net status= must be truly non-blocking (spawn + + detach); enforced by the single-flight lock and the performance benchmark test. + +* Rollback + +Each phase is independent. The indicator (Phase 1) is a drop-in replacement for +=custom/netspeed= (and =custom/airplane=); reverting is swapping those modules +back in the config and restoring their scripts. The panel is additive — not +wiring its clicks leaves the bar working as before. No credential store to roll +back (secrets stay in NM throughout). + +* Review findings [40/40] + +** DONE Define the structured diagnostics contract :blocking: +The spec says the engine "emits JSON" and that diagnostics "reuse =captive= +verbatim", but the current =~/.dotfiles/common/.local/bin/captive= flow is a +human-readable bash script that mixes diagnostics, sudo prompts, DNS mutation, +browser launch, and terminal prose. A GTK panel cannot reliably turn that into +clear state, progress, cancellation, or useful error messages. Define the +machine contract before implementation: every diagnostic step should have a +stable id, status (=pending/running/pass/warn/fail/skipped=), redacted evidence, +elapsed time, safety outcome, and next action. Keep =captive= as the interactive +CLI, but either refactor reusable probe/reset functions behind =net diagnose +--json= or make =captive= expose a non-interactive JSON mode. This blocks the +panel and logging work because otherwise the implementer must invent the +boundary. + +Disposition: accept — added the "Diagnostics contract" section (per-step id / +status / evidence / elapsed / safety / next_action) and the =captive= =--json= +probe-mode refactor under Architecture + Files touched. + +** DONE Specify user-facing failure messages and recovery actions :blocking: +The spec names failure states like =no-internet=, =captive=, failed probe, +failed reset, missing DNS, and missing speed-test backend, but it does not define +the messages the user sees or what each message tells them to do next. For this +feature, "error" is not enough: a user needs to know whether WiFi is associated, +whether DHCP succeeded, whether DNS is hijacked/broken, whether HTTP is +intercepted, whether sudo was declined, whether a command timed out, and whether +the system was left unchanged or partially changed. Add a message table for the +indicator, panel, and CLI with: failure class, visible text, evidence included, +redaction rule, and next action. This is blocking because UX quality here is the +product, not an implementation detail. + +Disposition: accept — added the "Failure states, messages, recovery" section +covering each class, the visible message, the "what changed" residue note, and +the next action across indicator/panel/CLI. + +** DONE Define the debug log and redacted support bundle :blocking: +There is no observability section. When this fails in a hotel or cafe, an agent +needs enough evidence to diagnose it without rerunning destructive actions. Add +log location, rotation/retention, JSONL event schema, command argv logging, +exit-code/stderr capture, elapsed time, selected iface, NM active connection +UUID, probe URL class, HTTP code, redirect host, DNS servers, and cache +read/write events. Also define a =net doctor --json= or =net debug-bundle= +command that emits redacted status, recent log events, dependency versions, and +a reproduction command. Redact SSID if configured, MAC addresses, portal query +tokens, PSKs, EAP identities/passwords, IPs when requested, and all GPG/NM +secrets. This blocks implementation readiness because post-failure diagnosis is +currently left to ad hoc terminal spelunking. + +Disposition: modify — accepted the JSONL event log, the schema, and the redaction +rules in full (new "Observability" section). Deferred the dedicated =net +debug-bundle= / =net doctor= command to vNext: for a single-user tool =net +diagnose --json= (the snapshot) plus the event log (the history) cover +post-failure diagnosis; a bundle command is gold-plating for v1. Recorded under +Out + Resolved decision 10. + +** DONE Pin the nmcli parsing and timeout contract :blocking: +The spec lists nmcli operations but not the exact fields, output modes, escaping +rules, ID semantics, or timeouts. This is risky because SSIDs and connection +names can contain spaces, colons, duplicates, hidden names, and non-ASCII; the +current =waybar-netspeed= already had an SSID parsing bug. The nmcli manual +documents =--terse=, =--get-values=, =--escape=, =--wait=, ID/UUID/path +selection, =passwd-file=, and built-in connectivity states +(=none/portal/limited/full/unknown=) at +https://man.archlinux.org/man/nmcli.1.en. The spec should require UUIDs for +saved-profile operations, explicit =--wait= budgets, parser tests for escaped +colons/backslashes/newlines/duplicate names/hidden SSIDs, and a decision on when +to use or ignore =nmcli networking connectivity [check]=. This is blocking +because the command wrapper is the core reliability boundary. + +Disposition: accept — added the "nmcli contract" section: terse + =--escape= + +=--get-values=, UUID-keyed ops, explicit =--wait= budgets, NM connectivity as a +cheap hint (our probe authoritative), and the parser test matrix. + +** DONE Define cache concurrency, atomicity, and stale-state behavior :blocking: +=net status= may spawn =net probe= whenever the cache is stale, but the spec +does not define locking, process coalescing, atomic writes, crash cleanup, or +what happens when the probe hangs. With a 2s Waybar interval, a bad network could +start overlapping probes, corrupt the runtime cache, or keep showing stale +"online" while the link is gone. Add a single-flight lock under +=$XDG_RUNTIME_DIR/waybar=, atomic write+rename for cache updates, max probe +runtime, stale age classes (fresh/stale/expired/unknown), cache invalidation on +iface/SSID/connection UUID change, and tests for concurrent =net status= calls. +This blocks the fast-path design because it is the main performance and +correctness risk. + +Disposition: accept — added "Concurrency, atomicity, staleness" under the +Connectivity model: flock single-flight, temp+rename atomic write, ≤6s probe +timeout, fresh/stale/expired/unknown classes, iface/SSID/UUID invalidation, stale +lock reclaim, plus concurrency tests in the test plan. + +** DONE Bound hot-path performance with measured budgets :blocking: +The spec says the cheap poll should be sub-100ms, but the proposed fast path +still may call multiple =nmcli= commands every two seconds, read sysfs, parse +throughput, and maybe spawn a background probe. The existing =waybar-netspeed= +had a deliberate sleep for throughput sampling; replacing it must define how +throughput is sampled without sleeping in the bar path. Add a per-command budget +for =waybar-net= and =net status=, a maximum number of subprocesses on the hot +path, a timeout for every subprocess, benchmark tests with fake slow =nmcli=, +and a rule that the indicator emits a degraded JSON state rather than blocking. +This is blocking because Waybar custom modules can visibly freeze or lag when +their exec path stalls. + +Disposition: accept — added the "Performance budgets" section: <100ms typical / +<250ms worst, throughput sampled across the poll interval (no in-process sleep), +one nmcli call max on the hot path, timeouts on every subprocess, the degraded +state, and a fake-slow-nmcli benchmark test. + +** DONE Make click actions non-blocking and visible :blocking: +Waybar right-click runs =net reset= and middle-click runs =net portal= directly. +Those operations can require sudo, open browsers, mutate DNS, delete/recreate NM +profiles, or hang on network commands, but Waybar click handlers provide no +panel, terminal, progress, or cancellation surface by default. Define whether +right/middle click instead opens the panel focused on the action, dispatches a +background job with notifications, or is removed from v1. If kept, specify +single-flight behavior, how sudo/polkit prompts surface, how success/failure is +reported, and how the user can inspect logs. This blocks UX readiness because +the fastest remediation path is currently the easiest place to hide failure. + +Disposition: modify — accepted the concern; made the interactions phase-aware and +non-blocking. Every click dispatches a detached, single-flight background job and +reports via =notify=; sudo surfaces through polkit/the normal prompt; failures go +to the notify + the event log. In Phase 1 (no panel) left-click runs probe + +notify and keeps the scratchpad; from Phase 2 left-click opens the panel focused +on the action. Recorded in the Indicator "Interactions" subsection. + +** DONE Specify connection mutation safety and rollback :blocking: +The spec says row click switches connections and remove gets a confirm, but it +does not define what happens when a switch partially succeeds, disconnects the +current working link, needs a password, loses the default route, or triggers +auto-activation. The nmcli manual warns that =connection down= does not prevent +future auto-activation and may internally block a profile until user action. +Define preflight, the exact NM command sequence, whether the old active +connection is kept until the new one proves usable, when rollback is attempted, +how long activation waits, and what the panel says when rollback fails. This is +blocking because the module can strand the user offline. + +Disposition: accept — added "Mutation safety + rollback" under Connection +management: keep the prior link up until the target activates (=--wait 30=), no +teardown on failure, password-required surfaced not stranded, =net down= reports +post-op active state + the auto-reactivation caveat, and the pinned NM command +sequence is tested against fake nmcli. + +** DONE Define the credential-store security model :blocking: +The GPG store is described as optional and default-unencrypted, but the spec does +not define file modes, schema, secret-source rules, import/export prompts, +recipient verification, stale secret handling, or what is logged. It also says +NM remains source of truth while the user-owned store contains PSK/EAP secrets, +which creates two truth sources for sensitive data. Add a precise schema, +=0600= file creation with parent-dir permissions, encrypted-recipient checks, +plaintext warning text, explicit opt-in flow, redaction requirements, behavior +when NM has a secret not in the store, behavior when the store has a secret NM +rejects, and tests for no secret leakage in JSON/logs/errors. This blocks Phase +4 and the full spec because otherwise the implementer must make security +decisions mid-code. + +Disposition: accept — rewrote "Credential storage" with the versioned schema, +=0600= file / =0700= dir, recipient verification on opt-in, the plaintext +warning, secret-source rule (entered/exported, never harvested from root store), +the two-source reconciliation policy (NM wins live, store wins for what NM +lacks, stale-secret flagging), and the no-leak tests. + +** DONE Define EAP, enterprise WiFi, and unsupported connection behavior :blocking: +The store says "PSK/EAP" and connection management says add/edit, but there is +no v1 contract for WPA-Enterprise fields, certificates, identity vs anonymous +identity, hidden networks, static IP, proxy settings, metered flags, MAC +randomization, or 802.1X prompt behavior. Either scope v1 to open/WPA-PSK plus +existing saved-profile activation, or define the minimum EAP form and the +unsupported-state messages. This blocks add/edit/import because enterprise WiFi +is too sensitive to hand-wave. + +Disposition: modify (scope) — scoped v1 to open + WPA-PSK add/edit, with +*activation* of any existing saved profile (including enterprise). Enterprise / +802.1X add/edit, static-IP, proxy, metered, and MAC-randomization editing are +vNext, shown as "edit via nmtui/nmcli". Recorded in Scope/Out, Connection +management, and Resolved decision 9. + +** DONE Split read-only diagnostics from mutating remediation :blocking: +The panel's diagnostics section includes probe, bounce/reset, gateway ping, and +DNS override test in one area, while =captive= currently performs resets and +temporary DNS changes as part of its flow. Users need to know which buttons are +read-only and which mutate NM profiles, MAC mode, DNS, or browser state. Add +separate "Diagnose" and "Repair" actions, confirmations for destructive or +privacy-changing operations, explicit cleanup verification for DNS override, and +a terminal state when cleanup is unverified. This blocks readiness because +network repair must not surprise the user or leave hidden residue. + +Disposition: accept — split the panel into a read-only Diagnose section and a +confirmed, mutating Repair section (and split the CLI into =net diagnose= vs =net +repair=). Added =cleanup_verified= + a terminal =cleanup-unverified= state to the +diagnostics contract. + +** DONE Define panel state, cancellation, and permissions UX :blocking: +The panel sections list buttons and a streaming output area, but not loading +states, disabled states, empty states, keyboard/focus behavior, cancellation, or +permission-denied handling. Add panel state machines for connection list loading, +rescan in progress, activation in progress, diagnostics running, speedtest +running, and no NetworkManager/no WiFi/no permissions/no GPG key/no +librespeed-cli. Each long operation should be cancellable where possible or +clearly non-cancellable with an elapsed-time display. This blocks the GTK work +because without it the implementer must invent the user flow. + +Disposition: modify — accepted the state-machine requirement (added "Panel state, +cancellation, permissions"), but scoped the state set to what can actually occur +on the two-machine fleet: dropped "no NetworkManager" as a modeled state (NM is +always present; a missing nmcli is a single hard-error exit) and kept +no-wifi-hardware, missing speedtest-go, no-GPG-key, plus the in-progress states +with elapsed-time + cancellation where the op allows. + +** DONE Verify speed-test dependency, server choice, and failure contract :blocking: +The spec chooses =librespeed-cli= and notes availability/default-server research +as an open risk, but Phase 3 still depends on parsing its JSON and showing +progress. I checked the upstream project page +(https://github.com/librespeed/speedtest-cli) and the AUR URL named by search is +not sufficient as a verified package/install contract in this spec. Add the +exact package name/source to install, command version expected, JSON shape, +server-selection policy, timeout, cancellation behavior, offline/rate-limited +messages, and tests with fixture JSON and fixture stderr. This blocks Phase 3 +because speed-test failure modes are otherwise undefined. + +Disposition: modify — verified live and changed the backend: =speedtest-go= (AUR +=speedtest-go-bin=, 1.x) is already installed on velox and supports =--json=, +=--server=, =--no-download/--no-upload=, so v1 needs no new dependency. +librespeed-cli (AUR =librespeed-cli= / =-bin=) is the documented self-hosted +fallback. Added the "Speed test" section with server policy, timeout, +cancellation, the failure-message mapping, and fixture-JSON/stderr tests. + +** DONE Define dependency installation and repo boundaries :blocking: +The files touched section alternates between archsetup paths and the external +dotfiles repo, while pocketbook has been folded into this repo and its previous +archsetup provisioning was intentionally removed. The spec should state where +the =net= package actually lives, which repository owns the scripts/tests, +whether =gtk4-layer-shell=, =python-gobject=, =librespeed-cli=, =gpg=, =nmcli=, +=curl=, and =resolvectl= are installed by archsetup or assumed present, and the +Makefile targets for test/lint/install. This blocks implementation because the +current path plan can produce code that is not installed on a fresh machine. + +Disposition: accept — added the "Repository + dependencies" section: all code in +=~/.dotfiles= (=net/= package in-tree like pocketbook, scripts in the hyprland +tier, tests under =tests/=), archsetup owns only the dep install +(=gtk4-layer-shell=, =python-gobject=, =speedtest-go-bin=; nmcli/curl/resolvectl +already present), Makefile =make test= collects the package suite, and a +daily-drivers note for ratio. Rewrote Files touched to match. + +** DONE Expand the test plan for failure, concurrency, and live verification :blocking: +The testing plan covers normal parsing and fake command sequences, but it misses +the riskiest behaviors: slow/hung =nmcli=/=curl=/=librespeed=, concurrent +=net status= cache refresh, corrupt cache, stale cache after SSID change, +permission denied, sudo declined, DNS override cleanup failure, NM partial +activation, duplicate connection names, secret redaction, missing optional +dependencies, no WiFi hardware, wired+tether+WiFi ambiguity, portal redirect +tokens, and Waybar click handlers. Add unit/fixture tests for each class plus a +manual/live checklist gated out of the normal suite. This is blocking because +the current plan would leave the exact "things that can go wrong here" mostly +untested. + +Disposition: accept — rewrote the Testing plan with the "Failure + concurrency" +class (slow/hung commands, single-flight, corrupt/stale cache, perm-denied, +cleanup-failure, partial activation, redaction, missing deps, no-wifi, +multi-active) and a per-phase live checklist gated out of the suite. + +** DONE Define status JSON schemas and compatibility rules +The spec says all subcommands take =--json= but does not define schemas. Add +versioned JSON examples for =status=, =probe=, =list=, =diagnose=, =speedtest=, +and error envelopes, including nullable fields and unknown/degraded states. This +is non-blocking for product direction but should be fixed before code so tests +can lock the CLI contract. + +Disposition: accept — added the "JSON schemas" section with versioned (=v:1=) +envelopes for status / probe / list / diagnose / speedtest and a shared error +envelope, including the degraded/unknown states. + +** DONE Rename or alias the phasing section for workflow compatibility +The spec has a usable =Phasing= section, but the spec-review workflow expects an +=Implementation phases= section that can be lifted into =todo.org=. Rename it or +add an alias heading during response. This is non-blocking because the existing +phase decomposition is understandable, but aligning the heading prevents future +workflow friction. + +Disposition: accept — renamed =Phasing= → =Implementation phases= and added +per-phase acceptance criteria. + +** DONE Add documentation and rollout acceptance checks +Rollback is described, but docs and rollout are thin. Add README/user-guide +updates for commands, panel behavior, config file, GPG opt-in, troubleshooting, +and rollback; add acceptance checks for each phase, including a fresh-login +Waybar smoke test and restoring =custom/netspeed=. This is non-blocking but +important for handing the feature to a future session without re-discovery. + +Disposition: accept — added per-phase acceptance criteria under Implementation +phases (incl. the fresh-login waybar smoke test and the =custom/netspeed= +restore), a Phase 4 "Docs + rollout", and (answering Craig's cj follow-up) a +dedicated "Help + documentation" section with the three help layers (CLI help, +panel help affordance, user guide). + +** DONE Add a failure-mode coverage table :blocking: +The spec now names many individual network failures, but it still does not carry +one compact coverage matrix that says, for each common failure mode, whether +=net diagnose= detects it, whether =net doctor --fix= can repair it, and what +terminal user action remains when it cannot. Add a table covering at least: +rfkill soft block, rfkill hard block, no WiFi hardware, associated/no DHCP, +gateway unreachable, captive DNS hijack, broken DNS where 1.1.1.1 works, HTTP +portal, HTTP interception without a parseable portal URL, upstream/AP outage, +wrong WPA password or missing secret, enterprise auth/cert failure, duplicate +SSID/connection-name ambiguity, hidden SSID, multiple active links, wedged +NetworkManager, slow/hung command, stale/corrupt cache, DNS cleanup failure, +missing speedtest backend, and VPN/routing interference. This blocks because +Craig asked for confidence that the diagnostics and doctor cover the real field +failures, and prose scattered across sections is too easy to misread. + +Disposition: accept — added the "Failure-mode coverage" section: a 22-row table +(every mode the finding named) with detect / doctor-fix / terminal-action +columns, conformed to the org-table standard (rules under every row, ≤120). + +** DONE Pin DNS repair semantics in doctor :blocking: +The spec diagnoses DNS hijack, broken hotel DNS, and the temporary 1.1.1.1 +override test, but =net doctor --fix= does not say whether it merely recommends +the override, applies a temporary override during recovery, or leaves DNS alone +after diagnosis. Define the exact behavior for each DNS class: captive hijack +should open the portal, broken DNS where 1.1.1.1 works should either offer an +explicit temporary repair with cleanup verification or recommend the command, +and port-53/egress blocking should stop as upstream/not locally fixable. This is +blocking because DNS is one of the most common "connected but unusable" failures +and the current doctor contract is ambiguous. + +Disposition: accept — added "DNS handling in doctor (explicit per class)" under +the new Doctor section: hijack → open portal (no DNS mutation); broken-but-1.1.1.1 +→ explicit temporary override with cleanup verification under =--fix=, recommend +otherwise; egress-blocked → terminal =upstream-not-local=. + +** DONE Make auth failures terminal user-action states :blocking: +Wrong WPA password, missing NM secret, locked keyring/polkit denial, enterprise +802.1X certificate/identity failure, and portal login-required are not fixed by +resetting or bouncing NetworkManager. The doctor sequence should classify these +as =needs-user-action= terminal states, stop before looping through destructive +repairs, and tell the user the exact next action (enter password, edit profile in +=nmtui=/=nmcli=, accept portal terms, provide cert/identity, or retry with +admin auth). This blocks because repeated reset/bounce against auth failures is +slow, noisy, and can make the network state worse without helping. + +Disposition: accept — added the =needs-user-action= terminal outcome to the +Doctor section: wrong password / missing secret / keyring-or-polkit denial / +802.1X cert-or-identity failure / portal-login-required all stop the doctor before +any destructive repair and name the exact next step. + +** DONE Define upstream/AP/provider failure terminal states :blocking: +Some failures are not client-repairable: AP has no uplink, hotel gateway is +down, DHCP server is broken, gateway drops traffic, ISP outage, or captive +portal backend is failing. The spec should define how =diagnose= proves "local +link is up but upstream is broken" and how =doctor --fix= stops after local +repairs are exhausted with a clear message like "local repairs tried; likely +upstream/AP/provider" plus the evidence. This blocks because users need to know +when to stop poking the laptop and switch networks or contact the venue. + +Disposition: accept — added the =upstream-not-local= terminal outcome: diagnose +proves link-up + IP + gateway-reachable but no route out and no captive redirect; +=doctor --fix= stops after local repairs with "local repairs tried; likely +upstream/AP/provider" + evidence → switch network / contact venue. + +** DONE Decide how VPN and policy routing affect v1 diagnosis +VPN/WireGuard management is Phase 5, but active VPNs, policy routes, DNS +overrides, and firewall killswitches can break apparent internet access in v1. +The current spec does not say whether v1 detects active VPN/policy routing and +classifies "network is fine, VPN route/DNS is broken" separately from WiFi +failure. Add either a v1 diagnostic check for active VPN/default-route/DNS +ownership with a "deferred repair" outcome, or explicitly state that VPN-routed +failures are out of scope and may be misclassified. This is blocking if Craig +expects the module to diagnose normal daily-driver network failures while VPN +tooling remains separate. + +Disposition: accept (chose the detect-and-classify option) — v1 detects an active +VPN / non-NM default route / non-NM DNS owner and classifies =deferred/vpn= ("link +is fine; internet is VPN-routed"), distinct from a WiFi failure. v1 does not +repair it (VPN management is Phase 5); it names the VPN as the likely owner and +stops. Added to the Doctor section + the coverage table + a doctor-classification +test. + +** DONE Remove stale GPG-store references from the resolved spec +The spec now decides "no separate credential store; secrets live in +NetworkManager", but the Testing plan still mentions =gpg round-trip= and =GPG +store= tests, and the panel-state list still mentions a no-GPG-key state. Remove +those stale references and replace them with NM-secret/no-secret-leak tests. +This is non-blocking for product behavior but blocking for implementation +clarity: otherwise tests will be written for a credential store that no longer +exists. + +Disposition: accept — replaced the Testing-plan =gpg round-trip= / =GPG store= +bullets with an "NM secrets / no-leak" test (add/edit writes the secret via nmcli; +assert no PSK/EAP in any JSON/log/error; no store to round-trip) and dropped the +=no-GPG-key= panel state. Residue from the cj-comment pass that dropped the store. + +** DONE Reconcile status, goal, and task text before implementation :blocking: +The spec status says "Implementation-ready with caveats" and "Phase 1 ready to +build", but the body still has an unresolved enterprise add/edit VERIFY, the +Goal still says "optional GPG-encrypted secret store", and the unified task title +still names "GPG-stored secrets" even though the accepted design removed the +store. Before implementation, make the top-level status, goal, scope, task +mapping, and resolved decisions agree with the current design. This blocks +readiness because a developer starting from the top of the file would still build +or plan around abandoned GPG-store behavior. + +Disposition: accept — fixed the Goal ("secrets stay in NM's own store"), the +=[#B]= task-mapping line (notes the "GPG-stored secrets" framing is superseded by +decision 5), the enterprise VERIFY (now resolved → Status updated), and corrected +the stale =pytest= mentions to =unittest= (the repo's actual harness). Top-of-file +status/goal/scope/decisions now agree with the design. + +** DONE Resolve enterprise add/edit scope or make the caveat explicit :blocking: +The spec still says "One open question for Craig: pull enterprise add/edit into +v1?" and points to a VERIFY in =todo.org=. That is a real product-scope decision: +if enterprise add/edit is in v1, panel forms, nmcli command sequences, tests, +error messages, and docs change materially; if it is out, the UI must consistently +show activate-only with "edit in nmtui/nmcli". Decide it in the spec before +implementation, or downgrade the status to =Ready with caveats= with this exact +accepted caveat. As written, the spec cannot be plain =Ready=. + +Disposition: accept — Craig decided (2026-06-29): enterprise add/edit is vNext, +activate-only in v1. Settled in the Status line, the Scope/Out bullet, decision 9, +and the VERIFY (now DONE in todo.org). The UI shows activate-only with "edit in +nmtui/nmcli" consistently. Evidence: 24 saved profiles, 0 enterprise. + +** DONE Define the concrete test harness and coverage gate :blocking: +The spec says TDD, fake binaries on PATH, and benchmark tests, but it does not +define the actual harness contract: pytest vs unittest for the =net= package, +where fake =nmcli=/=curl=/=speedtest-go=/=rfkill=/=resolvectl= live, how test +fixtures encode command histories, how subprocess timeouts are simulated, how +Waybar scripts are executed end-to-end, and how coverage is run. Add the exact +Makefile targets (=test=, =test-unit= or package-local =pytest=), pytest config, +coverage command (e.g. branch coverage over =net/= and =waybar-net= wrappers), +minimum threshold, and the rule for reading the coverage report to add missing +tests before declaring a phase done. This blocks readiness because "what is the +test harness?" is still answerable only by analogy to older suites. + +Disposition: accept — added the "Harness + coverage gate" section. Corrected the +premise: the repo is =unittest= (=make test= → =python3 -m unittest=, 33 suites), +not pytest. Pinned the fake-binary stub convention (=tests/<name>/fake-*= on a +temp PATH), the fixture command→output map, timeout simulation, the end-to-end +=waybar-net= subprocess run, and coverage via a throwaway venv (coverage.py is +absent system-wide) with a ≥90% branch target on the pure modules. + +** DONE Use coverage to find missing behavior, not just report a percentage :blocking: +The spec does not say how coverage findings affect implementation. For this +feature, line coverage alone can miss the important holes: doctor classification +branches, cleanup-unverified paths, redaction paths, degraded hot-path fallbacks, +timeout branches, and auth/upstream/VPN terminal states. Define coverage review +criteria per phase: branch coverage for pure classifiers and parsers, named +untested branches allowed only with comments or manual-check entries, and a +required "coverage gap pass" after the first green test run that maps uncovered +logic back to tests or consciously excluded live-only behavior. This blocks +readiness because the current test plan is broad but does not force the suite to +expose missing edge tests. + +Disposition: accept — added the "Coverage as a gap-finder, not a number (per +phase)" subsection: branch coverage required for the doctor classifier (every +outcome), cleanup-unverified, redaction, degraded-fallback, timeout, and the +parsers; a mandatory coverage-gap pass after the first green run mapping each +uncovered branch to a test or a named live-only exclusion; a phase isn't done +until that pass is recorded. + +** DONE Convert error classes into exact user-facing strings and evidence fields :blocking: +The failure table and doctor outcomes classify errors well, but many messages +are still templates or descriptions rather than final text. Add exact strings +for indicator tooltip, notification, CLI stderr, JSON =error.message=, and panel +banner/step text for every failure-mode row, including cases doctor cannot fix: +wrong password, missing secret, enterprise cert failure, upstream/AP/provider +failure, VPN-routed failure, hard rfkill block, DNS cleanup failure, speedtest +missing, and HTTP interception without parseable URL. For each string, specify +the redacted evidence included and the next action. This blocks UX readiness +because "useful error" is only testable once the actual text and evidence are +defined. + +Disposition: accept — rewrote the Failure states section: each row now carries the +exact final string (with =<placeholder>= evidence), the evidence field, and the +next action, plus a per-surface rendering rule (indicator tooltip / notify / +CLI+JSON error.message+detail+code / panel banner all render the one canonical +string). Added the missing doctor-unfixable rows: hard rfkill, wrong password / +missing secret, enterprise cert failure, upstream/AP/provider, VPN-routed, HTTP +interception without a parseable URL, and DNS cleanup-unverified. + +** DONE Add an enhancement disposition table +The spec captures several good enhancements (doctor, Makefile recovery, rfkill, +airplane absorption, VPN phase), but it does not show that low-cost adjacent +enhancements were considered and accepted/deferred/rejected. Add a small radar +table for likely affordances: copy redacted doctor report, open/copy portal URL, +retry with hardware MAC, forget network, rescan now, pin speedtest server, show +last good network/result, watch mode for =net doctor=, desktop notification +actions, QR-code/share WiFi import/export, and keyboard picker. Mark each +=v1=, =vNext=, or =rejected= with a one-line reason. This is non-blocking, but it +prevents accidental loss of cheap UX wins and keeps the v1 panel focused. + +Disposition: accept — added the "Enhancement radar" table dispositioning all the +named affordances: open/copy portal URL, forget network, rescan, hardware-MAC +retry, pin speedtest server, copy redacted doctor report = v1; last-good +network/result, doctor watch mode, actionable notifications, keyboard picker = +vNext; QR-share = rejected (low value for a 2-machine personal setup). + +** DONE Tighten the panel UX flow before Phase 2 +The panel has sections and state machines, but not a concrete interaction flow: +default focused section, row content, primary/secondary buttons, disabled-state +rules, confirmation wording for reset/bounce/DNS override, how "Get me online" +reports each escalation, what stays visible after the panel closes, and keyboard +navigation. Add a short UX flow spec or wire-level outline before Phase 2. This +is non-blocking for Phase 1, but it blocks Phase 2 implementation because a GTK +panel can easily become noisy or surprising if these defaults are invented while +coding. + +Disposition: accept — added the "Panel UX flow (settle before Phase 2)" +subsection: default focus (Connections, or Diagnose when opened from a captive +state), row content, one primary button per section, disabled-state rules, exact +confirmation wording for reset/bounce/DNS-override/remove, the live "Get me +online" escalation reporting, what survives panel close, and keyboard nav. + +** DONE Reconcile the panel navigation source of truth :blocking: +Disposition: accept — folded into "V2 panel UX". V2 (Connections | Diagnostics | +Performance) is the sole current target; the shipped four-page stack is marked history, +not active design. +The spec now names at least three navigation shapes: the shipped four-page stack +(Connections / Diagnose / Repair / Speed test), the V2 three-tab plan +(Connections / Diagnostics / Performance), and the redesign task's Diagnostics +sub-row (Diagnose / Get Me Online / Advanced). That leaves an implementer free +to keep extra pages and buttons even though Craig is explicitly asking for the +opposite. Make V2 the sole current target: one panel opened from the bar, top +tabs =Connections | Diagnostics | Performance=, with Diagnostics owning the +read-only checks, repair stream, debug capture, doctor report, and related +diagnostic evidence. Mark the old four-page stack as shipped history only, not +active design. This blocks the redesign because the page model determines what +code is deleted, not just labels. + +** DONE Fold speed tests into the diagnostic story :blocking: +Disposition: modify — Craig pre-decided Speed test lives under Performance (decision +19), and Performance carries future throughput history, which meets this finding's own +"keep the tab only if it carries ongoing throughput" condition. Accepted the rest: +Diagnostics runs a lightweight inline latency/throughput probe as a Diagnose evidence +row (with skip conditions for offline / metered / no-backend), and the full speed +result is stored in the doctor report. Folded into "V2 panel UX → Diagnostics owns the +diagnostic story". +Speed test is currently isolated under =Performance=, while the Goal and user +mental model treat speed, latency, and packet loss as part of "diagnostics." +That split risks another top-level button/page whose only job is a diagnostic +measurement. Keep the top-level =Performance= tab only if it carries ongoing +throughput/history later; for V2, specify that Diagnostics can run a lightweight +performance check from the same Diagnose/Get Me Online flow when internet is +available, and that the full speed test is presented as a diagnostic evidence +row or secondary action rather than a separate repair-adjacent workflow. Define +when it is skipped (offline, metered/hotspot warning, missing backend) and how +the result is stored in the doctor report. This is blocking because otherwise +the implementation preserves avoidable navigation and misses a useful failure +signal. + +** DONE Define saved-list vs available-scan semantics :blocking: +Disposition: accept — folded into "V2 panel UX → Connections". Saved / Available now / +Wired groups; Rescan refreshes only the availability/signal layer, never the Saved +list. +=net list= merges saved profiles with in-range scanned networks, while the panel +copy calls the page "Connections" and the control "Rescan." It is not clear to a +user whether they are looking at saved connections, currently available +networks, or both. The current implementation confirms the ambiguity: +=connections.py= lists saved profiles MRU-first, merges live signal/security for +saved profiles that are in range, then appends unsaved in-range SSIDs with +=uuid: nil=. Rename and specify the groups: e.g. =Saved= (instant, does not +require scan), =Available now= (scan-backed, may still be loading/stale), and +=Wired=. =Scan= should refresh only the availability/signal layer, not gate the +saved profile list. This blocks readiness because it affects loading behavior, +button enablement, and whether unsaved rows can be selected. + +** DONE Replace the Add page with join-from-row behavior :blocking: +Disposition: accept — folded into "V2 panel UX → Connections". Selecting an unsaved +Available-now row is the join flow (SSID/security prefilled); the standalone Add modal +is deleted for visible networks; hidden/manual join lives behind Advanced. +The current Add dialog asks for an SSID as free text even though a scan usually +already found the SSID and security type. That is redundant UI and a common +network-manager mistake: it turns "join this visible network" into "copy a name +from the list and type it again." V2 should remove the standalone Add button and +modal for normal visible networks. Selecting an unsaved available row should +become the join flow: the SSID/security are prefilled from the row, open +networks connect with a confirmation only if needed, WPA/WPA2/WPA3-Personal ask +only for the password, and hidden/manual SSID is tucked behind an Advanced +"Join hidden network" affordance. Keep edit/create for enterprise profiles out +of v1/V2 unless explicitly added later. This blocks the redesign because it +changes the primary connection workflow and deletes a whole page/control. + +** DONE Pin the supported authentication types in the join flow :blocking: +Disposition: accept — folded into "V2 panel UX → Supported authentication classes". +The spec says "open + WPA-PSK" and "enterprise activate-only," but cafe/hotel +networks also commonly appear as open captive portals, WPA/WPA2/WPA3-Personal +(PSK/SAE), and sometimes transition-mode networks; less commonly they use +enterprise/802.1X, WEP, OWE/enhanced-open, MAC registration, voucher portals, or +proxy-required networks. Define the V2 join matrix from the scanned NM +=SECURITY= value: supported inline (open, captive/open, WPA/WPA2/WPA3 Personal), +activate-only if already saved (802.1X/enterprise), hidden-manual behind +Advanced, and unsupported/rare types with a clear in-panel explanation plus a +non-terminal next step. If an auth type is common enough to support, support it +in the panel; if it is too rare for V2, say "not supported here yet" and keep +the user in the same UI rather than sending them to a terminal tool. Also define +what security label appears in the row so the user knows why a password is or is +not requested. This blocks because the Add/Join deletion above cannot be +implemented safely without knowing which auth classes the simplified flow covers. + +** DONE Fix destructive confirmation tense and verification +Disposition: accept — folded into "V2 panel UX → Forget confirmation". +The Forget confirmation says "The saved password is deleted" before the user has +clicked Forget. That reads as if the destructive action already happened. Change +the copy to future tense and name the scope, e.g. "This will remove the saved +NetworkManager profile and its stored password from this machine." After the +operation, verify the UUID is gone, refresh the Saved list, and report either +"Forgot <SSID>" or "Could not forget <SSID>; nothing changed / partial state +<evidence>." This is non-blocking because the existing confirm prevents an +accidental click, but the wording is misleading and the V2 "verify every action" +decision should cover it. + +** DONE Make connection loading progressive and observable :blocking: +Disposition: accept — folded into "V2 panel UX → Connections (progressive loading)". +Opening the panel currently says "Loading connections..." while =net list= +collects both saved profiles and the WiFi scan. Saved profiles do not require a +network scan, so a slow scan should not delay the saved list. Split loading into +two phases: render saved NM profiles immediately, then overlay availability, +signal, and unsaved in-range rows when the scan completes. Show a small +scan-in-progress state with elapsed time and stale-last-scan age, and make +Rescan update only the scan-backed fields. This blocks because it is the direct +answer to "why does it take so long to see the list of connections?" and keeps a +bad radio scan from making the whole panel feel broken. + +** DONE Define the visual contract with Waybar and existing Archsetup UI +Disposition: accept — folded into "V2 panel UX → Visual contract". +The panel is a layer-shell popup anchored under Waybar, but the spec does not +state the visual contract. The live Waybar theme uses a dark rounded capsule +(=border-radius: 1rem=), golden border, compact monospace text, and state colors +for =custom/net=; the GTK panel currently has a generic title, stack switcher, +default GTK controls, and square-ish/default widget corners. Add a short style +section: panel should read as a Waybar-attached popup, not a separate app; match +Waybar's palette, border/radius, spacing density, and state colors; avoid square +corners where surrounding UI is rounded; keep cards out of cards; use compact +icon+label controls with tooltips for advanced repairs. Also cite any existing +Archsetup-owned GTK/panel conventions that should be reused. This is +non-blocking for engine work but should block final V2 UX acceptance. + +** DONE Add a diagnostics report affordance that users can actually find +Disposition: accept — folded into "V2 panel UX → Findable diagnostics report". +The observability design has a JSONL event log, =diagnose --json=, automatic +verbose capture, and redacted bundles, but the panel flow does not yet define +the user affordance that turns those into an inspectable diagnosis. Add a +Diagnostics-side "Copy report" / "Open report" action after every diagnose, +repair, and speed/performance run. The report should include the current step +statuses, elapsed time, final classification, last speed/latency result when +available, scan age, route/interface owner, relevant redacted event-log tail, +and bundle path when verbose capture ran. It must explicitly say whether any +repair mutated state and whether cleanup/verification passed. This is blocking +for observability because "logs exist somewhere" is not enough when the network +is already failing. + +* Review and iteration history + +** 2026-06-29 Mon @ 17:00:39 -0400 — Codex — reviewer + +- *What changed or was recommended:* Rubric: =Not ready=. Applied the + spec-review workflow and added blocking findings for diagnostics structure, + user-facing errors, observability, nmcli contracts, cache concurrency, + hot-path performance, Waybar click actions, mutation rollback, credential + security, unsupported WiFi types, panel states, speed-test dependency + verification, install boundaries, and test coverage. +- *Why:* The spec has the right broad shape, but this feature combines a status + indicator, NetworkManager mutation, sudo-backed remediation, captive-portal + probing, secret storage, and GTK UI. Too many failure modes were still left for + implementers to invent during coding. +- *Artifacts:* Findings recorded in =Review findings=. Local code read included + =~/.dotfiles/hyprland/.local/bin/waybar-netspeed=, + =~/.dotfiles/common/.local/bin/captive=, + =~/.dotfiles/tests/waybar-netspeed/test_waybar_netspeed.py=, + =~/.dotfiles/tests/captive/test_captive.py=, pocketbook's GTK layer-shell + scaffold, current Waybar config, and the linked =todo.org= tasks. External + checks included the Arch nmcli man page and upstream LibreSpeed CLI page. + +** 2026-06-29 Mon @ 17:35:00 -0400 — Claude Code (archsetup) — responder + +- *What changed:* Processed all 18 findings — 13 accepted, 5 modified, 0 + rejected. Accepts added new sections: Repository + dependencies, Performance + budgets, nmcli contract, JSON schemas, Diagnostics contract (+ Diagnose vs + Repair), Failure states/messages/recovery, Observability, cache + concurrency/atomicity/staleness, mutation safety + rollback, the rewritten + credential security model, the expanded test plan, JSON schemas, the renamed + Implementation phases with per-phase acceptance criteria, and a Status line. + Modifies: deferred the =net debug-bundle= command (log + =diagnose --json= + cover it), made click actions phase-aware + non-blocking, scoped enterprise + WiFi to activate-only (add/edit vNext), trimmed the panel state set to states + that occur on the two-machine fleet, and switched the speed-test backend to the + already-installed =speedtest-go= (verified live) instead of a new + librespeed-cli dependency. +- *Why:* Codex's review was strong and mostly right; the modifies are scope calls + for a single-user, two-machine tool (avoid gold-plating, use installed deps, + don't model impossible states) and one phasing reconciliation (the panel is + Phase 2, so Phase-1 clicks can't open it). +- *Artifacts:* All 18 findings completed =DONE= in place with dispositions. Live + check: =pacman -Qq | grep speed= (speedtest-go-bin installed; =speedtest-go + --json= confirmed). Status now "review incorporated (Codex, 2026-06-29)", + implementation-ready with the named Phase-2/3 caveats; Phase 1 ready to build. + +** 2026-06-29 Mon @ 17:37:58 -0400 — Claude Code (archsetup) — responder (cj comments) + +- *What changed:* Folded in Craig's 10 cj comments on the spec. Design changes: + (1) dropped the separate credential store entirely — secrets stay in NM's own + =system-connections= (root =0600=, inline), touched via nmcli; no GPG, no + gpg-agent (rewrote the secrets section, decision 5, dropped Phase 4 + the gpg + dep). (2) Added =net doctor [--fix]= + Makefile console-recovery targets + (=make online= etc.) as a first-class TTY path; reversed the earlier + defer-the-doctor call (decision 10). (3) Added a full-stack =bounce= repair and + an =rfkill= unblock repair + indicator state — the rfkill one recovers the + framework-laptop post-power-loss soft-block Craig hit. (4) =custom/net= absorbs + the airplane module; the standalone airplane scripts/tests/module are deleted on + ship. (5) Moved VPN/WireGuard from "out" to a planned Phase 5. (6) Added a + "Help + documentation" section (CLI help / panel help / user guide). Answered + the enterprise-defer rationale and the captive-auto-login explanation inline. +- *Why:* Craig's comments simplify (NM-only secrets, fewer deps) and harden the + recovery story (doctor + make targets + rfkill/bounce reachable from a dead-GUI + console — his stated need). Enterprise add/edit in v1 is the one open call, + raised as a VERIFY. +- *Artifacts:* All 10 cj blocks removed. Live checks: airplane scripts/tests + present (confirmed deletion targets); =/etc/NetworkManager/system-connections/= + is root =0600= with inline secrets (confirms the NM-only secret model); + =rfkill= present. VERIFY filed in =todo.org= for the enterprise-scope decision. + +** 2026-06-29 Mon @ 17:49:24 -0400 — Codex — reviewer + +- *What changed or was recommended:* Added a second feedback batch from the + common network failure-mode review: require a diagnose/doctor coverage table, + pin DNS repair semantics, make auth failures terminal user-action states, + define upstream/AP/provider terminal states, decide how active VPN/policy + routing affects v1 diagnosis, and remove stale GPG-store references. +- *Why:* The design now has a solid recovery shape, but the field question is + sharper: for each common failure, does =diagnose= catch it, does =doctor --fix= + fix it, or does the user need to switch networks/provide credentials/contact + the venue? That mapping needs to live in the spec, not in chat. +- *Artifacts:* Findings appended under =Review findings= after reviewing the + current spec's Diagnostics, Failure states, Doctor, Testing plan, and + responder history. + +** 2026-06-29 Mon @ 18:06:22 -0400 — Codex — reviewer + +- *What changed or was recommended:* Added a readiness pass focused on + development-time questions, missed enhancements, UX cleanliness, error wording, + and test/coverage mechanics. New findings require reconciling stale status/goal + text, resolving or explicitly caveating enterprise add/edit, defining the + concrete harness and coverage gate, making coverage actionable, turning failure + classes into exact user-facing strings, adding an enhancement disposition + table, and tightening the Phase-2 panel UX flow. +- *Why:* The network failure model is now strong, but implementation would still + force developers to invent test infrastructure, coverage expectations, final + error copy, and several UX details. The spec should decide those up front or + explicitly mark them as caveats. +- *Artifacts:* Findings appended under =Review findings= after reading the + updated spec, current archsetup Makefile test targets, pocketbook's pyproject, + and the linked =todo.org= enterprise VERIFY. + +** 2026-06-29 Mon @ 17:55:08 -0400 — Claude Code (archsetup) — responder (2nd review) + +- *What changed:* Processed all 6 second-batch findings — 6 accepted, 0 modified, + 0 rejected. Added a "Doctor: escalation, classification, terminal states" + section with four outcomes (=fixable= escalating lightest-first; =needs-user- + action=, =upstream-not-local=, =deferred/vpn= terminal) and explicit per-class + DNS handling. Added a "Failure-mode coverage" table (22 rows: detect / doctor-fix + / terminal action, org-table-standard conformant, ≤120). Cleaned the last + GPG-store residue (Testing plan → NM-secret/no-leak test; dropped the no-GPG-key + panel state). Added a doctor-classification test class. +- *Why:* The findings sharpen the doctor from "escalate repairs" to "classify, + then stop at the right terminal state" — the safety property that keeps + =doctor --fix= from looping destructive repairs against a wrong password or an + upstream outage. The VPN finding got the detect-and-classify option (not repair; + that's Phase 5) so v1 doesn't misread a VPN-routed failure as WiFi down. The + coverage table is the single artifact Craig asked for to trust the field + coverage. +- *Artifacts:* All 6 findings =DONE= in place; cookie =[24/24]=. Table conformed + via =wrap-org-table.el= (max line 120). No new findings introduced; no scope + expansion past what the findings asked. Spec remains implementation-ready, the + enterprise add/edit VERIFY still the one open decision. + +** 2026-06-29 Mon @ 18:24:51 -0400 — Claude Code (archsetup) — responder (3rd review) + +- *What changed:* Processed the third review (7 findings). The enterprise-scope + finding closed first on Craig's call (vNext, activate-only). The other 6, all + accepted: (1) reconciled the top-of-file text — fixed the Goal's GPG-store + wording, the =[#B]= task-mapping line, the resolved enterprise VERIFY, and + corrected the stale =pytest= mentions to =unittest= (the repo's real harness). + (2) Added a "Harness + coverage gate" section (unittest, fake-binary stubs on a + temp PATH, venv coverage, ≥90% branch on pure modules). (3) Added a per-phase + "coverage as a gap-finder" pass. (4) Rewrote the Failure states section to exact + final strings + evidence fields + a per-surface rendering rule, and added the + missing doctor-unfixable rows. (5) Added the "Enhancement radar" table + (v1/vNext/rejected). (6) Added the "Panel UX flow" subsection. +- *Why:* The findings close the gap between "design decided" and "a developer can + start": the harness/coverage contract, the exact UX strings, and the panel flow + are the things otherwise invented mid-code. The =pytest=→=unittest= correction + was a real defect — the spec contradicted the repo's actual test convention. +- *Artifacts:* All 31 findings =DONE=; cookie =[31/31]=. Both new tables conformed + via =wrap-org-table.el= (coverage 120, radar 110). Harness verified against the + live repo (33 unittest suites, =make test=, coverage.py absent → venv). Status + raised to "Ready for Phase 1; Ready-with-caveats overall" — no open decisions + remain. + +** 2026-06-30 Tue @ 16:32:07 -0400 — Claude Code (archsetup) — responder (build + V2 redesign) + +- *What changed:* Brought the spec current with what shipped and what got decided + across 2026-06-29/30. Recorded Phases 1-3 as SHIPPED (engine, indicator, GTK4 + panel, bar clicks). Added the native captive-login engine (=portal-login= repair + tier replacing the =captive= shell-out), the live-testing portal UX fixes (removed + the polkit-gated flush, already-online short-circuit, Chrome first-run suppression, + in-panel portal, extractor hardening), and the panel auto-hide + Close button. + Then folded in the V2 redesign Craig directed: no terminals anywhere, a passwordless + root-helper + NOPASSWD sudoers as the enabler, verify-every-action, the full + ~44-mode failure catalog (now the authoritative coverage, in todo.org), and the + Connections | Diagnostics | Performance nav. Added decisions 15-19, V2 phases + 2.1-2.4, and corrected the two failure-table rows the shipped code never matched + (gateway-bounce, venue-DNS auto-revert). +- *Why:* The spec had drifted behind the code (still said "Phase 1 shipped, terminal + for privileged ops") and behind Craig's redesign calls. The doc is the contract for + the V2 build, so it has to state the no-terminal reversal, the sudo-helper + dependency, and where coverage now lives. +- *Artifacts:* Updated Status, Architecture, Engine CLI surface, Doctor DNS handling, + Failure-mode coverage, Interactions, Panel, Resolved decisions, Implementation + phases. Shipped commits referenced: dotfiles =a7d7559=, =eef6b0b=, =450b7f0=. Full + catalog + redesign children in todo.org "Network panel redesign". + +** 2026-06-30 Tue @ 17:29:29 -0400 — Codex — reviewer + +- *What changed or was recommended:* Rubric: =Not ready= for the V2 redesign until + the new blocking findings are dispositioned. Added a fourth review batch focused on + diagnostics/observability and the simple UX questions Craig raised: reconcile the + active panel nav, fold performance/speed testing into the diagnostics story, make + saved-vs-available list semantics explicit, replace the Add page with join-from-row, + pin supported WiFi authentication classes, fix Forget confirmation tense, make + connection loading progressive, define the Waybar/Archsetup visual contract, and add + a findable redacted diagnostics report affordance. +- *Why:* The spec has strong engine and failure-mode coverage, but the current doc and + shipped implementation still replicate common network-manager UX mistakes: ambiguous + mixed lists, scan-gated saved data, redundant SSID entry, misleading destructive + copy, diagnostics split across too many pages, and observability that exists in logs + more than in the user's workflow. +- *Artifacts:* Findings appended under =Review findings= after reading the + spec-review workflow, the target spec, todo.org's redesign task, and current + dotfiles implementation files =net/src/net/connections.py=, + =net/src/net/gui.py=, =net/src/net/panel.py=, and Waybar =style.css=. + +** 2026-06-30 Tue @ 17:36:40 -0400 — Claude Code (archsetup) — responder (4th review) + +- *What changed:* Dispositioned all nine fourth-review findings (8 accept, 1 modify, + 0 reject) and wove them into a new "V2 panel UX — the target design" section. + Accepts: V2's three-tab nav is the sole target (the four-page stack is history); + Connections splits into Saved / Available now / Wired with Rescan touching only the + scan layer; selecting an unsaved row is the join flow (the Add modal is deleted); + a join matrix pins which NM SECURITY classes are inline-supported / activate-only / + hidden / unsupported; loading renders Saved first and overlays the scan; the Forget + copy goes future-tense + verified; every run ends with a Copy/Open redacted report; + and a Waybar visual contract (rounded capsule, golden border, state colors). Modify: + the speed-test finding kept Craig's decision-19 placement (full speed test under + Performance, which carries future history) while accepting a lightweight inline + latency probe as Diagnose evidence stored in the doctor report. Cookie [40/40]. +- *Why:* Codex read the live implementation and caught the UX places where the module + still replicated common network-manager mistakes — mixed lists, scan-gated saved + data, redundant SSID entry, misleading destructive copy, diagnostics scattered + across pages, observability that lived in logs more than the workflow. +- *Artifacts:* Findings 32-40 completed in place with dispositions; the modify reason + on the speed-test finding. New "V2 panel UX" section under Panel. todo.org redesign + task updated to point the V2 build at the dispositioned design. + +** 2026-07-01 Wed @ 10:43:18 -0400 — Claude Code (archsetup) — responder (cj comments) + +- *What changed:* Folded in Craig's three cj comments from his review. (1) Notification + rendering: title = "Networking", body = the failure label on its own line then the + canonical string. (2) VPN kill-switch: added a "kill-switch blocking" failure state + plus a detection-and-correction strategy off the =deferred-vpn= branch (rootless + cascade over =ip rule= fwmark 0xca6c / =wg show= / Proton =pvpn-*= NM connections / + =nft=/=iptables= drop tables / firewalld =drop= zone; classify blocking only when a + block artifact exists AND no tunnel is up; correction surfaces the exact root command + per artifact). (3) Terminals: strengthened decision 15 to "no terminal ever reports + to or collects input from the user", disambiguated from the doctor's "terminal + states" wording. +- *Why:* Craig's review annotations. The kill-switch closes a real gap in the + VPN-routed classification; the terminal directive makes the no-terminal rule + absolute for the module UX. +- *Artifacts:* Three cj blocks removed. VPN research subagent cited wg-quick man page, + Pro Custodibus, System76/Proton killswitch docs, and local =doctor.py:42= / + =classify.py:60= / =USNY.conf:15=. One open tension filed as a VERIFY in todo.org: + the dead-GUI console-recovery path (=make online= from a TTY) vs the no-terminal + directive. diff --git a/docs/design/2026-06-29-waybar-timer-module-spec.org b/docs/design/2026-06-29-waybar-timer-module-spec.org new file mode 100644 index 0000000..4b0ed0e --- /dev/null +++ b/docs/design/2026-06-29-waybar-timer-module-spec.org @@ -0,0 +1,217 @@ +#+TITLE: Waybar Timer Module (wtimer) — Design Spec +#+AUTHOR: Craig Jennings & Claude +#+DATE: 2026-06-29 + +* Goal + +One always-visible waybar module that keeps time four ways — countdown timer, +wall-clock alarm, count-up stopwatch, and pomodoro — with several items running +at once. The bar shows the most urgent item with a per-type glyph; the tooltip +lists them all. Backed by a single =wtimer= script over a small JSON state file. +notify fires on completion. fuzzel drives creation. No GTK app. + +Source task: archsetup =todo.org= "Waybar timer module" (=:waybar:=), including +the folded roam-capture scope expansion (mode-selectable single panel, +stopwatch, multiple simultaneous, per-mode hover text). + +* Scope + +** In +- *Timer* — count down a duration, notify on elapse, then remove. +- *Alarm* — fire at a wall-clock time, notify, then remove. +- *Stopwatch* — count up from start; pause/resume; manual stop. +- *Pomodoro* — work/break cycles (25/5, long break 15 after 4 works), auto-advance with a notify at each phase change, runs until cancelled. +- *Multiple simultaneous* — N items of any mix held in state. Bar shows one primary item plus a =+N= badge; tooltip lists every item with its remaining/elapsed and label. +- *Pause / resume* per item; *cancel* one or all. +- *Interactions* — click to create (fuzzel), middle-click pause/resume primary, right-click cancel (fuzzel pick), scroll to cycle which item is primary. +- *Per-type glyph + CSS state classes* (running / paused / urgent / break). +- *Persistence across waybar restarts* (state file in the runtime dir). + +** Out (v1, note for later) +- No GTK panel — waybar module + tooltip + fuzzel only. +- No persistence across *reboot* (runtime-dir state clears). Alarms set before a reboot won't survive. Acceptable v1; revisit with =~/.local/state= + a catch-up-on-boot pass if wanted. +- No sound selection per item (uses notify's type sound). +- No history/stats of completed pomodoros beyond the current run's cycle count. + +* Architecture + +- =wtimer= — a single executable Python script in =hyprland/.local/bin/=. Chosen over POSIX sh (the other waybar backings) deliberately: the multi-item state machine, time arithmetic, pomodoro FSM, and JSON I/O are cleaner in Python, and it gives real line/branch *coverage numbers* (Craig asked for them). Precedent: pocketbook is Python in this repo. +- *Pure core + thin IO shell.* All logic is pure functions taking =now= as a parameter (dependency-injected clock — satisfies testing.md: no recursion, no scope-shadowing, production reads =time.time()=, tests pass an explicit instant). The CLI layer does the IO: read state, call pure fns, write state, emit JSON, shell out to notify/fuzzel. +- *State file*: =$XDG_RUNTIME_DIR/waybar/wtimer.json= (env override =WTIMER_STATE= for tests). Same runtime-dir convention as =sysmon-metric=. +- *Heartbeat*: waybar calls =wtimer render= every 1s. =render= runs the tick logic first (detect elapsed items, fire notify, advance pomodoro, drop finished timers/alarms), then prints the waybar JSON. One entry point waybar polls; no separate daemon. +- *Concurrency (BLOCKER from review).* The 1s =render= and the click/scroll handlers (=add=, =toggle=, =cancel=, =cycle=) are separate processes doing read-modify-write on the same state file. Without serialization, last-writer-wins drops a click's =add=, or clobbers render's "item removed/advanced" write so the same item ticks and notifies again next second. So every read-modify-write takes an exclusive =flock= on the state file for the whole cycle, and writes go through a temp file + =os.replace= (atomic), so a concurrent render never reads a half-written file. This is what actually makes "notify fires once" true — the mutation is only authoritative under the lock. +- *State dir*: =render= and the mutating commands =mkdir -p= the state dir first (=$XDG_RUNTIME_DIR/waybar/= may not exist on a fresh boot). +- *Clock injection everywhere*: =now= comes from =WTIMER_NOW= (epoch) if set, else =time.time()=. Pure fns take =now= as a parameter; the CLI seeds it from the env. This lets the CLI integration tests hit boundary instants (exactly-at-target), not just the pure-fn tests. +- *Instant refresh*: after any mutating command, send waybar =SIGRTMIN+14= (the module's signal) so the bar updates immediately instead of lagging up to 1s. Faked in tests (=WTIMER_REFRESH= override, default =pkill -RTMIN+14 waybar=). + +* State model + +#+begin_src json +{ + "items": [ + {"id": "1", "type": "timer", "label": "tea", "target": 1751240400, "duration": 300, "paused_left": null}, + {"id": "2", "type": "alarm", "label": "", "target": 1751251200, "paused_left": null}, + {"id": "3", "type": "stopwatch", "label": "", "start": 1751240000, "paused_elapsed": null}, + {"id": "4", "type": "pomodoro", "label": "", "target": 1751241900, "phase": "work", + "cycle": 1, "work": 1500, "short": 300, "long": 900, "interval": 4, "paused_left": null} + ], + "primary": "1", + "seq": 4 +} +#+end_src + +- =seq= is the monotonic id source (string ids). +- *Paused* timer/pomodoro: =paused_left= holds seconds remaining; =target= ignored while paused; resume sets =target = now + paused_left=, =paused_left = null=. +- *Paused* stopwatch: =paused_elapsed= holds elapsed seconds; resume sets =start = now - paused_elapsed=. +- =primary= is the id the bar shows; =null= or stale → auto-select (below). + +* Display logic + +** Primary selection (bar text) +1. If =primary= names a live item, show it. +2. Else the running countdown (timer/alarm/pomodoro) with the smallest remaining. +3. Else the first running stopwatch. +4. Else idle (no items). + +** Bar text +- =<glyph> <time>= for the primary, plus = +N= when N other items exist. +- Idle: a dim timer glyph alone (or empty — decide at render; lean dim glyph so the module has a stable click target). +- =time= formatting: =M:SS= under 1h, =H:MM:SS= at/over 1h. Stopwatch counts up; timer/alarm/pomodoro count down to target. +- Paused item: prefix a pause glyph or rely on the =paused= class (CSS dims it). + +** Glyphs (nerd font; final codepoints verified live before merge) +- timer , alarm , stopwatch , pomodoro-work , pomodoro-break (coffee), paused , idle (dim). +- One glyph table at the top of the script so a live-render tweak is one edit. + +** Tooltip (all items) +One line per item: =<glyph> <label-or-type> <remaining/elapsed> (<state>)=. Pomodoro line shows phase + cycle (e.g. =work 2/4=). Header line summarizes count. Empty state: "No timers". + +** CSS classes (the =alt=/=class= field) +=timer= / =alarm= / =stopwatch= / =pomodoro-work= / =pomodoro-break=, plus =paused= and =urgent= (remaining < 60s). Drives color in style.css + both themes. + +* Commands (CLI) + +| Command | Effect | +|---------------------------------+---------------------------------------------------------------------| +| =wtimer render= | tick + emit waybar JSON (the heartbeat) | +| =wtimer add timer <dur> [label]=| add a countdown (=dur= like =25m=, =90s=, =1h30m=, =5= → minutes) | +| =wtimer add alarm <HH:MM> [lbl]=| add a wall-clock alarm (next occurrence of that time) | +| =wtimer add stopwatch [label]= | start a count-up | +| =wtimer add pomodoro [label]= | start a pomodoro at work phase | +| =wtimer new= | fuzzel: pick type, prompt value, dispatch to =add= (thin wrapper) | +| =wtimer toggle [id]= | pause/resume the item (default: primary) | +| =wtimer cancel <id>= | remove one item | +| =wtimer pick-cancel= | fuzzel: choose an item to cancel (right-click handler) | +| =wtimer cancel-all= | clear all | +| =wtimer cycle [next|prev]= | move the primary pointer across all items (incl. paused), state-list order, wrapping | + +Duration parse: =Nh=, =Nm=, =Ns= combos, or a bare integer = minutes. Reject +unparseable input (exit non-zero, notify nothing). Alarm parse: =HH:MM= 24h; if +that time today already passed, target tomorrow. + +* Notifications + +- Timer elapse: =notify alarm "Timer" "<label or duration> done" --persist=. +- Alarm fire: =notify alarm "Alarm" "<HH:MM><, label>" --persist=. +- Pomodoro phase change: =notify info "Pomodoro" "Work → short break (3/4)"= (no =--persist=; phase nudges shouldn't pile up), long-break and work-resume worded accordingly. +- notify is faked on PATH in tests; assert type + that it fired once per event. + +* Pomodoro semantics + +- Defaults: work 25m, short 5m, long 15m, interval 4 (long break after every 4th work). +- FSM: work → short → work → short → work → short → work → long → work … +- =cycle= counts completed works in the current set (1..interval); resets after a long break. +- Each phase elapse advances =phase=, recomputes =target=, fires the phase notify. Pomodoro never auto-removes; cancel ends it. + +* Waybar wiring + +** Module def (config) — signal 14 (next free; 8–13 used) +#+begin_src json +"custom/timer": { + "exec": "wtimer render", + "return-type": "json", + "interval": 1, + "signal": 14, + "on-click": "wtimer new", + "on-click-middle": "wtimer toggle", + "on-click-right": "wtimer pick-cancel", + "on-scroll-up": "wtimer cycle next", + "on-scroll-down": "wtimer cycle prev" +} +#+end_src + +** Position — right of the sysmon (battery/resource) module +Insert =custom/timer= into =modules-right= immediately after =custom/sysmon= +(between =custom/sysmon= and =custom/netspeed=). On screen that places it just +right of the battery/resource readout. + +** Not collapsible — survives the right-side collapse +The module *definition* lives in the canonical config object, and =waybar-collapse= +only swaps the =modules-right= *array* in the runtime copy (which it seeds from +canonical, so the def is always present). So making the timer non-collapsible is +purely an array-membership change: add =custom/timer= to the =waybar-collapse= +right *base set* so it stays listed when the right side collapses: +- laptop: =["custom/arrow-right","custom/sysmon","custom/timer","tray","custom/date","custom/worldclock"]= +- desktop: =["custom/arrow-right","custom/timer","tray","custom/date","custom/worldclock"]= +Update the =tests/waybar-collapse= base-set expectations to match (TDD the change). + +* CSS + +Add =#custom-timer= plus the state classes to all three stylesheets. Keep the +*selectors and structure* parallel across the three (what the theme-drift test +checks); the actual color *values* are per-theme (dupre vs hudson) and differ by +design, so this is structural parity, not byte-identity. Confirm against the real +CSS files what the drift test compares before editing. +- =hyprland/.config/waybar/style.css= +- =hyprland/.config/themes/dupre/...= waybar css +- =hyprland/.config/themes/hudson/...= waybar css +Colors: normal = foreground; =urgent= = a warning hue (reuse the sysmon +warn/crit palette); =paused= = dimmed; =pomodoro-break= = a calmer accent. + +* Testing plan (TDD) + +- Suite: =tests/wtimer/test_wtimer.py= (auto-discovered by =make test='s =tests/*/test_*.py= glob — no enumeration gap). +- *Pure-function tests* (fast, the bulk), explicit injected =now=: + - =parse_duration=: =25m=, =90s=, =1h30m=, =5= (→min), =0=, negative, garbage, empty (Normal/Boundary/Error). + - =parse_alarm=: future today, already-passed-today → tomorrow, =00:00=, =23:59=, =24:00=/=12:60= invalid, non-=HH:MM=. + - =format_time=: 0, 59s, 60s, 3599s, 3600s, multi-hour, negative clamps to 0. + - =add_item= for each type; =seq= increments; ids unique. + - =tick=: timer not-yet-elapsed (no change), exactly-at-target, past-target (fires once, removed); alarm same; pomodoro work→short→…→long→work advance + cycle counting + the 4th-work→long boundary; paused items never tick; multiple items in one tick. + - =select_primary=: explicit primary, stale primary falls back, soonest-remaining rule, stopwatch-only, empty. + - =render_payload=: text/tooltip/class for each type + paused + urgent + =+N= badge + idle. + - =toggle= pause then resume round-trips remaining/elapsed exactly; =cycle= wraps; =cancel= / =cancel-all=. +- *CLI integration tests* (subprocess, fakes on PATH, =WTIMER_NOW= to hit boundaries): =add= then =render= round-trip; =render= fires the faked =notify= once on an elapsed item and drops it; state file created if absent; *missing parent dir* created (fresh-boot case); corrupt/empty state file → treated as empty, no crash; mutating command sends the faked refresh signal. +- *Concurrency test*: spawn overlapping =render= + a mutating command against one state file; assert no lost update (the added item survives) and exactly-once notify (no double-fire from a clobbered tick). This is the regression guard for the flock/atomic-write fix. +- *Mocking boundary*: fake =notify=, =fuzzel=, =killall= on PATH (record calls); never mock the wtimer logic. Clock injected as a parameter. +- *Coverage*: measure with =coverage.py= if present (target 90%+ on the logic per testing.md business-logic bar); report the actual number. If =coverage= is absent, report per-command/per-branch case coverage explicitly and flag the tool gap (verification.md). +- =tests/waybar-collapse= base-set expectations updated for the new module. +- =tests/= theme-drift check stays green (CSS parity). + +* Files touched + +dotfiles branch =waybar-timer-module=: +- =hyprland/.local/bin/wtimer= (new, executable) +- =tests/wtimer/test_wtimer.py= (new) +- =hyprland/.config/waybar/config= (module def + modules-right position) +- =hyprland/.local/bin/waybar-collapse= (base-set) + =tests/waybar-collapse/...= (expectations) +- =hyprland/.config/waybar/style.css= + dupre + hudson waybar css (CSS) + +archsetup (main, at the end): +- this spec +- =todo.org= task closure + +* Resolved decisions (no approvals — my calls) + +- Python, not sh — testability + coverage; pocketbook precedent. +- One =render= heartbeat (no daemon) — simplest, waybar already polls. +- notify fires from =render='s tick, mutation guarantees once-only. +- Primary = user-cycled, else soonest-remaining; =+N= badge for the rest. +- Multiple simultaneous via tooltip list + badge (not a GTK panel) — keeps it "cool yet simple". +- Pomodoro is one self-advancing item, not four chained timers. +- Runtime-dir state (waybar-restart durable, not reboot durable) — v1. + +* Rollback + +All code on the dotfiles =waybar-timer-module= branch off =09815f3=. Squash-merge +at the end; =git switch main && git branch -D waybar-timer-module= reverts cleanly +if it goes sideways. diff --git a/docs/design/2026-06-29-zfs-pre-snapshot-installer.org b/docs/design/2026-06-29-zfs-pre-snapshot-installer.org new file mode 100644 index 0000000..e5a339e --- /dev/null +++ b/docs/design/2026-06-29-zfs-pre-snapshot-installer.org @@ -0,0 +1,106 @@ +#+TITLE: ZFS pre-pacman snapshot installer step (durable retention) +#+DATE: 2026-06-29 +#+SOURCE: handoff from the home project, 2026-06-29 + +* Problem + +A pacman =PreTransaction= hook snapshots =zroot/ROOT/default@pre-pacman_<ts>= +before every transaction, but nothing prunes them. Sanoid doesn't manage them +(they aren't =autosnap_= names), so they accumulated to 53 on velox between +April and the 2026-06-29 health check. Unbounded, they fill the pool over time. + +* What's actually on velox vs. archsetup + +The live =/usr/local/bin/zfs-pre-snapshot= is *not* authored by archsetup — +=git grep= for its content (=MIN_INTERVAL=, the pre-pacman =LOCKFILE= logic) +finds nothing tracked. The =PreTransaction= hooks in the archsetup monolith +(~lines 910, 1907, 1942) are the live-update guard, a different hook. The +script appears hand-placed on velox. + +The 2026-01-17 security doc line "ZFS pre-pacman snapshots (already in +install-archzfs)" is therefore *out of date* — archsetup does not install this. +Incorporating the fix is a NET-NEW installer step, not a patch to an existing +one. Correct that stale doc line as part of the work. + +velox was patched live (pruned to 10, script replaced with the self-pruning +version below); live backup at =/usr/local/bin/zfs-pre-snapshot.bak-2026-06-29=. + +* Proposed installer step + +In the archzfs / ZFS-on-root install path, gated to ZFS-root installs (velox is +the only ZFS daily driver; ratio is btrfs), install: + +1. =/etc/pacman.d/hooks/zfs-snapshot.hook= — the =PreTransaction= hook that + runs the script. *Not included in the handoff* — source it from velox + (=/etc/pacman.d/hooks/zfs-snapshot.hook=) or write it. +2. =/usr/local/bin/zfs-pre-snapshot= — the =KEEP=10= self-pruning version + below. + +Tests live in archsetup, so this wants an archsetup session and a ZFS-root VM +test (=make test FS_PROFILE=zfs=), not a cross-project edit from home. + +* The script (KEEP=10 self-pruning version) + +#+begin_src bash +#!/bin/bash +POOL="zroot" +DATASET="$POOL/ROOT/default" +LOCKFILE="/tmp/.zfs-pre-snapshot.lock" +MIN_INTERVAL=60 +KEEP=10 # how many pre-pacman snapshots to retain (rollback safety for recent transactions) + +# Skip if a snapshot was created within the last 60 seconds +if [ -f "$LOCKFILE" ]; then + last=$(stat -c %Y "$LOCKFILE" 2>/dev/null || echo 0) + now=$(date +%s) + if (( now - last < MIN_INTERVAL )); then + exit 0 + fi +fi + +TIMESTAMP=$(date +%Y-%m-%d_%H-%M-%S) +SNAPSHOT_NAME="pre-pacman_$TIMESTAMP" + +if zfs snapshot "$DATASET@$SNAPSHOT_NAME"; then + echo "Created snapshot: $DATASET@$SNAPSHOT_NAME" + touch "$LOCKFILE" + + # Retention: keep only the most recent $KEEP pre-pacman snapshots, destroy older ones. + # Sanoid does not manage these (they aren't autosnap_), so prune them here at creation time. + zfs list -H -o name -t snapshot -s creation "$DATASET" 2>/dev/null \ + | grep '@pre-pacman_' \ + | head -n -"$KEEP" \ + | while read -r old; do + zfs destroy "$old" && echo "Pruned old snapshot: $old" + done +else + echo "Warning: Failed to create snapshot" >&2 +fi +#+end_src + +* Implementation (2026-06-30) + +- Hook sourced from velox (=/etc/pacman.d/hooks/zfs-snapshot.hook=) and embedded + as a heredoc in =configure_pre_pacman_snapshots()=. +- Insertion point: a new =configure_pre_pacman_snapshots()= gated on + =is_zfs_root=, called from =boot_ux= (the last step) so the hook doesn't fire + during the install's own package operations — the first pre-pacman snapshot is + the fresh system. The script ships as =scripts/zfs-pre-snapshot= (the + =zfs-replicate= pattern), made =ZFS_PRE_*=-env-overridable for testability. +- Tests: =tests/zfs-pre-snapshot/= unit-tests the pruning logic against a fake + =zfs= (creates, prunes oldest-past-KEEP, ignores non-=pre-pacman_= snapshots, + honors the lockfile, warns on snapshot failure); =test_boot.py= asserts the + hook + script land on a ZFS install; the orchestrator test pins the new + =boot_ux= substep. + +* Note on the "stale security doc" + +The 2026-01-17 line "ZFS pre-pacman snapshots (already in install-archzfs)" is +*not* stale: that file is an archive generated by install-archzfs (see its +header and footer), and the claim is accurate for install-archzfs. The real gap +was that archsetup took sanoid from install-archzfs but never ported the +pre-pacman hook. This change ports it. The archive is left untouched. + +* Remaining + +- ZFS-root VM verification (=make test FS_PROFILE=zfs=) before the task closes. diff --git a/docs/design/2026-06-30-captive-portal-login.org b/docs/design/2026-06-30-captive-portal-login.org new file mode 100644 index 0000000..1739689 --- /dev/null +++ b/docs/design/2026-06-30-captive-portal-login.org @@ -0,0 +1,89 @@ +#+TITLE: Captive-portal login — learnings + baking it into the net panel +#+DATE: 2026-06-30 +#+SOURCE: the 2026-06-30 Hyatt wifi saga (velox) + +* Why this exists + +On a locked-down-DNS laptop, captive portals never show their login page, even +though phones get on fine. We spent hours on a Hyatt portal before finding the +mechanism; this captures it so the fix becomes a panel feature instead of a +one-off script. + +* The mechanism (what actually blocks the login) + +A redirect portal works by *DNS hijack*: you query a name, the hotel's resolver +hands back the portal, you get the login page. Two things on velox stop that: + +- *System resolver forces DNS-over-TLS.* =/etc/systemd/resolved.conf.d/dns-over-tls.conf= + hardcodes =DNS=1.1.1.1#... 9.9.9.9#...= with =DNSOverTLS=yes=. The system never + queries the hotel's resolver at all. The hotel blocks 853 (DoT) and external + 53, so system DNS is simply dead on the portal — only 443 (DoH) gets out. +- *Browser DoH.* Chrome "secure DNS" on bypasses the hotel DNS too, so the + browser never gets redirected either. + +A phone works because it uses *plain DNS* from the hotel plus a built-in +captive-portal popper. The laptop has neither. + +Confirmed facts from the saga: +- Front desk: it's a normal redirect-to-login portal. Phone: connects fine. +- No DHCP option 114 (RFC 8910) — the portal doesn't advertise its URL. But the + URL is recoverable from the HTTP 302 once you're on plain DNS. +- The walled garden whitelists OS captive-detection endpoints + (=captive.apple.com= returns "Success") — a *misleading* signal, not real + internet. Don't trust it. +- 443/DoH egress works broadly on the portal; only port-53 DNS is held. So + "system DNS fails" never means "no internet" here. + +* The working fix (=~/.local/bin/hotel-wifi=, to be folded in) + +Temporarily disable DoT → plain hotel DNS → discover the portal URL from the +redirect → open it in a clean browser profile (no DoH, no stale HSTS/cookies) → +click the button → restore DoT. Reversible; tested to restore cleanly. + +#+begin_src sh +#!/bin/sh +# hotel-wifi disable DoT -> find the portal login URL -> open it +# hotel-wifi off restore normal encrypted DNS (run once online) +conf=/etc/systemd/resolved.conf.d/dns-over-tls.conf +if [ "${1:-on}" = "off" ]; then + [ -f "$conf.captive-disabled" ] && sudo mv "$conf.captive-disabled" "$conf" + sudo systemctl restart systemd-resolved + echo "Encrypted DNS (DoT) restored."; exit 0 +fi +[ -f "$conf" ] && sudo mv "$conf" "$conf.captive-disabled" +sudo systemctl restart systemd-resolved; sleep 1 +resolvectl flush-caches 2>/dev/null || true +portal="" +for t in http://captive.apple.com/hotspot-detect.html http://neverssl.com \ + http://detectportal.firefox.com/canonical.html; do + loc=$(curl -sS -m 6 -o /dev/null -w '%{redirect_url}' "$t" 2>/dev/null) + [ -n "$loc" ] && { portal="$loc"; break; } + url=$(curl -sS -m 6 "$t" 2>/dev/null | grep -ioE 'https?://[^"'"'"' >]+' \ + | grep -ivE 'apple\.com|neverssl|firefox|w3\.org|gstatic' | head -1) + [ -n "$url" ] && { portal="$url"; break; } +done +prof=$(mktemp -d) +setsid -f google-chrome-stable --user-data-dir="$prof" "${portal:-http://neverssl.com}" >/dev/null 2>&1 +echo "Click the login button. When online: hotel-wifi off" +#+end_src + +* Baking it into the net panel (the task) + +- The net engine already diagnoses captive / no-internet. When it sees a held + portal, the panel should offer a first-class *"Log in to this network"* + action that runs the plain-DNS + clean-browser flow above, reversibly, and + auto-restores DoT when connectivity returns (or on a timeout). +- Reconcile with the existing =net portal= command and the =captive= helper — + they assumed a DNS-hijack-to-gateway model that did NOT match this portal + (gateway served no web; DNS was held, not hijacked-to-portal). The plain-DNS + approach is the one that worked; make it the engine's portal path. +- The DoT toggle must be safe and reversible (the =off= step). Consider a + per-connection or time-boxed DoT-off that can't strand encrypted DNS. +- Surface the misleading-"Success" lesson: a whitelisted captive-check passing + is not "online" — gate on a real, non-whitelisted fetch. + +* Related fix that unblocked the panel (already shipped) + +The panel could never switch networks because =net up= placed =--wait= after the +nmcli subcommand (it's a global option). Fixed in dotfiles 2432311; fake-nmcli +now rejects the misplaced flag so it can't regress. diff --git a/docs/design/2026-07-02-waybar-expansion-animation-feasibility.org b/docs/design/2026-07-02-waybar-expansion-animation-feasibility.org new file mode 100644 index 0000000..cb195c6 --- /dev/null +++ b/docs/design/2026-07-02-waybar-expansion-animation-feasibility.org @@ -0,0 +1,53 @@ +#+TITLE: Waybar Expansion Animation — Feasibility Assessment +#+AUTHOR: Craig Jennings +#+DATE: 2026-07-02 + +* Question + +The todo.org task "Smooth waybar expansion animation" [#C]: the collapse/expand +jump is abrupt, and a few systray icons pop in one-by-one afterward. Can the +expansion animate smoothly? + +* How the collapse actually works + +=waybar-collapse= rewrites the module arrays in a runtime copy of the config +(=$XDG_RUNTIME_DIR/waybar/config=) and sends waybar SIGUSR2. Waybar reloads: +it tears down every module widget and rebuilds the bar from the new config. + +* Findings + +1. *No widget survives the reload, so nothing can transition.* GTK3 CSS + transitions animate property changes on live widgets. The collapse + mechanism replaces the whole widget tree; there is no widget on both sides + of the change to interpolate. The jump is structural, not stylistic. +2. *GTK3 doesn't animate widget add/remove anyway.* Smooth insert/remove needs + =GtkRevealer= wrapping, which waybar does not use for modules. Making it do + so is an upstream waybar patch, not a config or CSS matter. +3. *The layer surface resize isn't animatable either.* Hyprland layerrules can + animate map/unmap (slide/fade), but the bar stays mapped through a collapse + — the same surface changes width. No compositor-side hook exists for that. +4. *A CSS-only fake covers custom modules at best.* Custom modules could emit + a "collapsed" class and transition font-size/padding toward zero (GTK3 CSS + can animate those). But the collapsed set includes built-ins — tray, + pulseaudio, workspaces — which take no script-driven classes. The result + would be half the modules gliding and half popping: worse than the clean + jump. +5. *Tray icons popping in one-by-one is separate and unfixable here.* That's + asynchronous StatusNotifier re-registration after the reload; each app + answers on its own schedule. Only keeping the tray alive across the change + (i.e. not reloading) avoids it. + +* Conclusion + +Not feasible with the current collapse mechanism, and no acceptable partial +measure exists. A real animation requires waybar itself to support dynamic +module sets with Revealer-style transitions (an upstream feature), or +replacing the collapse-by-reload design entirely. + +* Recommendation + +Close the task as infeasible-for-now (or park at [#D] with a pointer here). +Revisit only if waybar upstream gains dynamic module visibility (worth a +check at major waybar releases) or if the bar ever migrates to a custom +GTK4 shell — the Blueprint pipeline from the net panel would make Revealer +transitions natural there. diff --git a/docs/design/maintenance-console-design-ideas.org b/docs/design/maintenance-console-design-ideas.org new file mode 100644 index 0000000..066c25d --- /dev/null +++ b/docs/design/maintenance-console-design-ideas.org @@ -0,0 +1,527 @@ +#+TITLE: Maintenance Console — Design Ideas +#+AUTHOR: Craig Jennings +#+DATE: 2026-07-06 + +* Direction + +A single-host maintenance console (GUI, waybar-launched) that surfaces every +health metric for this Arch workstation and, where the remediation is safe, +runs it. It reshapes the earlier install-health/workstation-contract framing +(=system-monitor-design-ideas.org=) into a maintenance surface with a doctor +action. + +The console is the glanceable, single-host version of the home project's +=system-health-check.org= workflow (~1000 lines, capability-dispatched across +ratio/velox/mybitch/truenas). The console owns the routine-maintenance and +at-a-glance-status slice; the workflow stays as the escalation path for +forensic investigation and as the only sanctioned way to run updates. + +* Scope: hosts (Q2 resolved 2026-07-06) + +v1 targets *both* daily drivers — ratio and velox — as first-class hosts, not +ratio-first-velox-later. Consequences for v1: + +- *Capability dispatch is mandatory from day one*, not deferred. The console + probes the live host and runs only applicable checks (btrfs RAID1 on ratio vs + ZFS-primary on velox; AMD amd-pstate on ratio vs Intel intel_pstate on velox). + This mirrors the home workflow's Phase 0 probe. +- *velox-only metrics ship in v1*: battery health (capacity / cycles) and the + unclean-shutdown / suspend-failure rate — both currently GAPs, both driven by + velox being the machine that travels and suspends. +- The ZFS-primary remediation paths (scrub, snapshot retention, pool health) are + built and tested, not stubbed. + +* Thresholds source of truth (Q3 resolved 2026-07-06) + +One machine-readable file, =maintenance-thresholds.toml=, holds every severity +value (cache-size trigger, disk %, scrub-age warn/crit, snapshot retention +limits, temp bands, backup-staleness windows, etc.). archsetup owns and ships +it; both consumers read the *installed* path, so neither reaches into the +other's repo: + +- the maintenance console (dotfiles code) reads it at runtime, +- the system-health-check workflow reads it instead of hardcoding severity + rules in prose. + +The workflow's hard-won values migrate into the TOML as the seed content (the +snapshot MONTHLY limit that bit /home, the 10 GB cache trigger, the scrub-age +bands). Install path is an implementation sub-question — a stable location both +consumers reach (e.g. =~/.config/archsetup/maintenance-thresholds.toml=). + +* Workflow ownership — move system-health-check into archsetup (proposed 2026-07-06) + +Craig's call: the =system-health-check.org= workflow should live in archsetup, +not the home project. Rationale — the home project is scoped to finances, +health, and personal matters; system design, execution, and maintenance are +archsetup's domain. Home only owns the workflow by inherited accident, not by +fit. Moving it here also collapses the Q3 coupling: the TOML source-of-truth and +its workflow consumer end up in the same project. + +Move scope (home → archsetup), to sequence when Craig gives the go: +- =system-health-check.org= (the workflow itself) +- =homelab-inventory/*.org= (ratio/velox/mybitch/truenas capability inventories + it cross-references) +- any home-project references to the workflow (startup, project-workflows index) + +Cross-project mechanics: the archsetup side (receiving the files, wiring the +TOML) is in-scope here; the home side (removing the originals, updating home's +references) is the home project's scope — handled by a handoff note to home's +inbox or a home session, not edited blind from here. + +* Panel shape + +Three regions, driven by the Automation column of the metrics table below: + +- *Actionable* (left) — metrics with a lever. Automation = Auto or Confirm. + Two doctor actions serve this column: "Clean up" fires every Auto metric + unattended; "Review & fix" opens a preview for the Confirm metrics and acts + only on approval. +- *Diagnostic* (right) — read-only telemetry. Automation = None or Human. A + state color (green/amber/red) and the value; no button. Red here is the + signal to run the workflow. +- *Updates* (quarantined strip) — Automation = Workflow. Shown as a count with + notable packages named; the only affordance is "run the workflow." The panel + never applies updates in place. + +*Bar glyph* tracks the worst *Diagnostic* state only — not the actionable +count. A big package cache is boring; a SMART failure is a fire. Actionable +clutter must not turn the bar red or it trains you to ignore it. + +** Doctor = live output wall (Q1 resolved 2026-07-06) + +"Clean up" and "Review & fix" are not fire-and-forget buttons. Running a doctor +opens an *output wall* — one lamp per action, streaming in realtime as each +runs: + +- *amber* while the action is running, +- *green* on success (with the reclaimed amount / result inline), +- *red* on failure. + +Feedback is always shown (not just when something was reclaimed) — you watch it +happen. This is the same live-results shape that should back the other doctors +(net, bluetooth), so every doctor in the system reads the same way. See the +todo task to retrofit the net + bt doctors to realtime lamp output. + +* Automation legend + +| Value | Meaning | +|----------+-----------------------------------------------------------------------------------------------------------| +| Auto | Doctor "Clean up" button — fire unattended, reversible or harmless | +|----------+-----------------------------------------------------------------------------------------------------------| +| Confirm | Doctor "Review & fix" — preview the change, act only on one click | +|----------+-----------------------------------------------------------------------------------------------------------| +| Human | Panel nudges; a human decides and acts (no button, not the agent) | +|----------+-----------------------------------------------------------------------------------------------------------| +| Workflow | vLater — metric shown read-only; no in-panel button. Agent-workflow assistance deferred (decided | +| | 2026-07-07, see below) | +|----------+-----------------------------------------------------------------------------------------------------------| +| None | Diagnostic only — no software remediation exists (hardware, telemetry) | +|----------+-----------------------------------------------------------------------------------------------------------| + +*Workflow buttons removed (decided 2026-07-07, prototyping E5).* The panel +carries no "run workflow" affordances — not on failed units, unclean boots, +updates, or CVEs. The metrics stay on the board as read-only telemetry; AI +assistance via the system-health-check workflow is a vLater feature. v1 ships +only determinate remedies (fixed, scriptable actions with predictable +outcomes). + +*Determinate remedies adopted (decided 2026-07-07).* Every remedy below is +Confirm-tier; contextual levers appear only when the metric is off-nominal: + +- /Service restarts & enables/: failed units (systemctl restart + reset-failed), + fail2ban, cronie, chronyd (+ makestep), tailscaled, snapper timers, zram + config re-apply. DNS/NetworkManager delegates to the net panel's doctor + (deep-link, not a duplicate repair chain). +- /Deterministic maintenance ops/: btrfs balance -dusage=50 (unallocated low), + reinstall owning packages on pacman -Qkk failures, rsyncshot RUN NOW (result + streams to the output well), smartctl -t short self-test, snapshot-retention + repair (write sane TIMELINE limits + cleanup — the /home lesson as a + one-press fix). btrfs device-error counter reset stays manual: resetting + without diagnosis masks a dying drive. +- /Composite macro/: RECLAIM SPACE on the disk-usage cell — runs every reclaim + lever (cache, journal, coredumps, app logs, docker tier-1, snapper cleanup) + as one output-wall stream. +- /Disruptive but determinate/: REBOOT behind arm-to-fire, offered when + running kernel != installed. +- /Still read-only/: temps, throttling, battery, memory/OOM, taint, journal + error content, kernel/hw events, listeners, unclean-shutdown rate — physical + or investigative; vLater AI territory. + +*Updates join the Confirm layer (decided 2026-07-07).* Two levers on the +updates strip, both behind the live-update guard (mesa/hyprland/wayland +runtime in the pending set): + +- UPDATE — repo + AUR system update. +- TOPGRADE — full ecosystem run. The panel's wrapper always passes + --disable git (topgrade's git step rebase-autostashes ~/code/*/ — never + under a live session). + +*MEM·PWR: evidence + two levers + expectation tags (decided 2026-07-07).* +Mostly physics, so the category leans watch-only — with these additions: + +- /CPU mode selector/: a free segmented control (PERF · BAL · POWER) writing + the EPP hint — set the active mode to anything, not drift-repair against a + declared default. amd-pstate on ratio, intel_pstate on velox. +- /Battery charge limit/ (velox-only, capability-gated): SET 80% writes + charge_control_end_threshold — the standard longevity cap. Battery *health* + (capacity vs design, cycles) stays watch-only hardware telemetry. +- /Evidence drill-downs/ (digest idiom): top-5 RAM consumers under memory, + recent boots listed clean/unclean under the unclean-rate, and throttle/OOM + events with timestamps. Evidence makes the numbers actionable even where no + button exists. +- /KILL on top-memory items/ (revised 2026-07-07 — Craig): arm-to-fire, four + guards: the arm shows the exact victim (name + size); SIGTERM not SIGKILL, + with the outcome reported to the wall; PID + process name revalidated at + fire time so a recycled PID can't be hit; session-critical names (systemd, + the compositor, the panel itself) render a disabled key — protected. A + SIGKILL escalation for TERM-survivors is vLater. +- /Expectation-setting, panel-wide/: every leverless cell's sub-line carries an + explicit tag ("hardware — watch only", "evidence below"), and each subpanel + header shows the split — "N fixable · M watch" — so the user knows their + agency before reading a single cell. + +*Refresh cadence (decided 2026-07-07).* Four tiers, matching probe cost: + +- /Live group, panel open/: temps, memory free + top consumers, throttle + state — re-read every ~3 s while their subpanel is visible, gated on + panel-open exactly like the audio panel's meters. Stop when hidden. +- /Fast local tier, panel open/: re-probed every ~30 s while the panel is up; + additionally, any metric re-probes immediately after an action that touches + it (fire CLEAN → cache re-measured, not assumed). +- /On open/: the hydration tiers re-run (fast reads first, process probes + behind them — sub-second perceived). +- /Network tier/: checkupdates / arch-audit / AUR / firmware stay on the + hourly systemd-timer cache with age shown; refreshed on demand only. +- /Panel closed/: the waybar glyph is fed by a light background scan every + ~30 min (systemd timer writing the state file the glyph reads) — the bar + stays honest without the panel running. + +*Journal errors get a digest, not a fix (decided 2026-07-07).* No generic +remedy exists — an error-priority line is a symptom of an arbitrary subsystem — +so the panel ships four determinate assists instead: + +- /Digest/: the cell expands to errors grouped by syslog identifier — count, + message snippet, first/last seen, and the exact next command (journalctl -u + <unit> -b) when the identifier maps to a unit. Top-10 groups, read-only. +- /MARK KNOWN with a full lifecycle/: arm-to-fire showing the exact pattern + before it stores; marked groups move to a dim KNOWN section (never vanish) + with per-row UNMARK; every mark/unmark logs to the results wall; marks carry + date + example. Patterns bind to identifier + message, never a whole unit — + a muted service's *new* errors still surface. +- /Two noise layers/: shipped defaults (bluetoothd HFP, pixman, xkbcomp) in + the packaged TOML, user marks in a separate user file merged over it (a + template sync never eats curation). CLEAR MARKS (arm-to-fire) empties the + user layer and re-enables shipped defaults; unmarking a shipped default + records a disable flag in the user layer. +- /OPEN JOURNAL/: launches a terminal running journalctl -p err -b — the same + delegation pattern as NET DOCTOR. + +Ruled out: auto-restarting units that log errors (error ≠ failed) and +keyword-driven fix suggestions (vLater AI territory). + +*Full-sweep findings — all committed to v1 (decided 2026-07-07).* Every metric +was audited against the converged checklist (honest label, evidence digest +where a count hides detail, curation lifecycle where "expected" is config +knowledge, guarded per-item remedies, cross-links, watch-only tags). Adopted, +all prototyped: + +- /Storage/: disk top-consumers digest (evidence only — no file deletion + keys); per-device error rows on RAID1 when counters are nonzero, + cross-checked against SMART; SMART sub-line carries the last self-test + result. Spec note: a real scrub runs hours — the ring needs a running-% + state, not an instant reset. +- /Packages/: orphan digest (name + size) with per-package REMOVE (armed) and + KEEP — the curation lifecycle encoding "intentional, not orphaned" (the + rust lesson); batch REMOVE ALL skips kept packages. Per-file pacnew rows + tagged safe-delete (reflector-managed) vs needs-merge, MERGE delegating to + a terminal diff. CVEs named: package · CVE id · severity. AUR and firmware + names spelled out. +- /systemd/: failed units upgraded from a count-lever to a per-unit roster + (name · since · exit code · journalctl hint) with per-row RESTART + RESET; + is-system-running names its cause ("degraded — N failed units below"); + taint letters decoded. +- /Logs/: coredumps grouped by binary (count · last · coredumpctl hint), + cleared with the CLEAR action; kernel/hw events listed when not clean + (hardware — watch only). +- /Services/: docker system df breakdown (images / containers / volumes / + build cache with per-type reclaimable); stopped containers upgraded to the + full signal/expected curation lifecycle (MARK EXPECTED / UNMARK, shipped + default: winvm) with per-container START; cron expected-entries drift + roster. +- /Snapshots/: count split by type — timeline (auto-pruned) · single + (manual — escapes timeline cleanup, the pile-up risk) · pre/post — with + oldest-single named and DELETE STALE (armed, keeps newest 2) when singles + accumulate. +- /Network minors/: fail2ban shows recent-ban count; NTP shows offset. + +Rationale for prototyping everything (Craig): real estate and complexity have +bitten before — surface those limits in the disposable prototype, not after +functionality exists behind the UI. + +*Vertical compression → rotary band selector (2026-07-07).* First attempt — +the MEM·PWR three-column layout on Packages/Logs/Services — lost too much row +detail to third-width truncation (Craig's verdict after use). Replaced by a +*rotary band selector*: the amplifier input-selector idiom. A machined knob +(click to cycle) whose needle swings to engraved band labels, one per evidence +section (ORPHANS · PACNEW · ADVISORIES; SIGNAL · KNOWN NOISE · COREDUMPS · +KERNEL/HW; CONTAINERS · DOCKER DISK · CRON & BACKUPS). Each band carries its +own status lamp (section health at a glance without switching) plus a count; +the selected band gets a gold underline and the needle. One section renders at +a time at full width, restoring complete row detail. Deliberately distinct +from the category tiles, console keys, and the CPU-mode segmented control — +each selection idiom in the panel now has its own visual voice. MEM·PWR keeps +its three-column evidence strip (short rows fit fine at third-width). + +*Listeners get the same treatment (decided 2026-07-07).* The count becomes +"unexpected listeners" — evidence digest (process · port · bind address from +ss -tlnp), expected-list curation with the full MARK EXPECTED / UNMARK / +CLEAR lifecycle (shipped defaults: sshd, mpd, tailscaled; user marks in the +user layer), and guarded per-socket remedies: STOP (systemctl stop, armed) +when a unit owns the socket, KILL (SIGTERM, armed) otherwise. Severity keys +on unexpected AND public-bind (0.0.0.0/::) — a loopback listener warns, an +exposed one fails — and when ufw is down the signal header names the exposure +("ufw down — N public binds exposed"). Stopped containers likewise gained a +contextual START lever (allowlist: winvm), and the firewall its ENABLE. + +*Updates strip border is state-tiered (decided 2026-07-07).* Green when +nothing pending, amber for ordinary pending/AUR/firmware counts, red when +CVEs exist or pending exceeds the "a lot" threshold (or the update cache has +gone stale — staleness window in the TOML). The CVE badge renders only when +the count is nonzero. + +*Guard arms instead of blocking (revised 2026-07-07).* When the guard trips, +the key arms (red, "press again to run anyway — or apply from a TTY") rather +than hard-refusing. The user decides; the footgun is acknowledged and +deliberately handed over. After a system update lands, the panel offers a +reboot: a REBOOT key (arm-to-fire) appears on the updates strip and the +reboot-required metric flips. + +*No per-ecosystem update metrics.* Topgrade's step set (yay, rustup, cargo, +pipx, npm/pnpm, gem, go, flatpak, fwupd, tmux/zsh/nvim plugins, git repos) has +no cheap offline "updates available?" probe — mirroring it means a network +round-trip per registry at panel-open. Instead: one *topgrade freshness* +metric (wrapper stamps last-run time; threshold in the TOML) whose remedy is +the TOPGRADE lever, plus a *firmware updates* count in the updates strip +(fwupd refreshes metadata on its own timer; the panel reads the cache). + +Rationale for the hard lines: system updates are Workflow, never Auto — the +2026-06-07 Hyprland crash was a live -Syu swapping mesa+hyprland under the +running session, and the standing rule is never -Syu live under Hyprland when +the mesa/hyprland/wayland runtime is in the set. Hardware findings (SMART, MCE, +thermal) are None — the fix is replacing a drive or clearing a fan, not +software. + +* Metrics — Storage & filesystem integrity + +| Metric | Fix / lever | Automation | Notes | +|----------------------------------+----------------------------------+------------+-----------------------------------| +| Package cache size | paccache -r / -ruk0 | Auto | Reclaim, all re-downloadable | +|----------------------------------+----------------------------------+------------+-----------------------------------| +| paccache -rk1 (keep 1 version) | paccache -rk1 | Confirm | Frees most; kills downgrade | +| | | | headroom | +|----------------------------------+----------------------------------+------------+-----------------------------------| +| Disk usage (df) | — | None | Fix is cache/snapshot/prune | +| | | | levers | +|----------------------------------+----------------------------------+------------+-----------------------------------| +| Btrfs unallocated space | — | None | Chunk headroom; diagnostic | +|----------------------------------+----------------------------------+------------+-----------------------------------| +| Btrfs scrub age | btrfs scrub start | Confirm | GAP; ZFS has this, btrfs doesn't. | +| | | | IO-heavy, on-demand | +|----------------------------------+----------------------------------+------------+-----------------------------------| +| Btrfs device stats (per-drive | btrfs device stats --reset | None | GAP; RAID1 early-warning ahead of | +| error counters) | | | SMART; reset after review | +|----------------------------------+----------------------------------+------------+-----------------------------------| +| ZFS pool health / errors | — | None | CRITICAL if state != ONLINE or | +| | | | errors > 0 | +|----------------------------------+----------------------------------+------------+-----------------------------------| +| ZFS scrub age | zpool scrub | Confirm | Covered | +|----------------------------------+----------------------------------+------------+-----------------------------------| +| ZFS capacity | — | None | Perf degrades > 80%; the ZFS | +| | | | headroom metric (no unallocated | +| | | | concept) | +|----------------------------------+----------------------------------+------------+-----------------------------------| +| ZFS fragmentation (FRAG) | — | None | GAP; no defrag exists — the | +| | | | remedy is snapshot pruning + | +| | | | staying under 80% | +|----------------------------------+----------------------------------+------------+-----------------------------------| +| ZFS autotrim (SSD TRIM) | zpool set autotrim=on / zpool | Confirm | GAP; velox; the fstrim.timer | +| | trim | | counterpart on ZFS | +|----------------------------------+----------------------------------+------------+-----------------------------------| +| fstrim.timer enabled + firing | systemctl enable --now | Confirm | GAP; standard SSD hygiene | +| | fstrim.timer | | | +|----------------------------------+----------------------------------+------------+-----------------------------------| +| pacman file integrity (Qkk) | reinstall package | Workflow | GAP; modified/missing files need | +| | | | judgment | +|----------------------------------+----------------------------------+------------+-----------------------------------| +| SMART health | — | None | Hardware; replace drive | +|----------------------------------+----------------------------------+------------+-----------------------------------| +| SMART wear / temperature | — | None | Diagnostic | +|----------------------------------+----------------------------------+------------+-----------------------------------| + +* Metrics — Snapshots + +| Metric | Fix / lever | Automation | Notes | +|--------------------------------+--------------------------+------------+---------------------------------------------| +| Snapper count / retention | snapper cleanup / delete | Confirm | Manual (single) snapshots need explicit | +| | | | choice | +|--------------------------------+--------------------------+------------+---------------------------------------------| +| ZFS snapshot count / retention | zfs destroy | Confirm | Runaway retention | +|--------------------------------+--------------------------+------------+---------------------------------------------| +| Snapshot auto-timer running | systemctl enable timer | Confirm | Is the auto-snapshot service firing | +|--------------------------------+--------------------------+------------+---------------------------------------------| + +* Metrics — Packages & security + +| Metric | Fix / lever | Automation | Notes | +|-----------------------------------+------------------------------+------------+--------------------------------------| +| Orphaned packages | pacman -Rns (named args) | Confirm | Review first (rust looked orphaned, | +| | | | was intentional) | +|-----------------------------------+------------------------------+------------+--------------------------------------| +| Pending updates count | — | Workflow | Never auto; workflow-only | +|-----------------------------------+------------------------------+------------+--------------------------------------| +| .pacnew files | diff + merge / delete | Confirm | Allowlist auto-deletes mirrorlist / | +| | | | locale.gen | +|-----------------------------------+------------------------------+------------+--------------------------------------| +| arch-audit CVEs | reviewed update | Workflow | GAP; top-priority add. Fix is an | +| | | | update | +|-----------------------------------+------------------------------+------------+--------------------------------------| +| Keyring freshness | pacman -Sy archlinux-keyring | Confirm | GAP; stale keyring breaks update | +| | | | signatures | +|-----------------------------------+------------------------------+------------+--------------------------------------| +| AUR / foreign staleness (Qm) | paru -Sua | Workflow | GAP; AUR updates reviewed, not auto | +|-----------------------------------+------------------------------+------------+--------------------------------------| +| Reboot required (kernel mismatch) | reboot | Human | GAP; uname -r vs /usr/lib/modules. | +| | | | You pick when | +|-----------------------------------+------------------------------+------------+--------------------------------------| + +* Metrics — systemd & boot + +| Metric | Fix / lever | Automation | Notes | +|--------------------------------+------------------------+------------+-----------------------------------------------| +| is-system-running (degraded?) | — | None | GAP; one-token whole-system verdict; | +| | | | candidate for the bar glyph | +|--------------------------------+------------------------+------------+-----------------------------------------------| +| Failed systemd units | restart / investigate | Workflow | Depends why it failed | +|--------------------------------+------------------------+------------+-----------------------------------------------| +| Maintenance timers enabled + | systemctl enable timer | Confirm | GAP; meta-metric — are | +| firing | | | paccache/btrfs-scrub/fstrim/reflector/snapper | +| | | | firing | +|--------------------------------+------------------------+------------+-----------------------------------------------| +| Kernel taint flag | — | None | GAP; tainted != 0 = proprietary module or | +| | | | prior crash | +|--------------------------------+------------------------+------------+-----------------------------------------------| + +* Metrics — Logs & coredumps + +| Metric | Fix / lever | Automation | Notes | +|------------------------------------+---------------------------------+------------+----------------------------------| +| Coredump count | coredumpctl clean (keep recent) | Auto | Keep last few days for forensics | +|------------------------------------+---------------------------------+------------+----------------------------------| +| Journald disk usage | journalctl --vacuum-size/time | Auto | Bounded journal | +|------------------------------------+---------------------------------+------------+----------------------------------| +| App-log cleanup (>7d) | log-cleanup cron trigger | Auto | Already a cron; manual trigger | +|------------------------------------+---------------------------------+------------+----------------------------------| +| Journal error count (real vs | — | Workflow | Forensic; noise-filtered count | +| noise) | | | | +|------------------------------------+---------------------------------+------------+----------------------------------| +| Kernel/hardware events | — | None | Forensic, hardware | +| (MCE/USB/thermal/GPU) | | | | +|------------------------------------+---------------------------------+------------+----------------------------------| + +* Metrics — Memory, thermal, power + +| Metric | Fix / lever | Automation | Notes | +|-----------------------------------------+-------------+------------+--------------------------------------------| +| Memory free / OOM kills | — | Workflow | OOM = investigate | +|-----------------------------------------+-------------+------------+--------------------------------------------| +| Swap / zram present + healthy | — | None | Diagnostic | +|-----------------------------------------+-------------+------------+--------------------------------------------| +| CPU / GPU temperatures | — | None | Hardware | +|-----------------------------------------+-------------+------------+--------------------------------------------| +| Thermal throttling active | — | None | Cooling issue | +|-----------------------------------------+-------------+------------+--------------------------------------------| +| Battery health (capacity / cycles) | — | None | GAP; laptop; ties to open suspend todo | +|-----------------------------------------+-------------+------------+--------------------------------------------| +| Unclean-shutdown / suspend-failure rate | — | Workflow | GAP; ratio flagged ~75% unclean 2026-06-08 | +|-----------------------------------------+-------------+------------+--------------------------------------------| + +* Metrics — Network & security posture + +| Metric | Fix / lever | Automation | Notes | +|-----------------------------------+---------------------------+------------+-----------------------| +| DNS / NetworkManager reachability | restart NM | Workflow | CRITICAL if down | +|-----------------------------------+---------------------------+------------+-----------------------| +| Firewall active (ufw / nftables) | ufw enable | Confirm | GAP; security posture | +|-----------------------------------+---------------------------+------------+-----------------------| +| Unexpected listeners (ss -tlnp) | — | None | GAP; security review | +|-----------------------------------+---------------------------+------------+-----------------------| +| Tailscale peers | tailscale up | Confirm | Covered | +|-----------------------------------+---------------------------+------------+-----------------------| +| fail2ban running + bans | systemctl start | Confirm | Covered | +|-----------------------------------+---------------------------+------------+-----------------------| +| NTP sync (chrony) | systemctl restart chronyd | Confirm | Covered | +|-----------------------------------+---------------------------+------------+-----------------------| + +* Metrics — Services, backups, virt + +| Metric | Fix / lever | Automation | Notes | +|---------------------------------+--------------------------+------------+--------------------------------------------| +| rsyncshot backup freshness | — | Workflow | CRITICAL if daily > 48h; investigate | +| | | | failure | +|---------------------------------+--------------------------+------------+--------------------------------------------| +| Docker/podman reclaimable | prune tier 1 / tiers 2-3 | Confirm | Tier 1 nearly Auto; 2-3 destructive | +|---------------------------------+--------------------------+------------+--------------------------------------------| +| Docker stopped containers | — | None | Mostly expected (WinVM on-demand) | +|---------------------------------+--------------------------+------------+--------------------------------------------| +| libvirt VM state | — | None | Expected off | +|---------------------------------+--------------------------+------------+--------------------------------------------| +| Cron running + expected entries | systemctl enable cronie | Confirm | rsyncshot + log-cleanup entries | +|---------------------------------+--------------------------+------------+--------------------------------------------| + +* Architecture & testing (decided 2026-07-07) + +*CLI-first, GUI as a face.* The console ships as the fourth panel sibling: a +=maint= Python package in dotfiles (like =net/=, =bt/=, =audio/=) with probe +modules (read-only collectors), a remedies module, =cli.py=, and =gui.py= +driving the same code. =maint status --json= is the contract; =maint fix +<thing>= is every lever. The GUI never does anything the CLI can't. + +Safety mechanics baked into the CLI: +- global =--dry-run= prints the exact command instead of executing — free test + surface, and the GUI's arm-press can display it ("this will run: …"). +- hard read/write split: collectors never elevate; every remedy is an + allowlisted exact argv in one small auditable module. + +*Four test layers (safest → scariest):* + +1. /Unit, fake binaries — no VM, no root (~90% of surface)./ Probes are + parsers over command output: feed canned smartctl/btrfs/journalctl/pacman/ + ss/docker output via fakes on PATH (the net suite's fake-curl and audio's + fake-parec pattern). Remedies tested as command construction (assert the + argv, don't run it). The live-update guard is a pure function over a + package list. +2. /Read-only integration on the live machine./ All collectors are read-only + by design — =maint status --json= runs safely against real hosts. +3. /Remedies in a VM — archsetup's existing harness./ + =scripts/testing/run-test.sh= boots the installer VM; a maint scenario + breaks things deliberately over ssh (stop cronie, mask fstrim, orphan + packages, fill the cache), runs =maint fix …=, asserts post-state. No GUI + in the VM. Add qcow2 snapshot/restore between remedy tests so each starts + pristine and destructive remedies can't contaminate each other. Pure + pacman-level tests may use a throwaway systemd-nspawn container instead + (lighter); the VM stays for systemd/btrfs/reboot territory. +4. /GUI on the host, never in the VM./ AT-SPI smoke like the sibling panels, + driven by fixture data. The prototype's GOOD/BAD snapshots become those + fixtures — =MAINT_PANEL_FIXTURE=bad= renders the degraded board without a + degraded machine, conforming to the =maint status --json= schema. + +* Open questions + +- RESOLVED 2026-07-06 — live output wall (amber running / green done / red fail), + realtime, always shown. See "Doctor = live output wall" above. +- RESOLVED 2026-07-06 — velox is a first-class v1 target alongside ratio. See + "Scope: hosts" below. +- RESOLVED 2026-07-06 — single machine-readable thresholds file + (=maintenance-thresholds.toml=) is the source of truth, *owned by archsetup*. + Both the console and the system-health-check workflow read it, so they can + never drift. See "Thresholds source of truth" and "Workflow ownership" below. diff --git a/docs/design/system-monitor-design-ideas.org b/docs/design/system-monitor-design-ideas.org new file mode 100644 index 0000000..26619a0 --- /dev/null +++ b/docs/design/system-monitor-design-ideas.org @@ -0,0 +1,1008 @@ +#+TITLE: System Monitor Design Ideas +#+DATE: 2026-07-04 +#+TODO: TODO | DONE +#+TODO: DRAFT READY DOING | IMPLEMENTED SUPERSEDED CANCELLED + +* DRAFT Status +:PROPERTIES: +:ID: system-monitor-design-ideas +:END: +- [2026-07-04 Sat] DRAFT — initial design sketch for a health monitor covering + Archangel ISO/base-install health, Archsetup workstation health, and the local + laptop as the daily canary. + +* Metadata + +| Field | Value | +|--------+--------------------------------------------| +| Status | draft | +|--------+--------------------------------------------| +| Owner | Craig Jennings | +|--------+--------------------------------------------| +| Repos | archsetup, archangel, dotfiles | +|--------+--------------------------------------------| +| Kin | net panel, bluetooth panel, audio panel | +|--------+--------------------------------------------| + +* Problem + +Archangel and Archsetup can fail in ways that are individually obvious only +after the damage is done: an ISO build goes stale against Arch or archzfs, +ZFSBootMenu or GRUB boots once but not after the first upgrade, a snapshot +hook silently disappears, a package database ages out, systemd services fail +after a reboot, or the desktop contract is technically installed but not +usable. + +The health surface should compress those risks into one operational question: +"Can I trust a fresh install, and is this current workstation drifting away +from the known-good install contract?" + +This monitor is not a generic CPU/RAM graph. It is an install-health and +workstation-contract console. CPU, memory, and temperature belong only as +secondary context unless they block install/test operations. + +* Priority Model + +Rank metrics by the cost of blindness: what happens if Craig never sees the +metric, no one mitigates it, and the next install/upgrade/reboot simply happens. + +Severity: + +- =P0= — can cause data loss, unbootable systems, or loss of rollback path. +- =P1= — can break fresh installs, upgrades, remote access, or core + workstation use. +- =P2= — causes degraded workstation behavior, security drift, or accumulating + maintenance debt. +- =P3= — useful context, not a release gate by itself. + +The panel should sort by live severity first, then by this priority. A red =P2= +row appears above a green =P0= row, but in the steady state the layout keeps the +P0/P1 rows in the first viewport. + +* Priority Ranking + +| Rank | Priority | Metric | Why this rank exists | +|------+----------+---------------------------------+------------------------------------------------------------------------------| +| 1 | P0 | Storage health | Silent pool/filesystem degradation is the nearest thing to data loss. | +|------+----------+---------------------------------+------------------------------------------------------------------------------| +| 2 | P0 | Snapshot safety coverage | Without snapshots, upgrades lose their rollback safety net. | +|------+----------+---------------------------------+------------------------------------------------------------------------------| +| 3 | P0 | Bootloader and EFI redundancy | A machine that cannot boot is operationally dead. | +|------+----------+---------------------------------+------------------------------------------------------------------------------| +| 4 | P1 | First-upgrade bootability | Catches the classic "installed fine, broke after update" failure. | +|------+----------+---------------------------------+------------------------------------------------------------------------------| +| 5 | P1 | End-to-end VM install pass rate | Best release gate for the whole Archangel + Archsetup chain. | +|------+----------+---------------------------------+------------------------------------------------------------------------------| +| 6 | P1 | Package sync and repo freshness | Arch, archzfs, keyring, and mirror drift are leading break signals. | +|------+----------+---------------------------------+------------------------------------------------------------------------------| +| 7 | P1 | Archangel ISO reproducibility | If current inputs cannot build an ISO, recovery/install confidence is stale. | +|------+----------+---------------------------------+------------------------------------------------------------------------------| +| 8 | P1 | Post-install service health | Network, DNS, SSH, and user services decide whether the system is usable. | +|------+----------+---------------------------------+------------------------------------------------------------------------------| +| 9 | P1 | Archsetup state/log cleanliness | Prevents "half-installed but looks fine" machines. | +|------+----------+---------------------------------+------------------------------------------------------------------------------| +| 10 | P2 | Workstation contract checks | Confirms this is Craig's workstation, not just generic Arch. | +|------+----------+---------------------------------+------------------------------------------------------------------------------| +| 11 | P2 | Backup and rollback readiness | Catches loss of off-machine recovery and edited-file backups. | +|------+----------+---------------------------------+------------------------------------------------------------------------------| +| 12 | P2 | Security and hardening drift | Important, but usually less immediately destructive than boot/storage. | +|------+----------+---------------------------------+------------------------------------------------------------------------------| + +This order intentionally puts ZFS/Btrfs health above VM install evidence. A +broken future install is expensive; silent damage to the current root or backup +chain is worse. + +* Consequence Matrix + +This is the design justification for every metric. A row earns panel space only +if blindness has a clear failure mode and Doctor has at least a useful +diagnostic or mitigation. + +| Rank | Metric | If never seen / never mitigated | Typical failure | Worst plausible failure | Doctor posture | +|------+---------------------------------+-----------------------------------------------------------+----------------------------------------------------------------+-------------------------------------------------------------------+-------------------------------------------------| +| 1 | Storage health | Disk, pool, or metadata degradation accumulates silently. | Correctable ZFS/Btrfs errors, low EFI/root space, stale scrub. | Data loss, degraded root, failed import/mount during boot. | Diagnose + scrub/cleanup with confirmation. | +|------+---------------------------------+-----------------------------------------------------------+----------------------------------------------------------------+-------------------------------------------------------------------+-------------------------------------------------| +| 2 | Snapshot safety coverage | Upgrades proceed without a rollback point. | Missing recent snapshot or missing pre-pacman hook. | Bad upgrade cannot be rolled back cleanly; manual repair required. | Create snapshot; restore hook; never rollback. | +|------+---------------------------------+-----------------------------------------------------------+----------------------------------------------------------------+-------------------------------------------------------------------+-------------------------------------------------| +| 3 | Bootloader and EFI redundancy | Boot path rots until the next reboot or disk failure. | Missing GRUB/ZBM file on one EFI partition. | Unbootable machine after update, firmware reset, or disk loss. | Diagnose; regenerate config; advanced reinstall only. | +|------+---------------------------------+-----------------------------------------------------------+----------------------------------------------------------------+-------------------------------------------------------------------+-------------------------------------------------| +| 4 | First-upgrade bootability | Installs look good until the first real system update. | Kernel/initramfs/bootloader mismatch in VM. | Fresh bare-metal install dies on first reboot after upgrade. | VM-only upgrade test; collect boot evidence. | +|------+---------------------------------+-----------------------------------------------------------+----------------------------------------------------------------+-------------------------------------------------------------------+-------------------------------------------------| +| 5 | End-to-end VM install pass rate | Unit tests give false confidence about the real workflow. | Current branch fails one filesystem path or desktop assertion. | Bare-metal install fails mid-flight after disks are wiped. | Run/schedule VM test; clean stale VM artifacts. | +|------+---------------------------------+-----------------------------------------------------------+----------------------------------------------------------------+-------------------------------------------------------------------+-------------------------------------------------| +| 6 | Package sync/repo freshness | Arch/keyring/archzfs drift surprises the next install. | 404s, stale keyring, bad mirror, stale archzfs DB. | Installer cannot pacstrap or installs mismatched ZFS/kernel bits. | Refresh DB; update keyring; reflector. | +|------+---------------------------------+-----------------------------------------------------------+----------------------------------------------------------------+-------------------------------------------------------------------+-------------------------------------------------| +| 7 | ISO build reproducibility | Recovery/install media confidence becomes historical. | AUR package fails to build; mkarchiso or DKMS breaks. | Need rescue/install media and discover no current ISO can be built. | Parse logs; clean work; explicit rebuild. | +|------+---------------------------------+-----------------------------------------------------------+----------------------------------------------------------------+-------------------------------------------------------------------+-------------------------------------------------| +| 8 | Post-install service health | System is installed but degraded in daily operation. | DNS, NetworkManager, fail2ban, tailscale, or user service down. | No remote access, no network, broken sync, or security tooling off. | Restart/re-enable classified services only. | +|------+---------------------------------+-----------------------------------------------------------+----------------------------------------------------------------+-------------------------------------------------------------------+-------------------------------------------------| +| 9 | Archsetup state/log cleanliness | Half-completed provisioning masquerades as success. | Missing marker, log error, skipped step after resume. | Fresh workstation lacks critical config but looks mostly usable. | Summarize; rerun resumable archsetup. | +|------+---------------------------------+-----------------------------------------------------------+----------------------------------------------------------------+-------------------------------------------------------------------+-------------------------------------------------| +| 10 | Workstation contract checks | The box drifts from "Craig's workstation" to generic Arch. | Dotfile symlink broken, keyring wrong, missing tool/package. | Desktop/session workflow is broken during real work. | Restow, repair perms, reinstall with confirm. | +|------+---------------------------------+-----------------------------------------------------------+----------------------------------------------------------------+-------------------------------------------------------------------+-------------------------------------------------| +| 11 | Backup and rollback readiness | Recovery assumptions go stale. | Missing =.archsetup.bak=, backup timer stale, dry-run fails. | Local rollback works but important personal/system state is gone. | Dry-run, start configured job, no deletes. | +|------+---------------------------------+-----------------------------------------------------------+----------------------------------------------------------------+-------------------------------------------------------------------+-------------------------------------------------| +| 12 | Security and hardening drift | Small protective edits quietly regress. | SSH/firewall/fail2ban/sysctl/EFI mask drift. | Exposed service or weakened local boot/config protections. | Restore owned snippets; no broad rewrite. | +|------+---------------------------------+-----------------------------------------------------------+----------------------------------------------------------------+-------------------------------------------------------------------+-------------------------------------------------| + +The worst cases are intentionally conservative, not dramatic. The monitor is +useful because it catches the boring early signal: stale, missing, not recently +tested, or silently degraded. + +* Product Shape + +Use the existing instrument-console panel language rather than a dashboard page: +lamps for pass/warn/fail state, engraved metric groups, dense rows, physical +console keys for explicit actions, and an output well for the last diagnosis. + +The bar module should be tiny: + +- icon: a pulse/terminal glyph or compact =SYS= label. +- state lamp: green/yellow/red/grey. +- text: one word only: =OK=, =WARN=, =FAIL=, =STALE=, =RUNNING=, =UNKNOWN=. +- click: opens the system monitor panel. +- middle click or secondary action: run a cheap refresh only, never repairs. + +The panel should be one screen with internal scroll only where needed. The +first viewport should show the decision state and the Doctor controls without +scrolling. + +* Layout + +** Faceplate + +Top row: + +- State lamp + state word: =OK= / =WARN= / =FAIL= / =STALE= / =RUNNING= / + =UNKNOWN=. +- Unit label: =SYS·01=. +- Scope segmented control: =HOST= / =INSTALL= / =BUILD=. +- Badges: =ZFS= or =BTRFS=, =VM STALE=, =DB STALE=, =SNAPSHOT=, =BACKUP=, + =ROOT= when elevated actions are available. +- Close button. + +The scope control changes the metric emphasis, not the underlying data model: + +- =HOST= is this laptop/workstation right now. +- =INSTALL= is the last Archangel+Archsetup VM install result. +- =BUILD= is ISO, AUR repo, archzfs, and test artifact health. + +** Health Stack + +Arrange metrics as four horizontal bands. Each band has a section title, a +summary lamp, two to four row lamps, and a short "age" or "count" value. + +1. =BOOT + STORAGE= + - bootloader + - EFI redundancy + - pool/filesystem + - snapshots + +2. =INSTALL PIPELINE= + - first-upgrade reboot + - ZFS VM install + - Btrfs VM install + - ISO build + +3. =PACKAGES + SERVICES= + - pacman sync freshness + - archzfs/AUR health + - failed services + - journal errors + +4. =WORKSTATION CONTRACT= + - Archsetup markers/logs + - user/dotfiles + - desktop/session + - backups/replication + +Each row is clickable. Clicking a row opens the evidence drawer in the output +well with: + +- last command run +- normalized verdict +- raw excerpt, redacted where needed +- suggested Doctor action, if any + +** Console Keys + +Use physical console-key buttons, same family as net/bt: + +| Key | Purpose | +|--------------+------------------------------------------------------------| +| REFRESH | Cheap read-only probe of host state | +|--------------+------------------------------------------------------------| +| DOCTOR | Diagnose, classify, run safe mitigations, re-check | +|--------------+------------------------------------------------------------| +| TEST VM | Run or schedule Archsetup VM validation | +|--------------+------------------------------------------------------------| +| BUILD ISO | Run or schedule Archangel ISO build | +|--------------+------------------------------------------------------------| +| CLEAN | Clean old test artifacts, package cache, stale logs | +|--------------+------------------------------------------------------------| +| SNAPSHOT | Create a manual pre-change snapshot | +|--------------+------------------------------------------------------------| + +Keys that can take a long time stream progress into the output well. Mutating +keys must use arm-first behavior: + +- first click arms for 3 seconds and explains the action. +- second click runs. +- destructive cleanup names what will be deleted before it runs. + +* Metric Details + +Details below are grouped by workflow rather than priority. The authoritative +importance order is the priority table above. + +** 1. End-to-end VM install pass rate =P1/rank 5= + +Problem overcome: unit tests can pass while the real install is broken by +mirrors, bootloader state, pacstrap, SSH, disk layout, or the actual desktop +contract. This metric fights false confidence. + +Representation: + +- Lamp row in =INSTALL PIPELINE=. +- Two child lamps: =ZFS= and =BTRFS=. +- Age chip: =last pass 2d= / =never= / =stale 14d=. +- Red if either required filesystem has no recent pass. + +Tools: + +- =scripts/testing/run-test.sh= +- =scripts/testing/create-base-vm.sh= +- =pytest= testinfra suite under =scripts/testing/tests/= +- =qemu-img=, =qemu-system-x86_64=, =sshpass= + +Doctor: + +- read-only first: summarize last =test-results/*/test-report.txt= and failing + test names. +- mitigation: offer =TEST VM= for the failed filesystem. +- cleanup: remove stale temporary VM overlays before retrying. +- no automatic retry loop if the failure is in the installer itself. + +** 2. First-upgrade bootability =P1/rank 4= + +Problem overcome: the machine can boot immediately after install but fail after +the first =pacman -Syu= because initramfs hooks, ZFS modules, GRUB, ZFSBootMenu, +or kernel packages drift. + +Representation: + +- Lamp row: =first-upgrade reboot=. +- Badge: =not run=, =passed=, =failed=. +- Evidence drawer includes boot count, kernel version, and last reachable SSH + timestamp. + +Tools: + +- VM harness +- =pacman -Syu= +- =reboot= +- =ssh= reachability checks +- =journalctl -b -1= where available + +Doctor: + +- run upgrade-in-VM only, never on host without explicit confirmation. +- if failure is ZFS, collect =zpool import=, =lsinitcpio=, =mkinitcpio.conf=, + and EFI files. +- if failure is Btrfs, collect =grub.cfg=, =crypttab=, =fstab=, and snapper + config. + +** 3. Package database freshness and sync health =P1/rank 6= + +Problem overcome: Arch rolling-release state changes faster than installer +assumptions. Stale sync databases, stale keyrings, archzfs drift, or broken +mirrors are leading indicators of a failing install. + +Representation: + +- Row lamp in =PACKAGES + SERVICES=. +- Small meter: newest sync DB age vs threshold. +- Child chips: =core=, =extra=, =multilib=, =archzfs=. +- Yellow over 48 hours, red over 7 days or failed sync. + +Tools: + +- =find /var/lib/pacman/sync= +- =pacman -Syyu --needed archlinux-keyring= +- =checkupdates= +- =reflector= +- =pacman-conf= + +Doctor: + +- safe: refresh package databases. +- safe: update =archlinux-keyring= before full upgrades. +- mitigation: run =reflector= with the configured country/age policy. +- no unattended full system upgrade from the panel unless separately approved. + +** 4. Archangel ISO build reproducibility =P1/rank 7= + +Problem overcome: an old "good" ISO can hide broken current inputs. Archiso, +archzfs, DKMS, AUR package recipes, and pacoloco cache state can all break the +next install. + +Representation: + +- Row lamp in =INSTALL PIPELINE= or =BUILD= scope. +- Shows latest ISO date, kernel version, and AUR manifest age. +- Red if latest build failed or no ISO exists. +- Yellow if latest successful ISO is older than the configured freshness + window. + +Tools: + +- =make build= in =~/code/archangel= +- =build.sh --skip-aur= for fast non-AUR iteration +- =build-aur.sh= +- =mkarchiso= +- =pacoloco= status if installed + +Doctor: + +- read-only: parse latest =out/*.log= for pacman, DKMS, archzfs, AUR, and + mkarchiso failures. +- cleanup: safe build-work cleanup only through Archangel's cleanup function + or =make clean=. +- mitigation: suggest =--skip-aur= when the failure is unrelated to baked AUR. +- build retry is explicit via =BUILD ISO=, not automatic. + +** 5. ZFS/Btrfs storage health =P0/rank 1= + +Problem overcome: the root filesystem can degrade silently before the user +notices. For ZFS this means pool errors or degraded vdevs; for Btrfs this means +device stats, scrub failures, metadata pressure, or degraded RAID. + +Representation: + +- =BOOT + STORAGE= band. +- Filesystem-specific lamp grammar: + - ZFS green: =zpool status -x= healthy. + - Btrfs green: device stats clean and recent scrub clean. +- Capacity strip for root/home/EFI. + +Tools: + +- ZFS: =zpool status -x=, =zpool list=, =zfs list=. +- Btrfs: =btrfs device stats=, =btrfs filesystem usage=, + =btrfs scrub status=. +- Common: =df -h=, =findmnt=, =lsblk=. + +Doctor: + +- safe: start a scrub only with arm-first confirmation. +- safe: clear stale Btrfs stats only after a clean scrub and explicit + confirmation. +- mitigation: warn on low EFI/root space and offer package cache cleanup. +- never destroy snapshots, pools, subvolumes, or datasets from Doctor. + +** 6. Snapshot safety coverage =P0/rank 2= + +Problem overcome: rollback safety is assumed during upgrades but can disappear +when hooks, services, or snapshot tools drift. + +Representation: + +- Row lamp: =snapshots=. +- Child chips: =genesis=, =pre-pacman=, =recent=, =pruned=. +- Yellow if no recent snapshot. +- Red if genesis or pre-transaction hook is missing. + +Tools: + +- ZFS: =zfs list -t snapshot=, =zfs-pre-snapshot=, + =/etc/pacman.d/hooks/zfs-snapshot.hook=. +- Btrfs: =snapper list=, =snap-pac=, =grub-btrfs-mkconfig=, + =/.snapshots=. +- Common: =pacman -Q= for snapshot packages. + +Doctor: + +- safe: create a manual snapshot. +- safe: reinstall or re-enable missing hook only if the expected script exists. +- cleanup: prune only snapshots matching the tool-owned policy and prefix. +- mitigation: show exact command for manual rollback; do not perform rollback + from the panel. + +** 7. Bootloader and EFI redundancy =P0/rank 3= + +Problem overcome: single-disk bootloader success can mask missing redundant EFI +installs on multi-disk systems. A system can also pass install but lose a boot +entry or generate an invalid config. + +Representation: + +- Row lamp: =bootloader=. +- Child chips: =ZBM= or =GRUB=, =EFI=, =entries=, =all disks=. +- Yellow if redundancy cannot be proven. +- Red if the expected loader/config is missing. + +Tools: + +- =bootctl status= +- =efibootmgr -v= +- =findmnt /efi /boot= +- ZFS: check =/efi/EFI/ZBM/zfsbootmenu.efi=. +- Btrfs: check =/boot/grub/grub.cfg= and grub-btrfs entries. + +Doctor: + +- read-only by default. +- mitigation: regenerate GRUB config for Btrfs with arm-first confirmation. +- mitigation: rebuild initramfs with arm-first confirmation. +- no automatic EFI reinstall without an explicit advanced flow. + +** 8. Post-install service health =P1/rank 8= + +Problem overcome: the install can complete while the real workstation is +degraded: DNS broken, NetworkManager failed, fail2ban not responding, user +services not lingering, or Docker/Tailscale/Syncthing not in their expected +state. + +Representation: + +- =PACKAGES + SERVICES= band. +- Count badge: =0 failed= or =3 failed=. +- Child lamps: =network=, =dns=, =security=, =user services=. + +Tools: + +- =systemctl --failed= +- =systemctl is-enabled/is-active= +- =resolvectl status= +- =nmcli general status= +- =fail2ban-client status= +- =loginctl show-user= + +Doctor: + +- safe: restart known flaky non-destructive services such as + =NetworkManager= only after classifying the failure. +- safe: re-enable expected services from Archsetup's contract. +- mitigation: bounce DNS resolver and re-check. +- no blanket =systemctl restart --failed=. + +** 9. Archsetup state and log cleanliness =P1/rank 9= + +Problem overcome: a resumable installer can leave a half-finished system that +looks usable until a missing marker or skipped step matters later. + +Representation: + +- =WORKSTATION CONTRACT= band. +- Step-progress mini bar: completed markers / expected markers. +- Red if =archsetup --status= reports incomplete required steps. +- Red if latest log contains fatal errors. + +Tools: + +- =./archsetup --status= +- =/var/log/archsetup-*.log= +- marker files from the Archsetup state directory +- existing testinfra assertions in =scripts/testing/tests/test_archsetup.py= + +Doctor: + +- read-only: summarize incomplete steps and latest log errors. +- mitigation: offer to rerun =archsetup= in normal resumable mode. +- cleanup: archive old logs, keep the latest N. +- never run =--fresh= from Doctor. + +** 10. Workstation contract checks =P2/rank 10= + +Problem overcome: a fresh Arch system is not the goal. The goal is Craig's +working machine: user, shell, groups, dotfiles, Emacs, Hyprland/DWM, keyring, +VPN tools, Bluetooth tools, and local scripts. + +Representation: + +- =WORKSTATION CONTRACT= band. +- Child lamps: =user=, =dotfiles=, =desktop=, =tools=. +- Evidence drawer mirrors the testinfra checks. + +Tools: + +- =id=, =getent passwd= +- =test -L ~/.zshrc= +- =stow= via dotfiles Makefile +- =pacman -Q=, =yay -Qi yay= +- =hyprctl=, =gdbus= portal checks when session is running + +Doctor: + +- safe: restow dotfiles with the selected profile. +- safe: repair keyring directory permissions. +- mitigation: reinstall missing official packages. +- AUR package rebuilds require confirmation and stream output. + +** 11. Security and hardening drift =P2/rank 12= + +Problem overcome: security settings are easy to regress because they are small +file edits: SSH root login, EFI mount masks, firewall, issue banner, fail2ban, +quiet printk. + +Representation: + +- Compact row under =WORKSTATION CONTRACT= or =PACKAGES + SERVICES=. +- Red only for high-risk drift, yellow for unknown/unreadable state. + +Tools: + +- =sshd -T= or config file checks +- =ufw status= +- =fail2ban-client status= +- =findmnt /efi= +- =sysctl kernel.printk= + +Doctor: + +- safe: restore known Archsetup-owned config snippets. +- safe: re-enable firewall if policy file is present. +- mitigation: write missing drop-ins only from version-controlled templates. +- no broad hardening rewrite from panel state. + +** 12. Backup and rollback readiness =P2/rank 11= + +Problem overcome: rollback only helps local state. The install also needs +backups of edited system files and confidence that personal data replication is +not silently stale. + +Representation: + +- Row lamp: =backups=. +- Chips: =system-file .bak=, =replication=, =last run=. +- Yellow if last replication exceeds policy. +- Red if expected backup files for edited system config are missing. + +Tools: + +- Archsetup backup assertions in =scripts/testing/tests/test_backups.py=. +- =zfs-replicate= if configured. +- =systemctl list-timers= for backup timers. +- =journalctl -u= relevant backup units. + +Doctor: + +- safe: create missing =.archsetup.bak= for files before editing. +- safe: run dry-run replication check. +- mitigation: start a configured backup timer/unit with confirmation. +- never delete backup targets from Doctor. + +* Doctor Model + +Doctor is a classifier with bounded mitigations, not a magic repair button. + +Flow: + +1. Probe the selected scope. +2. Normalize each metric to =ok=, =warn=, =fail=, =unknown=, or =running=. +3. Classify failures as: + - =safe-fix= — local, reversible, low risk. + - =safe-cleanup= — removes only known generated artifacts. + - =mitigation= — improves the chance of success but does not claim repair. + - =needs-confirmation= — mutating, long-running, or system-wide. + - =manual= — too dangerous or context-heavy for Doctor. +4. Run only safe actions automatically after the user presses Doctor. +5. Arm-first for anything mutating beyond safe local cleanup. +6. Re-run the affected probe. +7. Stream a verdict into the output well. + +Doctor should say exactly what it did: + +#+BEGIN_EXAMPLE +doctor: package db stale + check: core.db age 4d, archzfs.db age 4d + action: refreshed sync databases + action: updated archlinux-keyring + result: ok, newest db age 2m +#+END_EXAMPLE + +* Common Tool Drivers + +** Host probes + +| Area | Commands | +|------------+----------------------------------------------------------------| +| systemd | =systemctl --failed=, =systemctl is-active=, =journalctl= | +|------------+----------------------------------------------------------------| +| packages | =pacman=, =checkupdates=, =pacman-conf=, =yay= | +|------------+----------------------------------------------------------------| +| storage | =zpool=, =zfs=, =btrfs=, =df=, =findmnt=, =lsblk= | +|------------+----------------------------------------------------------------| +| boot | =bootctl=, =efibootmgr=, =mkinitcpio=, =grub-mkconfig= | +|------------+----------------------------------------------------------------| +| network | =nmcli=, =resolvectl=, =ping= or HTTPS probe | +|------------+----------------------------------------------------------------| +| desktop | =hyprctl=, =gdbus=, =loginctl=, dotfiles Makefile | +|------------+----------------------------------------------------------------| + +** Project probes + +| Area | Commands | +|------------+----------------------------------------------------------------| +| archangel | =make test=, =make build=, =build.sh --skip-aur= | +|------------+----------------------------------------------------------------| +| archsetup | =make test-unit=, =make test=, =scripts/testing/run-test.sh= | +|------------+----------------------------------------------------------------| +| VM | =qemu-img=, =qemu-system-x86_64=, =sshpass=, =pytest= | +|------------+----------------------------------------------------------------| +| artifacts | latest =out/*.log=, =out/*aur-manifest.tsv=, =test-results/*= | +|------------+----------------------------------------------------------------| + +* Top-family Comparison + +This monitor should borrow the mature display ideas from =top=-style tools +without becoming another CPU/process viewer. The domain objects are install +contracts, boot/storage health, package freshness, snapshots, services, and +artifacts. The interaction model is still the same: sort the thing that hurts, +filter to the thing you care about, expand one row for evidence, and act only +when the diagnosis is clear. + +** Comparison table + +| Tool | What it represents well | Sorting/filtering model | Useful pattern for system monitor | Gaps for our domain | +|-------------------+---------------------------------------------------------------+---------------------------------------------------------------+-----------------------------------------------------------------+----------------------------------------------------------| +| =htop= | Dense live table plus configurable meters; process tree; direct process actions. | Interactive sort by column, search, filter, tree toggle. | Metric table should support column sort, search, filter, and tree/group mode. | No historical artifact model; actions are process-centric. | +|-------------------+---------------------------------------------------------------+---------------------------------------------------------------+-----------------------------------------------------------------+----------------------------------------------------------| +| =btop++= | Boxed dashboard: CPU, memory, disks, network, processes, battery, GPU; strong graph language; selected process detail. | Easy switching between process sort modes; filter; tree view; pause. | Use boxed bands, mini time-series, detail pane, pause/freeze, and clickable controls. | Graph-first layout can overemphasize volatile values over install risk. | +|-------------------+---------------------------------------------------------------+---------------------------------------------------------------+-----------------------------------------------------------------+----------------------------------------------------------| +| =bottom/btm= | Custom widget layout, per-widget focus/expand, zoomable time windows, basic mode. | Process widget supports sort, search, tree; widgets can be filtered/configured. | Every health band should be expandable; stale/history windows should be zoomable. | Mostly resource telemetry, not remediation workflow. | +|-------------------+---------------------------------------------------------------+---------------------------------------------------------------+-----------------------------------------------------------------+----------------------------------------------------------| +| =atop= | Interval deltas, critical-resource highlighting, all active processes including exited ones, long-term logs. | Resource views and interval replay; emphasizes deviations and active load. | Add history/replay for health events and show "new since last good" changes. | Lower immediate visual polish; Linux-performance scoped. | +|-------------------+---------------------------------------------------------------+---------------------------------------------------------------+-----------------------------------------------------------------+----------------------------------------------------------| +| =Glances= | Broad plugin dashboard, thresholds, remote/web/API modes, export to JSON/CSV/time-series backends. | Configurable visible plugins; API/stdout selectors instead of only interactive sorting. | Use plugin architecture, threshold config, JSON output, remote/headless mode. | Too broad; can become a generic monitoring surface. | +|-------------------+---------------------------------------------------------------+---------------------------------------------------------------+-----------------------------------------------------------------+----------------------------------------------------------| +| =procs= | Modern table ergonomics: custom columns, keyword search across selected fields, sort by named column, tree view. | CLI sort asc/desc by partial column name; watch mode cycles sort columns; AND/OR/NAND/NOR search. | Use named metric columns, saved views, multi-keyword filters, and value-aware coloring. | Process-only; no graphs or remediation model. | +|-------------------+---------------------------------------------------------------+---------------------------------------------------------------+-----------------------------------------------------------------+----------------------------------------------------------| +| =gotop/gtop= family | Fast glanceable terminal dashboard with compact graphs and gauges. | Usually lighter than htop/btop; less important than presentation density. | Use compact sparklines/gauges for "age", "last pass", and "failure count". | Not enough evidence/action depth for this monitor. | +|-------------------+---------------------------------------------------------------+---------------------------------------------------------------+-----------------------------------------------------------------+----------------------------------------------------------| + +** What to pull in + +*** htop: table discipline + +Pull: + +- Column headers that are real controls: click or key-cycle to sort by + =priority=, =state=, =age=, =last_checked=, =last_pass=, =failure_count=, + =scope=, and =doctor_class=. +- Search and filter as first-class actions, not hidden debug commands. +- Tree mode for ownership: + - =system= → =boot/storage= → =bootloader= → =efi entries=. + - =archangel= → =iso build= → =aur repo= → =manifest rows=. + - =archsetup= → =state markers= → =desktop= → =dotfiles=. +- Horizontal detail access for long evidence, like htop's horizontal scrolling + for full commands. + +Equivalent-or-better requirement: + +- htop sorts processes; this monitor sorts risk. The default sort is + live-state severity, then priority rank, then age. + +*** btop++: instrument boxes and live graphs + +Pull: + +- Boxed bands with stable geometry. +- Small time-series graphs, but only where history matters: + - package DB age over time + - failed-service count + - journal error count + - snapshot count / newest snapshot age + - VM pass/fail history + - ISO build duration/result history +- Selected-row detail pane with the last command, verdict, and raw excerpt. +- Pause/freeze button so a failure does not scroll away while reading. +- Mouse-clickable controls where every visible key has the same keyboard path. + +Equivalent-or-better requirement: + +- btop's graphs answer "what is hot right now?" Our graphs answer "is the + safety margin shrinking?" Trend charts should be muted unless the threshold + is crossed. + +*** bottom: focus/expand and layout presets + +Pull: + +- Expand one band full-height: + - =BOOT + STORAGE= expands into boot files, EFI entries, pools, snapshots. + - =INSTALL PIPELINE= expands into last VM runs and build artifacts. + - =PACKAGES + SERVICES= expands into DB ages, repo status, failed units. + - =WORKSTATION CONTRACT= expands into Archsetup markers and testinfra-style + checks. +- Zoomable history windows: 24h / 7d / 30d / all artifacts. +- Layout presets: + - =compact= for bar dropdown. + - =full= for terminal/TUI. + - =host-only= for laptop health. + - =release-gate= for Archangel/Archsetup changes. + +Equivalent-or-better requirement: + +- bottom expands widgets; this monitor expands evidence and remediation state. + The expanded view must show "what changed since last good" before raw logs. + +*** atop: history and vanished failures + +Pull: + +- Permanent, compact health-event log. +- Interval deltas instead of only current values: + - new failed services since last check + - new journal errors since last check + - packages/repos newly stale + - snapshot hook present before, missing now + - bootloader file changed since last known-good +- "Show active/deviating only" mode. In normal use, hide green rows unless + their age is approaching threshold. +- Replay mode: inspect the state at the time an install/test/build failed. + +Equivalent-or-better requirement: + +- atop can report processes that already exited. This monitor should report + failures that already passed through: a transient failed unit, a VM test that + failed last night, an ISO build that failed before the current successful + build, or a package DB that was stale until Doctor fixed it. + +*** Glances: plugin/API/export model + +Pull: + +- Plugin-like probes. Each metric owns: + - =probe= + - =normalize= + - =thresholds= + - =doctor_actions= + - =redaction= + - =evidence= +- JSON output as a stable contract before GTK work. +- Optional stdout selectors: + - =system-monitor --stdout packages.state,storage.state= + - =system-monitor --json boot,snapshots= +- Remote/headless mode for VMs and bare-metal test targets. +- Threshold config in one file, not hardcoded in the UI. + +Equivalent-or-better requirement: + +- Glances is broad; this must stay opinionated. A plugin is accepted only if it + maps to install health, rollback safety, workstation contract, or recovery + readiness. + +*** procs: custom columns and query grammar + +Pull: + +- Named columns and saved views: + - =risk=: state, priority, age, doctor class. + - =install=: last pass, filesystem, artifact, branch, commit. + - =host=: state, source, last checked, command. + - =doctor=: action class, requires root, reversible, last run. +- Multi-keyword search: + - =zfs failed= + - =doctor safe-fix= + - =archangel stale= + - =service red= +- Boolean query modes: + - AND default for narrowing. + - OR for "show any boot or storage issue". + - NOT for "hide green". +- Value-aware coloring for age, severity, and units. + +Equivalent-or-better requirement: + +- procs lets the user build a process table. This monitor should let Craig + build a risk table without editing code. + +*** gotop/gtop: glance density + +Pull: + +- Small sparklines for trend, not full charts. +- Big obvious state words. +- Compact gauges for bounded values: + - EFI usage + - root/home usage + - DB age as percent of freshness window + - VM evidence age + - snapshot age +- Simple default screen that is useful without learning keys. + +Equivalent-or-better requirement: + +- The first screen should answer "am I safe to upgrade or install?" in under + two seconds. + +* Sorting and Views + +The monitor needs two sorting layers: global row ordering and per-band evidence +tables. + +** Global row ordering + +Default: + +1. =state= severity: red, yellow, unknown, running, green. +2. =priority= rank: P0 before P1 before P2. +3. =age= or =staleness=, descending. +4. =last_changed=, newest first. + +Alternate sorts: + +| Sort key | Use case | +|-----------------+------------------------------------------------------| +| =priority= | Release-gate review; keep P0/P1 at the top. | +|-----------------+------------------------------------------------------| +| =state= | Triage; show all red/yellow rows first. | +|-----------------+------------------------------------------------------| +| =age= | Find stale tests, stale package DBs, old backups. | +|-----------------+------------------------------------------------------| +| =doctor_class= | Find what Doctor can safely fix now. | +|-----------------+------------------------------------------------------| +| =scope= | Group host vs install vs build. | +|-----------------+------------------------------------------------------| +| =last_changed= | See what recently regressed. | +|-----------------+------------------------------------------------------| +| =source= | Group by archangel, archsetup, dotfiles, host. | +|-----------------+------------------------------------------------------| + +** Per-band sorts + +| Band | Sorts | +|------------------------+------------------------------------------------------------| +| =BOOT + STORAGE= | severity, mountpoint, filesystem, capacity, last scrub, newest snapshot age | +|------------------------+------------------------------------------------------------| +| =INSTALL PIPELINE= | result, filesystem, duration, artifact age, commit age, last pass | +|------------------------+------------------------------------------------------------| +| =PACKAGES + SERVICES= | severity, unit name, repo name, DB age, error count, enabled/active state | +|------------------------+------------------------------------------------------------| +| =WORKSTATION CONTRACT= | severity, check name, owner repo, last pass, doctor class | +|------------------------+------------------------------------------------------------| + +** Filters + +Quick filters should be visible as chips: + +- =red= +- =yellow= +- =doctorable= +- =needs-root= +- =stale= +- =zfs= +- =btrfs= +- =host= +- =install= +- =build= +- =changed= +- =hidden-green= + +The default view can hide healthy low-priority rows, but it must show enough +green P0/P1 summary state to prove the monitor is working. + +* Display Requirements Borrowed from Tops + +1. Every table has sortable columns and a visible sort indicator. +2. Every visible metric row has a filterable state, priority, age, and source. +3. Every row can expand to evidence without losing the list context. +4. Every graph has a threshold marker; trend without threshold is decoration. +5. Every long-running action can be paused/frozen in the display. +6. Every mutating action has an equivalent CLI command shown in the output well. +7. Every Doctor action records before/after state so fixed failures remain + visible in history. +8. Every band has a compact mode and an expanded mode. +9. Green rows are quiet; new regressions are loud. +10. The system must be useful over SSH/TUI before GTK polish. + +* Source Notes + +- =htop=: upstream README describes configurable system/process display, + interactive sorting/filtering/search, tree view, and process actions. +- =btop++=: upstream README describes resource boxes, detailed process stats, + filter, sort switching, tree view, mouse support, auto-scaling network graphs, + disk IO, battery, GPU support, and themes. +- =bottom/btm=: upstream README describes customizable widgets, process sort + and search, tree mode, expand/focus, zoomable graph intervals, filters, and + basic mode. +- =atop=: upstream README describes interval resource accounting, critical + highlighting, long-term compressed logs, exited-process visibility, cgroup + views, and active/deviation-focused output. +- =Glances=: upstream README describes plugin-style broad monitoring, web/API + modes, stdout JSON/CSV, remote monitoring, exports, and threshold-oriented + dashboard use. +- =procs=: upstream README describes configurable columns, named-column sort, + watch mode, tree view, logical keyword search, value-aware coloring, and + pager behavior. + +* Data Model + +Emit JSON from a CLI first; the panel is a client. + +#+BEGIN_SRC json +{ + "v": 1, + "scope": "host", + "state": "warn", + "ts": "2026-07-04T12:00:00-04:00", + "metrics": [ + { + "id": "packages.sync_freshness", + "label": "package databases", + "state": "warn", + "summary": "archzfs.db age 4d", + "evidence": [ + {"command": "find /var/lib/pacman/sync", "excerpt": "archzfs.db 2026-06-30"} + ], + "doctor": { + "class": "safe-fix", + "actions": ["refresh-sync-db", "update-keyring"] + } + } + ] +} +#+END_SRC + +* Implementation Notes + +- Start with a CLI: =system-monitor status --json=, =system-monitor doctor + --json=, =system-monitor refresh=. +- Keep probes read-only by default. Actions live in separate verbs. +- Cache slow probes. The bar should read a cache, not run VM tests. +- VM/build actions should create job records and stream logs; the panel follows + the job rather than blocking the UI process. +- Reuse the net/bt panel architecture if this becomes a GTK panel: GTK-free + model + fake-command unit tests + one AT-SPI smoke. +- Redact secrets from logs and JSON: WiFi PSKs, tokens, private repo URLs with + credentials, SSH material, and backup target credentials. + +* Open Decisions + +** TODO Where should the first implementation live? + +Recommendation: dotfiles owns the user-facing panel and CLI wrapper because it +is workstation UI. Archsetup owns reusable install-contract probes and testinfra +assertions. Archangel owns ISO/build probes. + +** TODO Should Doctor run elevated actions through polkit or terminal? + +Recommendation: read-only checks run unprivileged; elevated actions launch a +terminal or polkit prompt with the exact command visible. Do not hide long +privileged operations inside the panel process. + +** TODO How fresh must VM evidence be? + +Recommendation: host checks go stale after 1 hour; package DB after 48 hours; +VM install evidence after 7 days; ISO build evidence after 14 days or whenever +Archangel/Archsetup has changed since the last successful artifact. + +** TODO Which actions are allowed on bare metal? + +Recommendation: host Doctor may refresh databases, update keyring, restow +dotfiles, create snapshots, run scrub, and restart narrowly classified services. +It may not perform full upgrades, bootloader reinstalls, destructive snapshot +prune, or filesystem repair without a separate advanced flow. + +* First Build Slice + +1. CLI read-only host status: + - package DB freshness + - failed services + - ZFS/Btrfs health + - snapshot presence + - Archsetup status/log check +2. Doctor safe actions: + - refresh package DB + - update keyring + - restow dotfiles + - create manual snapshot +3. Artifact parser: + - latest Archsetup VM test result + - latest Archangel ISO build result +4. Panel prototype: + - faceplate + - four health bands + - evidence output well + - REFRESH and DOCTOR keys only |
