Compare commits

...
Author SHA1 Message Date
xavierk 8de9fe4ee1 prototype: titlebox health layout and presets 2026-09-14 02:00:16 +05:30
xavierk f406285a0f release: prepare v0.3.4
Release / release (push) Successful in 1m4s
2026-09-10 21:07:10 +05:30
xavierk 608ad7f823 docs(release): point consumers to release notes 2026-09-10 20:05:37 +05:30
xavierk cfdef63388 fix(release): execute publication request safely 2026-09-10 20:04:29 +05:30
xavierk f077fa671e feat(release): publish changelog-driven notes 2026-09-10 20:02:19 +05:30
xavierk d01df6468f feat(tui): clarify monitoring continuity and quitting 2026-09-10 19:43:32 +05:30
xavierk 7bbe5cede7 feat(tui): identify Fenris and explain polkit authentication 2026-09-10 13:28:05 +05:30
xavierk bb5bc9a72e docs(spec): assemble dashboard clarity spec, DC-1–DC-8 criteria, TUI-4 amendment (wayfinder #60) 2026-09-10 12:03:31 +05:30
xavierk 4a7661d81c chore: bump version to 0.3.3 (missed from #54 fix commit)
Release / release (push) Successful in 55s
2026-09-10 10:01:28 +05:30
xavierk fb683f52ba fix(store): degrade on store permission errors, keep store group-readable (issue #54)
Release / release (push) Successful in 53s
- open_store_readonly(): stat() PermissionError (non-group user on the
  2750 store dir) now maps to StoreFault so status/TUI degrade instead
  of crashing with a traceback.
- init_store(): chmod db + -wal/-shm group rw after WAL setup — SQLite
  WAL readers need write access to sidecars even for mode=ro opens.
- Store dir 2750 → 2770 (tmpfiles + make install) and UMask=002 on the
  collect unit so root-created files stay group-accessible.
- rpm %post upgrade path re-runs systemd-tmpfiles --create to correct
  placement modes on existing machines.
Bump to 0.3.3.
2026-09-10 09:58:24 +05:30
xavierk 512df2ae83 fix(store): default store_path when config omits it (issue #53)
Release / release (push) Successful in 59s
Fresh installs shipped a config template with no store_path key while
collector.py demanded one via get_store_path() — every first collect
crashed with KeyError 'store_path'. Resolve to the packaged default
(/var/lib/fenris/observations.db) when absent, document the key in the
template, and cover the fresh-install path with regression tests.
Bump to 0.3.2.
2026-09-10 09:45:02 +05:30
xavierk bcbc97a947 chore: ignore local build and tooling artifacts
Release / release (push) Successful in 57s
2026-09-10 09:27:44 +05:30
xavierk b593a2742e fix: make RPM runtime portable on Tumbleweed 2026-09-04 11:46:58 +05:30
39 changed files with 1688 additions and 125 deletions
+56 -13
View File
@@ -21,6 +21,25 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Validate release tag and notes
run: |
set -euo pipefail
VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' pyproject.toml)"
if [ -z "${VERSION}" ]; then
echo "::error::could not determine the project version"
exit 1
fi
if [ "${GITHUB_EVENT_NAME}" != "workflow_dispatch" ]; then
EXPECTED_TAG="v${VERSION}"
ACTUAL_TAG="${GITHUB_REF#refs/tags/}"
if [ "${ACTUAL_TAG}" != "${EXPECTED_TAG}" ]; then
echo "::error::tag ${ACTUAL_TAG} does not match ${EXPECTED_TAG}"
exit 1
fi
fi
python3 scripts/extract_changelog.py CHANGELOG.md "${VERSION}" \
--footer packaging/release-footer.md > "${RUNNER_TEMP}/release-body.md"
- name: Set up Python
uses: actions/setup-python@v5
with:
@@ -144,20 +163,44 @@ jobs:
env:
GITEA_PUBLISH_TOKEN: ${{ secrets.GITEAPACKAGETOKEN }}
run: |
VERSION=${{ steps.version.outputs.version }}
# Check if release already exists (idempotent re-runs)
EXISTING=$(curl -s -o /dev/null -w '%{http_code}' \
-H "Authorization: token ${GITEA_PUBLISH_TOKEN}" \
"https://git.bongbetic.com/api/v1/repos/xavierk/Fenris/releases/tags/v${VERSION}")
if [ "$EXISTING" = "200" ]; then
echo "Release v${VERSION} already exists, skipping creation"
else
curl --fail -X POST \
-H "Authorization: token ${GITEA_PUBLISH_TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\":\"v${VERSION}\",\"name\":\"v${VERSION}\"}" \
"https://git.bongbetic.com/api/v1/repos/xavierk/Fenris/releases"
set -euo pipefail
if [ -z "${GITEA_PUBLISH_TOKEN}" ]; then
echo "::error::GITEAPACKAGETOKEN repository secret is not configured"
exit 1
fi
VERSION=${{ steps.version.outputs.version }}
RELEASE_BODY="${RUNNER_TEMP}/release-body.md"
if [ ! -s "${RELEASE_BODY}" ]; then
echo "::error::validated release body is missing or empty"
exit 1
fi
EXISTING_RELEASE="${RUNNER_TEMP}/existing-release.json"
EXISTING=$(curl --silent --show-error -o "${EXISTING_RELEASE}" -w '%{http_code}' \
-H "Authorization: token ${GITEA_PUBLISH_TOKEN}" \
"https://git.bongbetic.com/api/v1/repos/xavierk/Fenris/releases/tags/v${VERSION}" || true)
case "${EXISTING}" in
200)
echo "Release v${VERSION} exists; resynchronizing its notes"
REQUEST="$(python3 scripts/release_request.py --version "${VERSION}" \
--body-file "${RELEASE_BODY}" --existing-release "${EXISTING_RELEASE}")"
;;
404)
REQUEST="$(python3 scripts/release_request.py --version "${VERSION}" \
--body-file "${RELEASE_BODY}")"
;;
*)
echo "::error::release lookup failed with HTTP ${EXISTING}"
exit 1
;;
esac
METHOD="$(printf '%s' "${REQUEST}" | python3 -c "import json,sys; print(json.load(sys.stdin)['method'])")"
RELEASE_PATH="$(printf '%s' "${REQUEST}" | python3 -c "import json,sys; print(json.load(sys.stdin)['path'])")"
PAYLOAD="$(printf '%s' "${REQUEST}" | python3 -c "import json,sys; print(json.dumps(json.load(sys.stdin)['payload']))")"
curl --fail --silent --show-error -X "${METHOD}" \
-H "Authorization: token ${GITEA_PUBLISH_TOKEN}" \
-H "Content-Type: application/json" \
-d "${PAYLOAD}" \
"https://git.bongbetic.com/api/v1/repos/xavierk/Fenris${RELEASE_PATH}"
- name: Attach artifacts to release
env:
+8 -3
View File
@@ -10,6 +10,11 @@ plan-dash-changes.md
# Packaging build artifacts
build/
dist/*.deb
dist/*.rpm
dist/SHA256SUMS*
dist/
# Local tooling
graphify-out/
json
src/fenris.egg-info/
.pytest_cache/
.venv/
+18
View File
@@ -0,0 +1,18 @@
# Changelog
<!--
Maintainers add one user-facing entry to Unreleased with each change. A release
commit bumps pyproject.toml, renames Unreleased to that bare-semver version and
an ISO date, then restores an empty Unreleased section; tag that commit. Do not
backfill releases from before this changelog.
-->
## [Unreleased]
## [0.3.4] - 2026-09-10
### Added
- Add Fenris identity and a polkit authentication notice to the dashboard.
- Clarify monitoring continuity, deliberate pauses, and quitting in the dashboard and status output.
- Add per-release notes with installation, verification, and rollback guidance.
+13 -10
View File
@@ -4,6 +4,7 @@
SHELL := /bin/bash
PYTHON := python3
VENV_DIR := /opt/fenris
VENDOR_DIR := $(VENV_DIR)/vendor
BIN_DIR := /usr/local/bin
LIBEXEC_DIR := /usr/libexec/fenris
UNIT_DIR := /etc/systemd/system
@@ -57,7 +58,7 @@ check-smartctl:
dist/fenris-*.whl: pyproject.toml src/fenris/*.py
@mkdir -p dist
$(PYTHON) -m build --wheel -o dist
$(PYTHON) -m pip wheel --no-deps --wheel-dir dist .
# ─── Install ────────────────────────────────────────────────────────────────
@@ -71,11 +72,10 @@ install: check-python check-smartctl dist/fenris-*.whl
@sudo groupadd -f fenris
@sudo install -d -o root -g fenris -m 2750 $(DATA_DIR)
@echo "=== Installing venv with pinned dependencies ==="
@echo "=== Installing version-neutral runtime packages ==="
@sudo rm -rf $(VENV_DIR)
@sudo $(PYTHON) -m venv $(VENV_DIR)
@sudo $(VENV_DIR)/bin/pip install --upgrade pip --quiet
@sudo $(VENV_DIR)/bin/pip install dist/fenris-*.whl --quiet
@sudo install -d -m 0755 $(VENDOR_DIR)
@sudo $(PYTHON) -m pip install --disable-pip-version-check --no-compile --target $(VENDOR_DIR) -r requirements.txt dist/fenris-*.whl --quiet
@echo "=== Installing wrapper ==="
@sudo install -m 0755 scripts/fenris $(BIN_DIR)/fenris
@@ -119,7 +119,7 @@ import-legacy:
@if [ -f "$(LEGACY_HISTORY)" ]; then \
echo "=== Detected legacy history: $(LEGACY_HISTORY) ==="; \
echo "Running idempotent import..."; \
$(VENV_DIR)/bin/python3 -c "import sys; sys.path.insert(0, 'src'); from fenris.legacy import import_legacy_history; from fenris.store import init_store; from pathlib import Path; conn = init_store(Path('$(DATA_DIR)/observations.db')); r = import_legacy_history(conn, Path('$(LEGACY_HISTORY)')); conn.close(); print(f' Samples imported: {r.get(\"samples_imported\", 0)}'); print(f' Hours imported: {r.get(\"hours_imported\", 0)}'); print(f' Malformed lines: {r.get(\"malformed_lines\", 0)}') if not r.get('skipped') else print(' Skipped: already imported')" || echo " Warning: import failed (non-fatal)"; \
PYTHONPATH=$(VENDOR_DIR) $(PYTHON) -c "from fenris.legacy import import_legacy_history; from fenris.store import init_store; from pathlib import Path; conn = init_store(Path('$(DATA_DIR)/observations.db')); r = import_legacy_history(conn, Path('$(LEGACY_HISTORY)')); conn.close(); print(f' Samples imported: {r.get(\"samples_imported\", 0)}'); print(f' Hours imported: {r.get(\"hours_imported\", 0)}'); print(f' Malformed lines: {r.get(\"malformed_lines\", 0)}') if not r.get('skipped') else print(' Skipped: already imported')" || echo " Warning: import failed (non-fatal)"; \
else \
echo "=== No legacy history found at $(LEGACY_HISTORY) ==="; \
fi
@@ -131,8 +131,10 @@ upgrade: dist/fenris-*.whl
@echo "=== Snapshotting database (IN-6) ==="
@sudo cp $(DATA_DIR)/observations.db $(DATA_DIR)/observations.db.bak 2>/dev/null || true
@echo "=== Installing new wheel with pinned dependencies ==="
@sudo $(VENV_DIR)/bin/pip install dist/fenris-*.whl --quiet
@echo "=== Installing new version-neutral runtime packages ==="
@sudo rm -rf $(VENDOR_DIR)
@sudo install -d -m 0755 $(VENDOR_DIR)
@sudo $(PYTHON) -m pip install --disable-pip-version-check --no-compile --target $(VENDOR_DIR) -r requirements.txt dist/fenris-*.whl --quiet
@echo "=== Syncing units against manifest ==="
@sudo install -m 0644 units/fenris-collect.timer $(UNIT_DIR)/
@@ -172,7 +174,7 @@ upgrade: dist/fenris-*.whl
done
@echo "=== Applying forward-only schema migrations (IN-5, IN-6) ==="
@sudo $(VENV_DIR)/bin/python3 -c "from fenris.store import migrate_to_latest; from pathlib import Path; n = migrate_to_latest(Path('$(DATA_DIR)/observations.db')); print(f' Migration steps applied: {n}') if n else print(' Schema already current')"
@sudo env PYTHONPATH=$(VENDOR_DIR) $(PYTHON) -c "from fenris.store import migrate_to_latest; from pathlib import Path; n = migrate_to_latest(Path('$(DATA_DIR)/observations.db')); print(f' Migration steps applied: {n}') if n else print(' Schema already current')"
@echo "=== Upgrade complete ==="
@@ -237,8 +239,9 @@ FENRIS_VERSION := $(shell sed -n 's/^version = "\(.*\)"/\1/p' pyproject.toml)
# GPG signing — packaging key UID (spec §4)
PACKAGING_KEY ?= packaging@bongbetic.com
stage: dist/fenris-*.whl
stage:
@echo "=== Staging packaging tree (v$(FENRIS_VERSION)) ==="
$(PYTHON) -m pip wheel --no-deps --wheel-dir dist .
bash packaging/stage.sh "$(FENRIS_VERSION)"
package-deb: stage
+25 -5
View File
@@ -42,7 +42,7 @@ sudo apt update && sudo apt install fenris
Replace `bookworm` with your distribution codename (`bookworm`, `jammy`, or
`noble`).
### Fedora (dnf)
### Fedora / openSUSE Tumbleweed (RPM)
Use the Fenris-owned repo file (not Gitea's auto-generated one):
@@ -53,6 +53,15 @@ sudo dnf config-manager --add-repo \
sudo dnf install fenris
```
On openSUSE Tumbleweed, add the same standard RPM repository file and install
with zypper:
```bash
sudo zypper addrepo --refresh \
https://git.bongbetic.com/xavierk/Fenris/raw/branch/main/packaging/fenris.repo fenris
sudo zypper install fenris
```
The repo file sets `gpgcheck=1` against the Fenris packaging key (downloaded
from the raw URL in `gpgkey`) and `repo_gpgcheck=0` (metadata check left to
TLS).
@@ -91,7 +100,8 @@ For contributors building from source:
sudo make install
```
This builds a wheel, installs it into `/opt/fenris` with pinned dependencies,
This builds a wheel, installs its locked pure-Python runtime packages into
`/opt/fenris/vendor`,
and places helpers, units, and the polkit policy. Units are dormant by default.
```bash
@@ -117,7 +127,7 @@ sudo make upgrade
What it does:
1. Snapshots `observations.db` to a one-generation backup (`.bak`).
2. Installs the new wheel into the same venv with pinned dependencies.
2. Replaces the locked runtime packages under `/opt/fenris/vendor`.
3. Syncs units and polkit against the manifest; runs `daemon-reload`.
4. Restarts the timer **only** if unit contents changed **and** it is active — a running collection run finishes on its mapped interpreter; the next run uses the new code.
5. Applies forward-only schema migrations (the store directory is never rebuilt; automatic downgrade does not exist).
@@ -149,7 +159,7 @@ make uninstall # removes artifacts, preserves config and observation history
make purge # also removes /etc/fenris and /var/lib/fenris
```
Uninstall performs the sanctioned disable first (`fenris-monitor disable --now`) — an open period closes `user_disabled` — then removes the venv, helpers, units, polkit policy, and wrapper while keeping `/etc/fenris` and the observation store. Reinstalling resumes from the preserved store.
Uninstall performs the sanctioned disable first (`fenris-monitor disable --now`) — an open period closes `user_disabled` — then removes the runtime packages, helpers, units, polkit policy, and wrapper while keeping `/etc/fenris` and the observation store. Reinstalling resumes from the preserved store.
## Cadence drop-ins
@@ -179,6 +189,16 @@ No interval key exists in `/etc/fenris/fenris.conf`. Cadence is a systemd concer
| `fenris start` / `stop` / `run` | Rejected with a one-line migration pointer — never aliased. |
| `fenris --device` | Rejected with a pointer to the configuration file. |
## Reading the dashboard
`fenris` opens the TUI dashboard.
- **Continuity** — the service strip's continuity line (and `fenris status`) reports whether monitoring survives reboots: `monitoring: active in background · persists across reboots`, or `monitoring: does not start on next boot`.
- **Paused vs. quit** — a full-width `monitoring: paused — deliberate disable` block means collection is stopped (`fenris monitor pause`); resume with `fenris monitor resume`. Pressing `q` only leaves the screen — monitoring keeps running in the background.
- **Auth banner** — at launch, `privileged actions will prompt for authentication (polkit)` shows once and clears on the first refresh. Privileged actions elevate via polkit; Fenris never asks for sudo.
Per-release notes live on the [releases page](https://git.bongbetic.com/xavierk/Fenris/releases): each entry is the version's `CHANGELOG.md` section — what was added, changed, and fixed — plus standing install and verification instructions.
## Retired menu options
The legacy `fenris.sh` menu script and the `fenris.py` monolith have been removed. Here's where the old options went:
@@ -212,7 +232,7 @@ Use a stable `/dev/disk/by-id/` path. Raw `/dev/nvmeX` paths are warned against.
| sysusers/tmpfiles | `/usr/lib/{sysusers,tmpfiles}.d/fenris.conf` | managed by Makefile |
| Configuration | `/etc/fenris/fenris.conf` | `/etc/fenris/fenris.conf` |
| Observation store | `/var/lib/fenris/observations.db` | `/var/lib/fenris/observations.db` |
| Venv | `/opt/fenris` | `/opt/fenris` |
| Runtime packages | `/opt/fenris/vendor` | `/opt/fenris/vendor` |
| Legacy history | — | `./data/history.jsonl` (auto-imported) |
---
@@ -6,7 +6,7 @@ Accepted — resolves [Task: Compose release spec + ADR amending 0004](https://g
## Context
ADR 0004 fixed delivery as `sudo make install` from a source checkout: wheel into a Fenris-owned venv at `/opt/fenris`, a hand-rolled placement manifest, units in `/etc/systemd/system`. The release plan ([map](https://git.bongbetic.com/xavierk/Fenris/issues/33), decisions [Lock channel + toolchain](https://git.bongbetic.com/xavierk/Fenris/issues/38), [Signing + key policy](https://git.bongbetic.com/xavierk/Fenris/issues/39), [Package ownership](https://git.bongbetic.com/xavierk/Fenris/issues/40), [Migration path](https://git.bongbetic.com/xavierk/Fenris/issues/41), [Release cadence](https://git.bongbetic.com/xavierk/Fenris/issues/43)) now ships Fenris as native deb + rpm packages built by nfpm and published to the self-hosted Gitea 1.27.1 package registry, for Debian 12, Ubuntu 22.04/24.04, and Fedora 40+ (x86_64), with dependencies vendored in a bundled venv because every target distro ships `python3-textual` below Fenris's floor. Packages become the primary delivery; ADR 0004's delivery model demotes to a dev fallback.
ADR 0004 fixed delivery as `sudo make install` from a source checkout: wheel into a Fenris-owned runtime directory at `/opt/fenris`, a hand-rolled placement manifest, units in `/etc/systemd/system`. The release plan ([map](https://git.bongbetic.com/xavierk/Fenris/issues/33), decisions [Lock channel + toolchain](https://git.bongbetic.com/xavierk/Fenris/issues/38), [Signing + key policy](https://git.bongbetic.com/xavierk/Fenris/issues/39), [Package ownership](https://git.bongbetic.com/xavierk/Fenris/issues/40), [Migration path](https://git.bongbetic.com/xavierk/Fenris/issues/41), [Release cadence](https://git.bongbetic.com/xavierk/Fenris/issues/43)) now ships Fenris as native deb + rpm packages built by nfpm and published to the self-hosted Gitea 1.27.1 package registry, for Debian 12, Ubuntu 22.04/24.04, Fedora 40+, and openSUSE Tumbleweed (x86_64), with locked pure-Python dependencies vendored at `/opt/fenris/vendor`. Packages become the primary delivery; ADR 0004's delivery model demotes to a dev fallback.
The implementation-ready operative contracts live in the [release and packaging specification](../spec/release-packaging.md); this ADR records the decisions and their rationale.
@@ -14,12 +14,12 @@ The implementation-ready operative contracts live in the [release and packaging
Amendments to ADR 0004, section by section:
1. **Delivery (amended).** Packages are primary: one deb per codename pool (`bookworm`, `jammy`, `noble`) and one rpm (group `fenris`, Fedora 40+), built by nfpm from a single `packaging/nfpm.yaml` over a staged `--copies` venv at `/opt/fenris`, published to the Gitea Debian/RPM registry and installed with `apt`/`dnf`. `sudo make install` remains as the dev fallback for machines without packages; the two deliveries are mutually exclusive per machine. Version scheme `<pyproject-version>-1`, revision bump on rebuild.
1. **Delivery (amended).** Packages are primary: one deb per codename pool (`bookworm`, `jammy`, `noble`) and one rpm (group `fenris`, Fedora 40+ and openSUSE Tumbleweed), built by nfpm from a single `packaging/nfpm.yaml` over locked pure-Python runtime packages staged at `/opt/fenris/vendor`, published to the Gitea Debian/RPM registry and installed with `apt`, `dnf`, or `zypper`. `sudo make install` remains as the dev fallback for machines without packages; the two deliveries are mutually exclusive per machine. Version scheme `<pyproject-version>-1`, revision bump on rebuild.
2. **Layout and manifest (amended).** The hand-rolled manifest model is retired: the dpkg/rpm database **is** the manifest, and nothing like `manifest.txt` ships. Package-owned layout: units in `/usr/lib/systemd/system` (vendor placement; `/etc/systemd/system` is admin-only for drop-ins and enable state); helpers stay in `/usr/libexec/fenris` (exactly `fenris-monitor` and `fenris-collect` — no new polkit-reachable binaries); polkit policy in `/usr/share/polkit-1/actions/`; wrapper at `/usr/bin/fenris` (FHS; `/usr/local/bin` remains `make install`'s). The `fenris` group is declared in `/usr/lib/sysusers.d/fenris.conf` (`g fenris -`) and `/var/lib/fenris` in `/usr/lib/tmpfiles.d/fenris.conf` (`d /var/lib/fenris 2750 root fenris -`), both invoked from the maintainer scripts. The package owns the `/var/lib/fenris` directory only; `observations.db`, WAL sidecars, and `.bak` are never owned and never ghosted — ghost-erase would delete the store, violating 0004 §8.
3. **Privilege (unchanged).** Root acts through maintainer scripts at install/upgrade/removal time; at runtime, elevation is exclusively polkit, exactly as 0004 §3 and [ADR 0003](0003-service-lifecycle-and-sanctioned-toggle.md) §5 fix it.
4. **Dormant install (restated for packages).** A fresh package install is fully dormant: postinst/%post performs `systemctl daemon-reload` (plus `systemd-sysusers` and `systemd-tmpfiles --create`) and nothing else — never enable, never preset, never start; no preset file ships. The sanctioned toggle (`fenris monitor resume`) remains the only opt-in.
5. **Legacy import (narrowed).** Auto-detection of `./data/history.jsonl` is scoped to `make install` only — a package install has no checkout to inspect. `fenris import <path>` remains available as the only import path from packages.
6. **Upgrade (inherited, maintainer-script mechanics).** Upgrades arrive as packages from the single registry channel. postinst/%post on upgrade: snapshot `observations.db` → one-generation `.bak`, run forward-only schema migrations via inline `/opt/fenris/bin/python3 -c "…migrate_to_latest…"` (no new binaries), `daemon-reload`, and restart `fenris-collect.timer` only if unit contents changed **and** it is active. `/var/lib/fenris` is never rebuilt; a running oneshot finishes on its old interpreter.
6. **Upgrade (inherited, maintainer-script mechanics).** Upgrades arrive as packages from the single registry channel. postinst/%post on upgrade: snapshot `observations.db` → one-generation `.bak`, run forward-only schema migrations through the target `python3` with `/opt/fenris/vendor` on its import path (no new binaries), `daemon-reload`, and restart `fenris-collect.timer` only if unit contents changed **and** it is active. `/var/lib/fenris` is never rebuilt; a running oneshot finishes on its old interpreter.
7. **Rollback (unchanged, plus one hard edge).** One-generation `.bak` semantics are unchanged. Package downgrade is additionally unsupported: forward-only store-version refusal means installing an older package over a newer store fails by design; documented rollback = restore the snapshot, then install the old release.
8. **Removal (mapped).** deb `remove` ≈ `make uninstall` (conffile and store survive); deb `purge` ≈ `make purge` (plus `.bak` and group cleanup); rpm erase ≈ `make uninstall` (unmodified config removed, modified survives as `.rpmsave`; purge is a documented manual command). prerm/%preun performs the sanctioned disable — `fenris-monitor disable --now`, closing the period `user_disabled` — on remove/erase **only, never on upgrade** (deb prerm upgrade case is a no-op; rpm `%preun` gated on `$1 -eq 0`).
9. **Conffile semantics (new).** `/etc/fenris/fenris.conf` ships as a placeholder-commented default with no active device selector — deb conffile, rpm `%config(noreplace)`. The device selector is entered by hand (root edits the file), as in both prior deliveries; no configuration verb is added to `fenris-monitor`, and [ADR 0003](0003-service-lifecycle-and-sanctioned-toggle.md) §3's read-and-validate-at-collection-time semantics are untouched. On upgrade, local edits survive as-is; a changed package default lands beside them as `.dpkg-new`/`.rpmnew`.
+14 -1
View File
@@ -91,7 +91,7 @@ Status: Accepted — resolves [Define cross-cutting acceptance criteria](https:/
- **TUI-1** (A) Variant A "Panes": one dense keyboard-first screen; confidence rendered as evidence (state + contributing facts); boot enablement, runtime activity, last collect outcome, and freshness displayed as four separate facts.
- **TUI-2** (M) Pause/resume asymmetry and polkit tty passthrough work in a live terminal: pause confirms, resume does not, and the platform agent prompts without breaking the TUI.
- **TUI-3** (P) Textual runs on Python 3.9+, gated at install time, never a runtime crash.
- **TUI-4** (A) The Panes screen layout is normative: a full-width headline band (lifespan headline or its no-projection wording, confidence state with contributing facts, scenario range); a usage-history pane on the left (write-history sparkline with ▲ habit-change and ? unexplained-gap markers plus legend, habit-split bar with active/idle/powered-off/unknown shares); a drive-health and settings pane on the right (health facts, vendor-wear context line, read-only settings with the endurance baseline and its provenance label); a full-width service strip at the bottom (the four separate service facts, the monitoring-period line, the action legend). Production bindings are `p` pause (asks), `r` resume (does not), `c` collect now, `d` disclosures, `q` quit ([Prototype the TUI information architecture](https://git.bongbetic.com/xavierk/Fenris/issues/3)); the prototype branch is visual reference only.
- **TUI-4** (A) The Panes screen layout is normative: a full-width headline band (lifespan headline or its no-projection wording, confidence state with contributing facts, scenario range); a usage-history pane on the left (write-history sparkline with ▲ habit-change and ? unexplained-gap markers plus legend, habit-split bar with active/idle/powered-off/unknown shares); a drive-health and settings pane on the right (health facts, vendor-wear context line, read-only settings with the endurance baseline and its provenance label); a full-width service strip at the bottom (the four separate service facts, the monitoring-period line, the action legend). Production bindings are the footer `p pause · r resume · c collect · d disclosures` — pause asks, resume does not — plus a bordered quit rail `q QUIT TUI` visually separate from monitoring state; the rail owns quit and the footer carries no quit entry (bindings amended by [Lock the dashboard wording strings](https://git.bongbetic.com/xavierk/Fenris/issues/57); original [Prototype the TUI information architecture](https://git.bongbetic.com/xavierk/Fenris/issues/3)); the prototype branch is visual reference only.
## Failure and recovery (ADR 0005)
@@ -131,3 +131,16 @@ Status: Accepted — resolves [Define cross-cutting acceptance criteria](https:/
- **AC-3** (A) Any acquisition failure — missing binary, nonzero exit, malformed JSON, unreadable sysfs attribute — fails the whole collection run; no partial sample (identity without counters, or counters without identity) is ever written; the miss surfaces through ADR 0005 freshness, never as degraded identity.
- **AC-4** (P) `vid`/`ssvid` are read from the PCI sysfs node when present and stored null otherwise; they are segment metadata only, never key components.
- **AC-5** (P) `make install` verifies `smartctl` and fails cleanly otherwise; the acquisition path adds no Python dependency and no OS package beyond smartmontools (ADR 0004 §9).
## Dashboard clarity and release notes ([Chart Fenris dashboard clarity](https://git.bongbetic.com/xavierk/Fenris/issues/55))
Decided in [Write the dashboard clarity acceptance criteria](https://git.bongbetic.com/xavierk/Fenris/issues/59), from [Prototype the dashboard clarity additions](https://git.bongbetic.com/xavierk/Fenris/issues/56), [Lock the dashboard wording strings](https://git.bongbetic.com/xavierk/Fenris/issues/57), and [Specify the changelog and release-notes mechanism](https://git.bongbetic.com/xavierk/Fenris/issues/58).
- **DC-1** (A) TUI branding: the header bar renders `Fenris — NVMe endurance monitor`; a dimmed `by Bongbetic` sits inline with service facts in the bottom service strip; neither string appears in `fenris status` (TUI-only identity surfaces).
- **DC-2** (A) Continuity parity, keyed to the boot fact as-is: active + boot-enabled renders `monitoring: active in background · persists across reboots`; boot-disabled renders `monitoring: does not start on next boot` — identical lowercase source strings in the TUI service strip and `fenris status`, including while paused (paused implies boot-disabled; the row still reports the fact). Test impact: feeds the CI-2 sweep (lowercase source-string comparison).
- **DC-3** (A) Paused presentation (Deliberate disable): the TUI shows a strong state block titled `monitoring: paused — deliberate disable` with subline `paused time is excluded from your usage habit · resume: fenris monitor resume`; `fenris status` prints the same two lines with identical wording. Test impact: feeds the CI-2 sweep (lowercase source-string comparison).
- **DC-4** (A) Quit affordance distinct from monitoring state: a bordered labelled rail `q QUIT TUI` visually separate from the paused state block; the footer reads `p pause · r resume · c collect · d disclosures` with no quit entry (the rail owns quit); quitting the TUI never alters monitoring state. Amends TUI-4's binding parenthetical.
- **DC-5** (A) Launch auth banner: `privileged actions will prompt for authentication (polkit)` renders full-width under the header at TUI launch, clears on the first refresh tick, and never reappears in the session; no user-facing string uses "sudo" (polkit-accurate elevation wording only).
- **DC-6** (A) CHANGELOG.md shape (Keep a Changelog 1.1): `## [Unreleased]` always present at top, even empty; version headings `## [X.Y.Z] - YYYY-MM-DD` with strict ISO date; categories Added/Changed/Fixed only, security folding into Fixed; entries are single `- ` bullets, imperative mood, user-facing, no commit hashes or issue numbers.
- **DC-7** (A) Extraction fails closed: `scripts/extract_changelog.py` slices the requested version's section verbatim and never reads `[Unreleased]`; a missing or empty section or a malformed date produces `::error::` and a nonzero exit; the release workflow fails when the pushed tag ≠ `v{version from pyproject.toml}` (guard skipped on `workflow_dispatch`).
- **DC-8** (A/P) Release body: the body is the extracted section verbatim plus the standing footer from `packaging/release-footer.md`; a re-run against an existing release PATCHes the body (re-sync is a feature) while uploaded assets skip idempotently. A covers assembly/PATCH-logic unit tests; P is one scripted `workflow_dispatch` verification of body assembly.
+122
View File
@@ -0,0 +1,122 @@
# Fenris dashboard clarity specification
**Status: decision-complete.** Assembled by [Assemble the dashboard clarity specification and close the map](https://git.bongbetic.com/xavierk/Fenris/issues/60) from the closed tickets of the Wayfinder map [Chart Fenris dashboard clarity](https://git.bongbetic.com/xavierk/Fenris/issues/55). This document is normative for the follow-up **execution effort**; nothing here is implemented by the map.
**Canonical roles.** The [redesign specification](fenris-redesign.md) (frozen) and [ADRs 0001–0007](../adr/) remain authoritative and untouched — this is a companion spec covering five dashboard clarity additions plus the changelog-driven release-notes mechanism. The [criteria register](acceptance-criteria.md) carries the testable statements: **DC-1–DC-8**, appended by this assembly, with **TUI-4's binding list amended** (§4). Terminology follows the glossary in [`CONTEXT.md`](../../CONTEXT.md), including *Deliberate disable* and *Release*.
**Binding language.** *Must*, *exactly*, and *never* are normative.
## How to read this document
Five screen additions (§1–§5), one release-notes mechanism (§6), the verbatim string register (§7), and the README section to add at execution (§8). Each section cites its criteria. Source strings are lowercase; the TUI may render uppercase via styling only. Typography, governing every string: em-dash `—` separates a title from its qualifier; middle dot `·` joins facts within a line; UTF-8 is assumed. CI parity sweeps compare lowercase source strings — rendering case is styling, not wording.
## 1. Header bar and Bongbetic credit — DC-1
Visual base is treatment A, quiet integration: the existing Panes information architecture is preserved.
- The header bar reads `Fenris — NVMe endurance monitor`.
- The credit `by Bongbetic` renders dimmed, inline with service facts in the bottom service strip — never in the action row.
- Both are TUI-only identity surfaces: `fenris status` never renders them.
## 2. Continuity line — DC-2
A labelled `CONTINUITY` row in the service strip (treatment B), mirrored by `fenris status` — the TUI/CLI parity anchor. The row is keyed to the boot fact as-is, independently of run state (Deliberate disable runs `systemctl disable --now`, so paused implies boot-disabled; the row still reports the fact):
- Active + boot enabled: `monitoring: active in background · persists across reboots`
- Boot disabled: `monitoring: does not start on next boot`
Identical lowercase source strings in the TUI service strip and `fenris status`, including while paused.
## 3. Paused state block — DC-3
Treatment C, strong state blocks: when monitoring is paused, a full-width, high-contrast banner clearly identifying Deliberate disable:
- Title: `monitoring: paused — deliberate disable`
- Subline: `paused time is excluded from your usage habit · resume: fenris monitor resume`
`fenris status` prints the same two lines with identical wording (state line + consequence line). The resume hint uses the CLI form only; the footer owns key hints — no duplication.
## 4. Quit rail — DC-4 (amends TUI-4)
Treatment B, labelled rails: a prominent bordered `q QUIT TUI` rail, visually separate from the monitoring-state block and the paused banner. The footer becomes `p pause · r resume · c collect · d disclosures` — the rail owns quit; the footer carries no quit entry. Quitting the TUI never alters monitoring state. The register's TUI-4 binding parenthetical is amended accordingly by this assembly.
## 5. Launch auth banner — DC-5
A quiet informational line (treatment A) that never competes with drive state:
- Text: `privileged actions will prompt for authentication (polkit)`
- Full-width under the header at TUI launch; clears on the first refresh tick; never reappears in the session.
- TUI-only; `fenris status` never shows it.
- Elevation wording is polkit-accurate everywhere: no user-facing string uses "sudo" (sudo belongs to install/upgrade docs).
- Evidence class A: a Textual pilot drives refresh ticks headlessly.
## 6. Changelog and release notes — DC-6, DC-7, DC-8
Implements the existing glossary term *Release* (tag + packages + change notes together). No new glossary terms; no ADR (reversible mechanism).
### 6.1 CHANGELOG.md (source of truth, repo root)
- Keep a Changelog 1.1 shape. `## [Unreleased]` is always present at top, even empty. Version headings are `## [X.Y.Z] - YYYY-MM-DD` — bracketed bare semver, strict ISO date.
- Categories are `### Added`, `### Changed`, `### Fixed` only; security fixes fold into Fixed.
- Entries are single `- ` bullets, imperative mood, user-facing phrasing; no commit hashes or issue numbers.
### 6.2 Extraction (release.yml, tag time)
- `scripts/extract_changelog.py` (checked in, unit-tested): takes the changelog path and a version; slices that version's section verbatim; never reads `[Unreleased]`. Fails closed — `::error::` plus nonzero exit — when the section is missing or empty or the date is malformed.
- Guard: the workflow fails when the pushed tag ≠ `v{version from pyproject.toml}` (guard skipped on `workflow_dispatch`).
### 6.3 Release body
- Body = extracted version section verbatim + standing footer from `packaging/release-footer.md` (channel install one-liners, `sha256sum -c SHA256SUMS.asc` verify, rollback pointer). The footer is standing text; only the changelog section varies.
- Re-run against an existing release: PATCH the body (changelog re-sync is a feature); uploaded assets/packages keep their current idempotent-skip.
### 6.4 Discipline
- All entries land in `[Unreleased]` as part of the fixing change — no notes-later step.
- One release commit bumps the pyproject version, renames `[Unreleased]` → the version heading, and restores an empty `[Unreleased]`; the tag points at that commit (tag ↔ pyproject ↔ changelog triple-match, enforced fail-closed by DC-7).
- No backfill: per-release notes begin with the release shipping this mechanism; `CHANGELOG.md` starts with empty `[Unreleased]`.
## 7. String register (verbatim)
### TUI-only strings (launch/identity surfaces)
| Surface | String |
|---|---|
| Header bar | `Fenris — NVMe endurance monitor` |
| Credit (dimmed, inline with service facts) | `by Bongbetic` |
| Auth banner (full-width under header at launch, clears on first refresh tick, never reappears) | `privileged actions will prompt for authentication (polkit)` |
| Quit rail (bordered, labelled) | `q QUIT TUI` |
| Footer (owns key hints; no quit entry) | `p pause · r resume · c collect · d disclosures` |
### Parity strings (TUI and `fenris status` identical — CI-2)
| Surface | String |
|---|---|
| Continuity, active + boot enabled | `monitoring: active in background · persists across reboots` |
| Continuity, boot disabled | `monitoring: does not start on next boot` |
| Paused state line | `monitoring: paused — deliberate disable` |
| Paused consequence line | `paused time is excluded from your usage habit · resume: fenris monitor resume` |
`fenris status` prints the paused state line + consequence line when paused, identical wording to the banner title + subline.
## 8. README section (add at execution)
The README gains a "Reading the dashboard" section after the CLI reference. Verbatim text:
```markdown
## Reading the dashboard
`fenris` opens the TUI dashboard. Three things it tells you:
- **Continuity** — the service strip's continuity line (and `fenris status`) reports whether monitoring survives reboots: `monitoring: active in background · persists across reboots`, or `monitoring: does not start on next boot`.
- **Paused vs. quit** — a full-width `monitoring: paused — deliberate disable` block means collection is stopped (`fenris monitor pause`); resume with `fenris monitor resume`. Pressing `q` only leaves the screen — monitoring keeps running in the background.
- **Auth banner** — at launch, `privileged actions will prompt for authentication (polkit)` shows once and clears on the first refresh. Privileged actions elevate via polkit; Fenris never asks for sudo.
Per-release notes live on the [releases page](https://git.bongbetic.com/xavierk/Fenris/releases): each entry is the version's `CHANGELOG.md` section — what was added, changed, and fixed — plus standing install and verification instructions.
```
This resolves the map's README-wording fog: the wording is decided here; the actual README edit is execution.
## 9. Out of scope
Executing any of this — code, tests, releases — and any TUI layout or information-architecture redesign beyond the five additions named above. Execution is a fresh effort after handoff.
+6 -5
View File
@@ -14,11 +14,12 @@
| Ubuntu 22.04 (jammy) | — | deb | `debian/pool/jammy/main` |
| Ubuntu 24.04 (noble) | — | deb | `debian/pool/noble/main` |
| Fedora 40+ | every release | rpm | `rpm/fenris` group |
| openSUSE Tumbleweed | rolling | rpm | `rpm/fenris` group |
- Architecture: **x86_64 only** (arm64 only if real ARM hardware appears — map fog).
- Dependencies are vendored in a bundled venv for every target: Debian 12 and Ubuntu 22.04/24.04 ship `python3-textual` 0.1.13, far below the floor; Fedora 40+ ships ≥ 0.48 but below the pin ([toolchain research](../research/deb-rpm-toolchain.md)). No distro `python3-textual` dependency ever enters package metadata.
- Dependencies are vendored as locked, pure-Python runtime packages for every target: Debian 12 and Ubuntu 22.04/24.04 ship `python3-textual` 0.1.13, far below the floor; Fedora 40+ ships ≥ 0.48 but below the pin ([toolchain research](../research/deb-rpm-toolchain.md)). No distro `python3-textual` dependency ever enters package metadata.
- Package metadata `depends:`/`Requires:` are exactly `python3 (>= 3.10)`, `smartmontools`, `systemd` — the Python floor is 3.10 (oldest supported distro interpreter, Ubuntu 22.04), bumping ADR 0004 §10's 3.9 gate for packages; `make install` keeps the checkout's floor.
- The bundled venv is staged with `python3 -m venv --copies` at `/opt/fenris`: shebangs point at the fixed absolute `/opt/fenris/bin/python`, and the stdlib still comes from the host interpreter, which is why `python3 (>= 3.10)` is a hard dependency.
- Runtime packages are staged with `python3 -m pip --target /opt/fenris/vendor`; entry points run the target system's `python3` with that directory on the import path. No package ships a copied Python interpreter, avoiding build-host ABI paths and rolling-distribution minor-version breakage.
## 2. Distribution channel
@@ -33,7 +34,7 @@
## 3. Build toolchain
- **nfpm** for both formats from a single `packaging/nfpm.yaml` — one config, `overrides:` for per-format deltas, two invocations (`nfpm pkg -p deb`, `nfpm pkg -p rpm`). fpm is dropped entirely (CLI-flag config drifts); no hand rpm spec; dh-virtualenv is deb-only and dormant since 2020.
- **Single source of truth:** version injected from `pyproject.toml`; file lists generated by a staging script (venv `--copies` → `/opt/fenris` tree, plus wrapper, helpers, units, polkit policy, sysusers/tmpfiles fragments) referenced by `nfpm.yaml` as a `type: tree` content entry — no hand-maintained file lists.
- **Single source of truth:** version injected from `pyproject.toml`; file lists generated by a staging script (locked runtime packages → `/opt/fenris/vendor`, plus wrapper, helpers, units, polkit policy, sysusers/tmpfiles fragments) referenced by `nfpm.yaml` as a `type: tree` content entry — no hand-maintained file lists.
- **Entry point:** `make package` → `dist/fenris_<v>_amd64.deb` + `dist/fenris-<v>-1.x86_64.rpm`.
- **Version scheme:** `<pyproject-version>-1` in both formats; a rebuild of the same upstream version bumps the revision (`-2`, `-3`, …) — the same filename is never re-PUT (registry 409s duplicates).
- **Authoring `nfpm.yaml`, the staging script, and the workflow file is execution** — deliberately not part of the decision map. The [toolchain research doc](../research/deb-rpm-toolchain.md) sketches the pipeline.
@@ -62,7 +63,7 @@ Per [ADR 0007](../adr/0007-package-delivery-amends-0004.md) §2 — the dpkg/rpm
| Artifact | Location | Ownership |
|---|---|---|
| Bundled venv | `/opt/fenris` | package (tree) |
| Bundled runtime packages | `/opt/fenris/vendor` | package (tree) |
| Wrapper | `/usr/bin/fenris` | package |
| Helpers | `/usr/libexec/fenris/{fenris-monitor,fenris-collect}` | package — exactly these two, no new polkit-reachable binaries |
| Units | `/usr/lib/systemd/system/fenris-collect.{timer,service}` | package (vendor placement; `/etc/systemd/system` is admin-only) |
@@ -76,7 +77,7 @@ Per [ADR 0007](../adr/0007-package-delivery-amends-0004.md) §2 — the dpkg/rpm
- **preinst / %pre:** abort with a pointer to the migration runbook (§9) if `/var/lib/fenris/manifest.txt` **or** `/etc/systemd/system/fenris-collect.timer` exists (dual marker covers pre-manifest make installs). No auto-clean — scripts never delete files outside the package DB.
- **postinst / %post (install):** `systemd-sysusers`, `systemd-tmpfiles --create`, `systemctl daemon-reload`. Nothing else — no enable, no preset, no start; no preset file ships.
- **postinst / %post (upgrade):** snapshot `observations.db` → `.bak` (one generation) → forward-only schema migration via inline `/opt/fenris/bin/python3 -c "…migrate_to_latest…"` → `daemon-reload` → restart `fenris-collect.timer` only if unit contents changed **and** it is active. `/var/lib/fenris` is never rebuilt; an in-flight oneshot finishes on its old interpreter.
- **postinst / %post (upgrade):** snapshot `observations.db` → `.bak` (one generation) → forward-only schema migration via target `python3` with `/opt/fenris/vendor` on its import path → `daemon-reload` → restart `fenris-collect.timer` only if unit contents changed **and** it is active. `/var/lib/fenris` is never rebuilt; an in-flight oneshot finishes on its old interpreter.
- **prerm / %preun:** sanctioned disable (`fenris-monitor disable --now`, closing the monitoring period `user_disabled`) on remove/erase **only, never on upgrade** — deb prerm upgrade case is a no-op; rpm `%preun` gated on `$1 -eq 0`.
- **Removal mapping:** deb `remove` ≈ `make uninstall` (conffile + store survive); deb `purge` ≈ `make purge` (+ `.bak`, group cleanup); rpm erase ≈ `make uninstall` (unmodified config removed, modified survives as `.rpmsave`); rpm purge = documented manual command.
+6 -1
View File
@@ -6,6 +6,11 @@
# The device selector specifies which NVMe drive to monitor.
# Uncomment and set exactly one device path:
#
# devices = /dev/disk/by-id/nvme-Samsung_SSD_980_PRO_500GB_S5PANS0T123456
# device = /dev/disk/by-id/nvme-Samsung_SSD_980_PRO_500GB_S5PANS0T123456
#
# The observation store path is optional and defaults to
# /var/lib/fenris/observations.db when unset:
#
# store_path = /var/lib/fenris/observations.db
#
# See https://git.bongbetic.com/xavierk/Fenris for documentation.
+1 -1
View File
@@ -15,7 +15,7 @@ depends:
- systemd
contents:
# Staged tree: venv, wrapper, helpers, units, polkit, sysusers, tmpfiles
# Staged tree: runtime packages, wrapper, helpers, units, polkit, sysusers, tmpfiles
- src: build/stage/
dst: /
type: tree
+4 -3
View File
@@ -9,7 +9,8 @@ set -eu
STORE_DIR="/var/lib/fenris"
STORE_DB="${STORE_DIR}/observations.db"
STORE_BAK="${STORE_DIR}/observations.db.bak"
VENV_PYTHON="/opt/fenris/bin/python3"
RUNTIME_PYTHON="/usr/bin/python3"
VENDOR_DIR="/opt/fenris/vendor"
case "${1:-}" in
configure)
@@ -18,8 +19,8 @@ case "${1:-}" in
if [ -f "${STORE_DB}" ]; then
cp "${STORE_DB}" "${STORE_BAK}" 2>/dev/null || true
fi
if [ -x "${VENV_PYTHON}" ] && [ -f "${STORE_DB}" ]; then
"${VENV_PYTHON}" -c "
if [ -d "${VENDOR_DIR}" ] && [ -f "${STORE_DB}" ]; then
PYTHONPATH="${VENDOR_DIR}" "${RUNTIME_PYTHON}" -c "
from fenris.store import migrate_to_latest
from pathlib import Path
n = migrate_to_latest(Path('${STORE_DB}'))
+20
View File
@@ -0,0 +1,20 @@
## Install
Install Fenris from its package channel after following the [package setup instructions](https://git.bongbetic.com/xavierk/Fenris/src/branch/main/README.md#install-from-package-recommended):
```bash
sudo apt update && sudo apt install fenris # Debian / Ubuntu
sudo dnf install fenris # Fedora
sudo zypper install fenris # openSUSE Tumbleweed
```
## Verify downloads
```bash
gpg --output SHA256SUMS --decrypt SHA256SUMS.asc
sha256sum -c SHA256SUMS
```
## Rollback
Installing an older package over a newer observation store is unsupported. Restore the observation-store snapshot, then install the earlier Release; see the [upgrade and rollback guidance](https://git.bongbetic.com/xavierk/Fenris/src/branch/main/README.md#upgrade).
+6 -3
View File
@@ -5,7 +5,8 @@ set -eu
STORE_DIR="/var/lib/fenris"
STORE_DB="${STORE_DIR}/observations.db"
STORE_BAK="${STORE_DIR}/observations.db.bak"
VENV_PYTHON="/opt/fenris/bin/python3"
RUNTIME_PYTHON="/usr/bin/python3"
VENDOR_DIR="/opt/fenris/vendor"
if [ "$1" -eq 1 ]; then
# Fresh install
@@ -17,8 +18,8 @@ elif [ "$1" -ge 2 ]; then
if [ -f "${STORE_DB}" ]; then
cp "${STORE_DB}" "${STORE_BAK}" 2>/dev/null || true
fi
if [ -x "${VENV_PYTHON}" ] && [ -f "${STORE_DB}" ]; then
"${VENV_PYTHON}" -c "
if [ -d "${VENDOR_DIR}" ] && [ -f "${STORE_DB}" ]; then
PYTHONPATH="${VENDOR_DIR}" "${RUNTIME_PYTHON}" -c "
from fenris.store import migrate_to_latest
from pathlib import Path
n = migrate_to_latest(Path('${STORE_DB}'))
@@ -43,4 +44,6 @@ print(f'Fenris migration: {n} step(s) applied') if n else None
fi
rm -f "${OLD_CONTENT}"
done
# Re-apply placement modes (store dir group access, issue #54)
systemd-tmpfiles --create || true
fi
+12 -7
View File
@@ -5,7 +5,7 @@
#
# VERSION defaults to the version in pyproject.toml.
# The staged tree contains:
# /opt/fenris/ — bundled venv with the built wheel
# /opt/fenris/vendor/ — bundled pure-Python application dependencies
# /usr/bin/fenris — unprivileged wrapper
# /usr/libexec/fenris/ — fenris-monitor, fenris-collect
# /usr/lib/systemd/system/ — fenris-collect.{timer,service}
@@ -35,18 +35,23 @@ rm -rf "${STAGE_DIR}"
mkdir -p "${STAGE_DIR}"
# --- Use pre-built wheel from dist/ ---
WHEEL=$(ls "${REPO_ROOT}"/dist/fenris-*.whl 2>/dev/null | head -1)
WHEEL=$(ls "${REPO_ROOT}"/dist/fenris-"${VERSION}"-*.whl 2>/dev/null | head -1)
if [ -z "${WHEEL}" ]; then
echo "Error: no wheel found in dist/ — run 'make dist/fenris-*.whl' first" >&2
exit 1
fi
echo " Using wheel: $(basename "${WHEEL}")"
# --- Create venv with --copies and install wheel ---
echo " Creating bundled venv ..."
python3 -m venv --copies "${STAGE_DIR}/opt/fenris"
"${STAGE_DIR}/opt/fenris/bin/pip" install --upgrade pip --quiet 2>&1 | tail -1
"${STAGE_DIR}/opt/fenris/bin/pip" install "${WHEEL}" --quiet 2>&1 | tail -1
# --- Vendor runtime packages without an interpreter ---
# A copied Python binary contains an ABI and build-host dynamic-library path.
# It fails after rolling-distribution Python upgrades (for example Tumbleweed
# 3.12 -> 3.13). Fenris and its locked dependencies are pure Python, so place
# them in a version-neutral directory and execute with the target's python3.
echo " Installing version-neutral runtime packages ..."
VENDOR_DIR="${STAGE_DIR}/opt/fenris/vendor"
mkdir -p "${VENDOR_DIR}"
python3 -m pip install --disable-pip-version-check --no-compile \
--target "${VENDOR_DIR}" -r "${REPO_ROOT}/requirements.txt" "${WHEEL}"
# --- Inject version into wrapper from pyproject.toml ---
# The wrapper has a hardcoded version string; patch it for packaging.
+1 -1
View File
@@ -1,2 +1,2 @@
# Type Path Mode User Group Age Argument
d /var/lib/fenris 2750 root fenris - -
d /var/lib/fenris 2770 root fenris - -
@@ -0,0 +1,67 @@
# PROTOTYPE — titlebox, drive health and colour presets (throwaway)
Answers wayfinder ticket **Approve titlebox, health layout and colour presets**
on map **Fenris TUI polish and hourly history**.
Not production code. Do not merge onto main.
## Question
What concrete layout and preset contract makes Fenris feel polished while
preserving monitoring clarity and accessibility?
## Run
```sh
./run
# then open http://127.0.0.1:8765/prototype/titlebox-health-presets/
```
Or open `index.html` directly.
## What this prototype asks the human to react to
Use the bottom control bar to switch:
- preset: **Amber**, **Nord**, **High Contrast**;
- status: Monitoring, Collecting, Paused, Waiting, Interrupted, Error, Stale,
Unknown;
- terminal size: 80×24 baseline, 140×40 wide, or a constrained/narrow layout;
- title glyph: wolf emoji or fallback text;
- motion: normal blink or reduced motion.
## Contract under test
1. **Title hierarchy** — the only maker-credit surface is the top titlebox,
`🐺 Fenris by Bongbetic`; the old service-strip credit is removed. The
lifespan headline stays a product data surface, not the app title.
2. **Glyph fallback** — when the wolf is unsupported, explicitly fall back to
`Fenris by Bongbetic` rather than rendering tofu or disturbing border width.
3. **Drive health vs settings** — vendor wear lives in **Drive health** beside
temperature/spare/error facts. **Settings** stays read-only for device,
baseline, retention and display preferences.
4. **Palette roles** — themes style chrome, graph and accent colours; status
semantics (green/amber/red/grey plus glyph+text) outrank the theme and are
never the sole carrier.
5. **Display preferences** — preset and reduced-motion preferences are scoped
to the unprivileged TUI user, not `/etc/fenris/fenris.conf` and not the
observation store. Proposed persistence: `${XDG_CONFIG_HOME:-~/.config}/fenris/tui.json`.
6. **Reduced motion** — normal Monitoring blinks only the dot at the settled
750 ms cadence; reduced motion renders the same `● Monitoring` state steady.
7. **Constrained terminal** — below 80×24, the graph region hides and leaves a
one-line textual summary plus `graph needs ≥80×24`; status, title, drive
health and action/quit affordances stay visible.
## Framework facts verified
Current Textual docs via Context7 library `/websites/textual_textualize_io`:
- `App.register_theme(theme)` registers a theme; setting `App.theme` activates
it. Textual themes generate CSS variables from base colours.
- `$text`, `$text-muted`, `$text-disabled` and `color: auto` exist to maintain
text legibility against theme backgrounds.
- Mouse events expose coordinates relative to the screen or widget; focusable
widgets can be resolved at coordinates, and Pilot can click widgets/offsets
for automated proof of keyboard+mouse paths.
Local repo lockfile still pins `textual==8.2.8`; this prototype is static HTML
so it deliberately does not depend on the Python environment.
@@ -0,0 +1,89 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Fenris titlebox, health layout and presets prototype</title>
<style>
:root{color-scheme:dark;font-family:ui-monospace,SFMono-Regular,Consolas,Menlo,monospace}
*{box-sizing:border-box}body{margin:0;background:#06070a;color:var(--text);font:14px/1.35 ui-monospace,SFMono-Regular,Consolas,Menlo,monospace}.note{max-width:1180px;margin:18px auto 8px;padding:0 16px;color:#c0c6d0}.terminal{width:min(var(--term-w),calc(100vw - 32px));min-height:var(--term-h);margin:0 auto 108px;background:var(--bg);border:1px solid var(--line);box-shadow:0 24px 80px #000;border-radius:8px;overflow:hidden}.screen{padding:8px}.titlebox{height:38px;border:2px solid var(--accent);display:flex;align-items:center;justify-content:center;font-weight:900;font-size:17px;letter-spacing:.01em;color:var(--title);background:linear-gradient(180deg,var(--title-bg),transparent)}.titlebox .fallback{display:none}body[data-glyph=plain] .titlebox .emoji{display:none}body[data-glyph=plain] .titlebox .fallback{display:inline}.auth{margin-top:6px;color:var(--muted);border-left:4px solid var(--accent);padding:4px 8px;background:var(--panel-soft)}.grid{display:grid;grid-template-columns:3fr 2fr;gap:8px;margin-top:8px}.pane{border:1px solid var(--line);background:var(--panel);padding:9px 11px;min-height:130px}.headline{grid-column:1/-1;min-height:92px}.pane-title{color:var(--muted);margin:-19px 0 6px -2px;background:var(--panel);width:max-content;padding:0 6px}.big{font-size:18px;font-weight:900}.muted{color:var(--muted)}.accent{color:var(--accent)}.status-line{display:flex;gap:12px;align-items:center;justify-content:space-between;flex-wrap:wrap;margin-top:5px}.state{font-weight:900}.state .glyph{display:inline-block;width:2ch;text-align:center}.state.monitoring{color:var(--ok)}body:not(.reduced) .state.monitoring .glyph{animation:blink 1.5s steps(1,end) infinite}.state.collecting{color:var(--ok)}.state.paused,.state.waiting{color:var(--warn)}.state.interrupted,.state.error,.state.stale{color:var(--bad)}.state.unknown{color:var(--neutral)}@keyframes blink{50%{opacity:.18}}.pause-banner{display:none;margin-top:8px;border:2px solid var(--warn);background:var(--warn-bg);color:var(--warn-text);padding:7px 10px;font-weight:900}body[data-state=paused] .pause-banner{display:block}.facts{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:5px;margin-top:7px}.fact{border:1px solid var(--line-dim);padding:4px 6px;background:var(--panel-soft)}.fact b{color:var(--muted);font-weight:700}.history{min-height:240px}.range{display:flex;gap:5px;align-items:center;flex-wrap:wrap}.pill{border:1px solid var(--line);padding:2px 7px;border-radius:999px;background:var(--panel-soft);color:var(--text)}.pill.active{border-color:var(--accent);color:var(--accent);font-weight:900}.bars{display:grid;grid-template-columns:repeat(14,1fr);gap:3px;align-items:end;height:82px;margin:8px 0 2px;border-bottom:1px solid var(--line)}.bar{display:flex;flex-direction:column;justify-content:flex-end;align-items:center;min-width:0}.bar span{display:block;width:100%;text-align:center}.allocated{color:var(--graph)}.unalloc{color:var(--unalloc)}.gap{color:var(--gap)}.zero{color:var(--zero)}.partial{border-top:2px dashed var(--select)}.selected{outline:1px solid var(--select);outline-offset:1px}.day-row{display:grid;grid-template-columns:repeat(14,1fr);gap:3px;color:var(--muted);font-size:12px;text-align:center}.marker{color:var(--select);text-align:center;letter-spacing:.5ch}.legend{color:var(--muted);font-size:12px;margin-top:7px}.readout{border-top:1px dashed var(--line);padding-top:7px;margin-top:7px}.health{display:grid;grid-template-rows:auto auto;gap:8px}.subbox{border:1px solid var(--line-dim);padding:7px 8px;background:var(--panel-soft)}.subbox h3{margin:0 0 5px;font-size:14px;color:var(--accent)}.settings-grid{display:grid;grid-template-columns:auto 1fr;gap:3px 9px}.setting-control{color:var(--accent);font-weight:900}.service{margin-top:8px;border:1px solid var(--line);background:var(--panel);display:grid;grid-template-columns:1fr auto;gap:8px;padding:8px 10px}.continuity{margin-top:3px}.actions{display:flex;align-items:center;gap:10px;white-space:nowrap}.quit{border:2px solid var(--text);padding:3px 8px;font-weight:900;color:var(--text);background:var(--quit-bg)}.long-reason{display:none;color:var(--bad);margin-top:5px}body[data-state=error] .long-reason,body[data-state=stale] .long-reason,body[data-state=interrupted] .long-reason{display:block}.narrow-only{display:none}.switcher{position:fixed;z-index:10;bottom:14px;left:50%;transform:translateX(-50%);display:flex;gap:8px;align-items:center;flex-wrap:wrap;justify-content:center;max-width:calc(100vw - 20px);background:#f5f7fb;color:#151923;border-radius:999px;padding:8px 11px;box-shadow:0 8px 32px #000}.switcher label{font-weight:800}.switcher select,.switcher button{font:700 13px ui-monospace,monospace;border:1px solid #c8ced9;border-radius:999px;background:white;color:#151923;padding:6px 9px}.switcher button.active{background:#151923;color:white}.switcher .check{display:flex;align-items:center;gap:4px}.switcher input{accent-color:#151923}
body[data-preset=amber]{--bg:#0d0b07;--panel:#17120b;--panel-soft:#20180f;--title-bg:#22170a;--title:#f8e1ad;--text:#f3eadb;--muted:#c2ad8a;--line:#8a6432;--line-dim:#5a4022;--accent:#f0b35a;--graph:#f0b35a;--unalloc:#d38f3f;--gap:#6f604e;--zero:#d8c5a3;--select:#f8e08e;--ok:#5ee08b;--warn:#ffd166;--bad:#ff6b6b;--neutral:#b8bec9;--warn-bg:#3a2c0c;--warn-text:#fff3c7;--quit-bg:#2a1e10;--term-w:960px;--term-h:650px}
body[data-preset=nord]{--bg:#0b1118;--panel:#111827;--panel-soft:#162033;--title-bg:#172235;--title:#e5e9f0;--text:#e5e9f0;--muted:#a9b4c8;--line:#4c566a;--line-dim:#354154;--accent:#88c0d0;--graph:#88c0d0;--unalloc:#d08770;--gap:#677285;--zero:#c5ccd8;--select:#ebcb8b;--ok:#5ee08b;--warn:#ffd166;--bad:#ff6b6b;--neutral:#b8bec9;--warn-bg:#2f2a17;--warn-text:#fff0bd;--quit-bg:#172235;--term-w:960px;--term-h:650px}
body[data-preset=high]{--bg:#000;--panel:#000;--panel-soft:#111;--title-bg:#000;--title:#fff;--text:#fff;--muted:#d8d8d8;--line:#fff;--line-dim:#888;--accent:#00ffff;--graph:#ffff00;--unalloc:#ffaf00;--gap:#9b9b9b;--zero:#fff;--select:#00ffff;--ok:#00ff00;--warn:#ffff00;--bad:#ff4040;--neutral:#fff;--warn-bg:#333300;--warn-text:#fff;--quit-bg:#000;--term-w:960px;--term-h:650px}
body[data-width=wide]{--term-w:1180px;--term-h:760px}.wide-note{display:none}body[data-width=wide] .wide-note{display:inline}
body[data-width=narrow]{--term-w:620px;--term-h:600px;font-size:13px}body[data-width=narrow] .grid{grid-template-columns:1fr}body[data-width=narrow] .headline{grid-column:auto}body[data-width=narrow] .facts{grid-template-columns:repeat(2,minmax(0,1fr))}body[data-width=narrow] .bars,body[data-width=narrow] .day-row,body[data-width=narrow] .marker,body[data-width=narrow] .legend{display:none}body[data-width=narrow] .narrow-only{display:block;border:1px dashed var(--line);padding:8px;margin:8px 0;color:var(--warn)}body[data-width=narrow] .history{min-height:auto}body[data-width=narrow] .service{grid-template-columns:1fr}body[data-width=narrow] .actions{white-space:normal;justify-content:space-between}.sr{position:absolute;left:-10000px}
</style>
</head>
<body data-preset="amber" data-state="monitoring" data-width="normal" data-glyph="emoji">
<p class="note"><b>PROTOTYPE:</b> titlebox, health/settings grouping, theme presets and status accessibility for <i>Approve titlebox, health layout and colour presets</i>. Static throwaway; no production code.</p>
<main class="terminal" aria-label="Fenris terminal mockup">
<div class="screen">
<div class="titlebox" aria-label="Application title"><span class="emoji">🐺 Fenris by Bongbetic</span><span class="fallback">Fenris by Bongbetic</span></div>
<div class="auth">privileged actions will prompt for authentication (polkit) <span class="wide-note">· clears after first refresh</span></div>
<section class="pane headline">
<div class="pane-title">lifespan + monitoring</div>
<div class="big">Usage-adjusted theoretical lifespan: <span class="accent">8 yr 103 d remaining</span></div>
<div>if current habits continue · sustained regime: 46 days · scenario range: 7d 12.4 / 28d 11.1 / 90d 10.7 GB/day</div>
<div class="status-line"><div class="state monitoring" id="stateLine"><span class="glyph">●</span><span id="stateLabel">Monitoring</span></div><div id="stateReason">Last sample: 4 min ago · blink means enabled + fresh, not data refresh</div></div>
<div class="long-reason" id="longReason">last run failed (exit 3) · last good sample 4 min ago · collection stopped outside Fenris details stay visible without changing the status vocabulary</div>
<div class="pause-banner">‖ Paused — monitoring paused · paused time excluded from your usage habit</div>
<div class="facts"><div class="fact"><b>Boot</b><br><span id="bootFact">on</span></div><div class="fact"><b>Runtime</b><br><span id="runtimeFact">timer active</span></div><div class="fact"><b>Last collect</b><br><span id="collectFact">ok · 4 min ago</span></div><div class="fact"><b>Freshness</b><br><span id="freshFact">fresh</span></div></div>
</section>
<div class="grid">
<section class="pane history" tabindex="0" aria-label="Usage history graph pane">
<div class="pane-title">usage history</div>
<div class="range"><b>usage history · Local · UTC+05:30 · Asia/Kolkata</b><span class="pill">7</span><span class="pill active">14</span><span class="pill">28</span><span class="pill">90</span></div>
<div class="narrow-only">14-day write history: 9.2–18.6 GB/day · selected Wed 09 Sep · graph needs ≥80×24</div>
<div class="bars" aria-hidden="true">
<div class="bar"><span class="gap">░</span><span class="gap">░</span></div><div class="bar"><span class="zero">·</span></div><div class="bar"><span class="allocated">█</span><span class="allocated">█</span></div><div class="bar"><span class="allocated">█</span><span class="unalloc">▒</span><span class="unalloc">▒</span></div><div class="bar selected"><span class="allocated">█</span><span class="allocated">█</span><span class="allocated">█</span><span class="unalloc">▒</span></div><div class="bar"><span class="allocated">█</span></div><div class="bar"><span class="allocated">█</span><span class="allocated">█</span></div><div class="bar"><span class="gap">░</span><span class="gap">░</span><span class="gap">░</span></div><div class="bar"><span class="allocated">█</span><span class="allocated">█</span><span class="allocated">█</span><span class="allocated">█</span></div><div class="bar"><span class="allocated">█</span><span class="unalloc">▒</span></div><div class="bar"><span class="zero">·</span></div><div class="bar"><span class="allocated">█</span><span class="allocated">█</span></div><div class="bar partial"><span class="allocated">█</span><span class="allocated">█</span><span class="unalloc">▒</span></div><div class="bar partial"><span class="allocated">█</span></div>
</div>
<div class="marker">&nbsp;&nbsp;&nbsp;&nbsp;▼</div>
<div class="day-row"><span>27</span><span>28</span><span>29</span><span>30</span><span>31</span><span>01</span><span>02</span><span>03</span><span>04</span><span>05</span><span>06</span><span>07</span><span>08</span><span>09</span></div>
<div class="legend">legend: <span class="allocated">█ allocated</span> · <span class="unalloc">▒ unallocated</span> · <span class="gap">░ gap/no evidence</span> · <span class="zero">· 0 B</span> · ┄ partial · ▼ selected</div>
<div class="readout"><b>Wed 09 Sep</b> · writes 14.2 GB allocated + 2.0 GB unallocated · 21 evidenced hours · coverage 87% · partial · 13 h elapsed · so far<br><span class="muted">Enter/click drills into hourly detail; Esc/Backspace returns. Footer shows graph keys while graph is focused.</span></div>
</section>
<section class="pane health" tabindex="0" aria-label="Drive health and settings pane">
<div class="subbox"><h3>Drive health</h3><b>Samsung SSD 990 PRO</b><br>temperature 41°C · spare 100%<br>media errors 0 · unsafe shutdowns 2<br>power-on 1,184 h · 46 cycles · 2 TB<br><b>vendor wear</b>: 3% used · 18.4 TB written <span class="muted">(context, not a second projection)</span></div>
<div class="subbox"><h3>Settings</h3><div class="settings-grid"><span>device</span><span>/dev/disk/by-id/nvme-Samsung_990</span><span>baseline</span><span>verified override · 1,200 TBW</span><span>retention</span><span>raw 14 d · intervals/hours/days indefinite</span><span>theme</span><span class="setting-control" id="themeText">Amber preset · user preference</span><span>motion</span><span class="setting-control" id="motionText">normal blink</span></div></div>
</section>
</div>
<section class="service" aria-label="Service strip and actions">
<div><b>CONTINUITY</b> <span id="continuity">monitoring: active in background · persists across reboots</span><div class="continuity muted">p pause · r resume · c collect · d disclosures · graph focus: ←/→ select · Enter drill · 1/2/3/4 ranges · t preset · m motion</div></div>
<div class="actions"><span class="quit">q QUIT TUI</span></div>
</section>
</div>
</main>
<nav class="switcher" aria-label="Prototype controls">
<label>preset <select id="preset"><option value="amber">Amber</option><option value="nord">Nord</option><option value="high">High Contrast</option></select></label>
<label>state <select id="state"><option value="monitoring">Monitoring</option><option value="collecting">Collecting</option><option value="paused">Paused</option><option value="waiting">Waiting</option><option value="interrupted">Interrupted</option><option value="error">Error</option><option value="stale">Stale</option><option value="unknown">Unknown</option></select></label>
<label>size <select id="width"><option value="normal">80×24</option><option value="wide">140×40</option><option value="narrow">narrow</option></select></label>
<button id="glyph">wolf title</button>
<label class="check"><input id="motion" type="checkbox"> reduced motion</label>
</nav>
<script>
const states={
monitoring:{glyph:'●',label:'Monitoring',cls:'monitoring',reason:'Last sample: 4 min ago · blink means enabled + fresh, not data refresh',boot:'on',runtime:'timer active',collect:'ok · 4 min ago',fresh:'fresh',cont:'monitoring: active in background · persists across reboots'},
collecting:{glyph:'◐',label:'Collecting',cls:'collecting',reason:'run in flight (≤90 s) · underlying state: monitoring',boot:'on',runtime:'service activating',collect:'running now',fresh:'fresh',cont:'monitoring: active in background · persists across reboots'},
paused:{glyph:'‖',label:'Paused',cls:'paused',reason:'monitoring paused — paused time excluded from your usage habit',boot:'off',runtime:'timer inactive',collect:'ok before pause',fresh:'last sample 49 days ago',cont:'monitoring: does not start on next boot'},
waiting:{glyph:'○',label:'Waiting',cls:'waiting',reason:'awaiting another sample · first graph data is independent of lifespan warm-up',boot:'on',runtime:'timer active',collect:'ok · one sample',fresh:'empty / insufficient pair',cont:'monitoring: active in background · persists across reboots'},
interrupted:{glyph:'⊘',label:'Interrupted',cls:'interrupted',reason:'collection stopped outside Fenris — monitoring period still open',boot:'on',runtime:'timer inactive',collect:'unknown stop',fresh:'last sample 3 days ago',cont:'enabled but not currently collecting'},
error:{glyph:'✖',label:'Error',cls:'error',reason:'last run failed (exit 3) · last good sample 4 min ago',boot:'on',runtime:'timer active',collect:'FAILED exit 3',fresh:'fresh data, failed last run',cont:'monitoring: active in background · persists across reboots'},
stale:{glyph:'◌',label:'Stale',cls:'stale',reason:'last sample 3 days ago · timer active, no failure recorded',boot:'on',runtime:'timer active',collect:'ok then silent',fresh:'stale',cont:'monitoring: active in background · persists across reboots'},
unknown:{glyph:'?',label:'Unknown',cls:'unknown',reason:'service state unavailable',boot:'unknown',runtime:'unknown',collect:'unknown',fresh:'store facts unavailable',cont:'service state unavailable'}
};
function apply(){
const preset=document.getElementById('preset').value; const state=document.getElementById('state').value; const width=document.getElementById('width').value;
document.body.dataset.preset=preset; document.body.dataset.state=state; document.body.dataset.width=width;
const s=states[state]; const line=document.getElementById('stateLine'); line.className='state '+s.cls; line.querySelector('.glyph').textContent=s.glyph; document.getElementById('stateLabel').textContent=s.label; document.getElementById('stateReason').textContent=s.reason;
document.getElementById('bootFact').textContent=s.boot; document.getElementById('runtimeFact').textContent=s.runtime; document.getElementById('collectFact').textContent=s.collect; document.getElementById('freshFact').textContent=s.fresh; document.getElementById('continuity').textContent=s.cont;
document.getElementById('themeText').textContent={amber:'Amber preset · user preference',nord:'Nord preset · user preference',high:'High Contrast preset · user preference'}[preset];
const reduced=document.getElementById('motion').checked; document.body.classList.toggle('reduced',reduced); document.getElementById('motionText').textContent=reduced?'reduced motion · steady status dot':'normal blink';
const params=new URLSearchParams({preset,state,width,glyph:document.body.dataset.glyph,motion:reduced?'reduced':'normal'}); history.replaceState(null,'','?'+params);
}
const params=new URLSearchParams(location.search); for(const id of ['preset','state','width']) if(params.get(id)) document.getElementById(id).value=params.get(id)); if(params.get('glyph')==='plain') document.body.dataset.glyph='plain'; if(params.get('motion')==='reduced') document.getElementById('motion').checked=true;
document.getElementById('glyph').onclick=()=>{document.body.dataset.glyph=document.body.dataset.glyph==='emoji'?'plain':'emoji'; document.getElementById('glyph').textContent=document.body.dataset.glyph==='emoji'?'wolf title':'plain title'; apply()};
for(const id of ['preset','state','width','motion']) document.getElementById(id).addEventListener('change',apply);
apply();
</script>
</body>
</html>
+6
View File
@@ -0,0 +1,6 @@
#!/bin/sh
# PROTOTYPE: static local server; no dependencies or persistence.
set -eu
cd "$(git rev-parse --show-toplevel)"
printf '%s\n' 'Open http://127.0.0.1:8765/prototype/titlebox-health-presets/?preset=amber&state=monitoring'
exec python3 -m http.server 8765 --bind 127.0.0.1
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "fenris"
version = "0.3.0"
version = "0.3.4"
description = "NVMe wear monitor with persistent TUI"
requires-python = ">=3.9"
dependencies = [
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""Extract one validated Keep a Changelog version section."""
from __future__ import annotations
import argparse
from datetime import date
from pathlib import Path
import re
import sys
class ChangelogError(ValueError):
"""A release cannot safely use the supplied changelog."""
_SEMVER = r"(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)"
_VERSION_HEADING = re.compile(
rf"^## \[(?P<version>{_SEMVER})\] - (?P<date>.+)$", re.MULTILINE
)
def extract_version_section(changelog: str, version: str) -> str:
"""Return *version*'s changelog section without altering its bytes.
The section ends immediately before the next level-two heading. A release
cannot use an absent, empty, or malformed version section.
"""
if not re.fullmatch(_SEMVER, version):
raise ChangelogError(f"requested version is not bare semver: {version!r}")
heading = next(
(match for match in _VERSION_HEADING.finditer(changelog)
if match.group("version") == version),
None,
)
if heading is None:
if re.search(rf"^## \[{re.escape(version)}\].*$", changelog, re.MULTILINE):
raise ChangelogError(f"version {version} has a malformed heading or date")
raise ChangelogError(f"version {version} is missing from the changelog")
heading_date = heading.group("date")
if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", heading_date):
raise ChangelogError(f"version {version} has a malformed release date")
try:
date.fromisoformat(heading_date)
except ValueError as error:
raise ChangelogError(f"version {version} has a malformed release date") from error
next_heading = re.search(r"^## ", changelog[heading.end():], re.MULTILINE)
section_end = heading.end() + next_heading.start() if next_heading else len(changelog)
section = changelog[heading.start():section_end]
if not re.search(r"^- \S", section[heading.end() - heading.start():], re.MULTILINE):
raise ChangelogError(f"version {version} has an empty changelog section")
return section
def extract_changelog(path: Path, version: str) -> str:
"""Read and extract a requested version from a changelog file."""
try:
return extract_version_section(path.read_text(encoding="utf-8"), version)
except OSError as error:
raise ChangelogError(f"cannot read changelog {path}: {error.strerror}") from error
def assemble_release_body(section: str, footer: str) -> str:
"""Append standing guidance while preserving the extracted section verbatim."""
separator = "\n" if section.endswith("\n") else "\n\n"
return f"{section}{separator}{footer}"
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("changelog", type=Path)
parser.add_argument("version")
parser.add_argument(
"--footer",
type=Path,
help="append this standing release guidance after the extracted section",
)
args = parser.parse_args(argv)
try:
section = extract_changelog(args.changelog, args.version)
if args.footer:
try:
footer = args.footer.read_text(encoding="utf-8")
except OSError as error:
raise ChangelogError(
f"cannot read release footer {args.footer}: {error.strerror}"
) from error
section = assemble_release_body(section, footer)
sys.stdout.write(section)
except ChangelogError as error:
print(f"::error::{error}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
+25 -2
View File
@@ -10,6 +10,29 @@ import argparse
import os
import subprocess
import sys
from pathlib import Path
def add_runtime_packages() -> None:
"""Make the package-owned, pure-Python dependencies importable.
RPM and deb installations deliberately use the target system's Python.
Their dependencies are vendored without a copied interpreter so a distro
Python minor-version update cannot leave Fenris linked to a removed ABI.
The legacy development install keeps its venv fallback.
"""
runtime_dir = Path("/opt/fenris")
vendor_dir = runtime_dir / "vendor"
if vendor_dir.is_dir():
sys.path.insert(0, str(vendor_dir))
return
site_packages = next((runtime_dir / "lib").glob("python*/site-packages"), None)
if site_packages:
sys.path.insert(0, str(site_packages))
add_runtime_packages()
def is_root() -> bool:
@@ -51,8 +74,8 @@ def cmd_tui(args: argparse.Namespace) -> None:
def cmd_status(args: argparse.Namespace) -> None:
"""Show status."""
from fenris.status import print_status
print_status()
from fenris.status import render_status
print(render_status())
def cmd_sample(args: argparse.Namespace) -> None:
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env python3
"""Describe the Gitea request that creates or resynchronizes a release."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import re
import sys
from typing import Any
_SEMVER = r"(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)"
class ReleaseRequestError(ValueError):
"""A release request could not be prepared safely."""
def build_release_request(
version: str, body: str, existing_release: dict[str, Any] | None
) -> dict[str, Any]:
"""Return the observable POST or PATCH request for a Gitea release."""
if not re.fullmatch(_SEMVER, version):
raise ReleaseRequestError(f"version is not bare semver: {version!r}")
if existing_release is None:
return {
"method": "POST",
"path": "/releases",
"payload": {"tag_name": f"v{version}", "name": f"v{version}", "body": body},
}
release_id = existing_release.get("id")
if not isinstance(release_id, int):
raise ReleaseRequestError("existing release does not contain an integer id")
return {
"method": "PATCH",
"path": f"/releases/{release_id}",
"payload": {"body": body},
}
def _read_json(path: Path) -> dict[str, Any]:
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
raise ReleaseRequestError(f"cannot read existing release {path}: {error}") from error
if not isinstance(value, dict):
raise ReleaseRequestError("existing release must be a JSON object")
return value
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--version", required=True)
parser.add_argument("--body-file", type=Path, required=True)
parser.add_argument("--existing-release", type=Path)
args = parser.parse_args(argv)
try:
body = args.body_file.read_text(encoding="utf-8")
existing = _read_json(args.existing_release) if args.existing_release else None
print(json.dumps(build_release_request(args.version, body, existing)))
except (OSError, ReleaseRequestError) as error:
print(f"::error::{error}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
+1 -1
View File
@@ -1,2 +1,2 @@
"""Fenris: NVMe wear monitor with persistent TUI."""
__version__ = "0.3.0"
__version__ = "0.3.1"
+9 -4
View File
@@ -15,12 +15,17 @@ import sys
from datetime import datetime, timezone
from pathlib import Path
# Add the venv to path if running from the installed location
# Package dependencies are vendored independently of the host Python minor
# version. Keep the venv fallback for the legacy development install.
VENV_DIR = Path("/opt/fenris")
if VENV_DIR.exists():
site_packages = next((VENV_DIR / "lib").glob("python*/site-packages"), None)
if site_packages:
sys.path.insert(0, str(site_packages))
vendor_dir = VENV_DIR / "vendor"
if vendor_dir.is_dir():
sys.path.insert(0, str(vendor_dir))
else:
site_packages = next((VENV_DIR / "lib").glob("python*/site-packages"), None)
if site_packages:
sys.path.insert(0, str(site_packages))
from fenris.store import init_store, get_store_path
from fenris.collector import run_collection
+9 -8
View File
@@ -21,12 +21,17 @@ import sqlite3
from datetime import datetime, timezone
from pathlib import Path
# Add the venv to path if running from the installed location
# Package dependencies are vendored independently of the host Python minor
# version. Keep the venv fallback for the legacy development install.
VENV_DIR = Path("/opt/fenris")
if VENV_DIR.exists():
site_packages = next((VENV_DIR / "lib").glob("python*/site-packages"), None)
if site_packages:
sys.path.insert(0, str(site_packages))
vendor_dir = VENV_DIR / "vendor"
if vendor_dir.is_dir():
sys.path.insert(0, str(vendor_dir))
else:
site_packages = next((VENV_DIR / "lib").glob("python*/site-packages"), None)
if site_packages:
sys.path.insert(0, str(site_packages))
from fenris.store import init_store, get_store_path
from fenris.monitoring_periods import (
@@ -53,10 +58,6 @@ def cmd_enable(args: argparse.Namespace) -> None:
- Resume with no open period: opens a new row
"""
store_path = getattr(args, 'store_path', DEFAULT_STORE_PATH)
if not store_path.exists():
print("Error: Observation store not found at", store_path, file=sys.stderr)
sys.exit(1)
conn = init_store(store_path)
now = datetime.now(timezone.utc)
+61 -2
View File
@@ -88,7 +88,13 @@ def open_store_readonly(store_path: Path) -> sqlite3.Connection:
Raises StoreFault if unreadable, NewerSchema if user_version > SCHEMA_VERSION.
"""
if not store_path.exists():
try:
exists = store_path.exists()
except OSError as e:
# A non-group user stat()ing a 2750 store directory gets
# PermissionError before any StoreFault can be raised (issue #54).
raise StoreFault("observation store not readable: %s" % e)
if not exists:
raise StoreFault("observation store not found at %s" % store_path)
try:
@@ -443,7 +449,7 @@ def _format_headline(proj) -> str:
def _append_service_facts(lines: List[str], service: Dict[str, Any]) -> None:
"""Append the four separate service facts (§7.3, LC-9)."""
"""Append service facts and dashboard-clarity monitoring state."""
boot = "enabled" if service.get("boot_enabled") else "disabled"
activity = "active" if service.get("timer_active") else "inactive"
@@ -467,6 +473,55 @@ def _append_service_facts(lines: List[str], service: Dict[str, Any]) -> None:
lines.append("boot: %s · timer: %s · last collect: %s%s · freshness: %s%s"
% (boot, activity, collect, collect_age, freshness_str, freshness_age))
lines.append("CONTINUITY: %s" % monitoring_continuity(service))
if service.get("deliberately_paused"):
lines.extend(deliberate_pause_lines())
# ---------------------------------------------------------------------------
# Dashboard clarity parity wording (DC-2, DC-3)
# ---------------------------------------------------------------------------
_CONTINUITY_ACTIVE = "monitoring: active in background · persists across reboots"
_CONTINUITY_DISABLED = "monitoring: does not start on next boot"
_PAUSED_TITLE = "monitoring: paused — deliberate disable"
_PAUSED_CONSEQUENCE = (
"paused time is excluded from your usage habit · resume: fenris monitor resume"
)
def monitoring_continuity(service: Dict[str, Any]) -> str:
"""Return the boot-persistence wording, independent of timer runtime."""
return _CONTINUITY_ACTIVE if service.get("boot_enabled") else _CONTINUITY_DISABLED
def deliberate_pause_lines() -> List[str]:
"""Return the exact CLI/TUI presentation for a sanctioned pause."""
return [_PAUSED_TITLE, _PAUSED_CONSEQUENCE]
def is_deliberately_paused(conn: sqlite3.Connection, service: Dict[str, Any]) -> bool:
"""Whether the latest closed period was ended by Fenris's own pause path.
Raw systemd operations have no `user_disabled` row, so they must never be
presented as a Deliberate disable. A live enabled timer also wins over a
stale period marker, keeping the presentation consistent with service facts.
"""
if service.get("boot_enabled") or service.get("timer_active"):
return False
open_period = conn.execute(
"SELECT 1 FROM monitoring_periods WHERE ended_at IS NULL LIMIT 1"
).fetchone()
if open_period is not None:
return False
row = conn.execute(
"SELECT end_cause FROM monitoring_periods "
"WHERE ended_at IS NOT NULL "
"ORDER BY ended_at DESC, id DESC LIMIT 1"
).fetchone()
return row is not None and row[0] == "user_disabled"
def format_disclosures() -> str:
@@ -556,6 +611,10 @@ def get_status(store_path: Optional[Path] = None, clock_now: Optional[datetime]
service["freshness"] = freshness
service["freshness_age_s"] = freshness_age_s
try:
service["deliberately_paused"] = is_deliberately_paused(conn, service)
except sqlite3.Error:
service["deliberately_paused"] = False
# --- Drive anomalies (§9.7, FL-7) ---
drive_facts = []
+28 -4
View File
@@ -15,9 +15,19 @@ from typing import Optional
SCHEMA_VERSION = 1
# Packaged default placement (spec §8.3). The config may override it, but a
# fresh install that sets only the device selector must collect cleanly.
DEFAULT_STORE_PATH = Path("/var/lib/fenris/observations.db")
def get_store_path(config: dict) -> Path:
"""Get the store path from config."""
return Path(config["store_path"])
"""Get the store path from config.
Falls back to the packaged default when the config does not pin one,
so a fresh install whose config holds only the device selector works
instead of crashing with KeyError 'store_path' (issue #53).
"""
return Path(config.get("store_path", DEFAULT_STORE_PATH))
def init_store(store_path: Path) -> sqlite3.Connection:
@@ -27,10 +37,24 @@ def init_store(store_path: Path) -> sqlite3.Connection:
Returns a connection to the store.
"""
conn = sqlite3.connect(str(store_path))
# Enable WAL mode for concurrent reads during writes
conn.execute("PRAGMA journal_mode=WAL")
# Group members (fenris group) read the live store read-only, but SQLite
# in WAL mode needs write access to the db and its -wal/-shm sidecars even
# for readers. Best effort: root-created stores stay group-accessible
# without relying on the creating process's umask (issue #54).
import os as _os
for sidecar in (store_path,
store_path.with_name(store_path.name + "-wal"),
store_path.with_name(store_path.name + "-shm")):
try:
mode = _os.stat(sidecar).st_mode & 0o777
_os.chmod(sidecar, mode | 0o060)
except OSError:
pass
# Check if this is a new database
cursor = conn.execute("PRAGMA user_version")
current_version = cursor.fetchone()[0]
+110 -29
View File
@@ -23,7 +23,7 @@ from typing import Any, Dict, List, Optional
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, Vertical
from textual.containers import Container, Horizontal, VerticalScroll
from textual.screen import ModalScreen
from textual.widgets import Static
@@ -43,6 +43,9 @@ from .status import (
format_disclosures,
freshness_age_human,
grade_freshness,
deliberate_pause_lines,
is_deliberately_paused,
monitoring_continuity,
open_store_readonly,
query_service_state,
read_config,
@@ -229,6 +232,7 @@ def _query_service_facts(conn: sqlite3.Connection, clock_now: datetime) -> Dict[
"freshness": freshness,
"freshness_age_s": None,
"period": period_info,
"deliberately_paused": is_deliberately_paused(conn, svc),
**svc,
}
@@ -286,20 +290,38 @@ class DisclosuresScreen(ModalScreen[None]):
class FenrisTuiApp(App):
"""Fenris Panes TUI — keyboard-first, one dense screen (spec §7)."""
TITLE = "Fenris"
SUB_TITLE = "NVMe endurance monitor"
TITLE = "Fenris — NVMe endurance monitor"
SUB_TITLE = ""
CSS = """
#main-grid {
layout: grid;
grid-size: 2 3;
grid-size: 2 4;
grid-columns: 3fr 2fr;
grid-rows: 8 1fr 7;
height: 1fr;
grid-rows: 8 10 7 3;
height: auto;
}
#main-grid.paused {
grid-size: 2 5;
grid-rows: 8 5 10 7 3;
}
#dashboard-scroll { height: 1fr; }
#headline-band { column-span: 2; }
#service-strip { column-span: 2; }
.pane { border: round #555555; padding: 0 1; }
#paused-banner {
column-span: 2;
display: none;
background: $error 20%;
color: $text;
height: 100%;
}
#service-strip { column-span: 2; height: 100%; }
#quit-rail {
column-span: 2;
border: heavy $accent;
content-align: center middle;
height: 100%;
}
.pane { border: round #555555; padding: 0 1; height: 100%; }
#confirm-text { padding: 1 2; }
#disc-text { padding: 1 2; }
"""
@@ -317,21 +339,27 @@ class FenrisTuiApp(App):
store_path: Optional[Path] = None,
config_path: Optional[Path] = None,
helper_path: Optional[str] = None,
refresh_interval_s: float = CADENCE_DEFAULT_S,
**kwargs,
) -> None:
super().__init__(**kwargs)
self.store_path = store_path or Path("/var/lib/fenris/observations.db")
self.config_path = config_path
self.helper_path = helper_path or "/usr/libexec/fenris/fenris-monitor"
self.refresh_interval_s = refresh_interval_s
self._show_auth_notice = True
self._conn: Optional[sqlite3.Connection] = None
self._clock_now = datetime.now(timezone.utc)
def compose(self) -> ComposeResult:
with Vertical(id="main-grid"):
yield Static("", id="headline-band", classes="pane")
yield Static("", id="usage-history", classes="pane")
yield Static("", id="drive-health", classes="pane")
yield Static("", id="service-strip", classes="pane")
with VerticalScroll(id="dashboard-scroll"):
with Container(id="main-grid"):
yield Static("", id="headline-band", classes="pane")
yield Static("", id="paused-banner")
yield Static("", id="usage-history", classes="pane")
yield Static("", id="drive-health", classes="pane")
yield Static("", id="service-strip", classes="pane")
yield Static("q QUIT TUI", id="quit-rail")
def on_mount(self) -> None:
"""Set border titles and render initial state."""
@@ -339,8 +367,30 @@ class FenrisTuiApp(App):
self.query_one("#usage-history").border_title = "usage history"
self.query_one("#drive-health").border_title = "drive"
self.query_one("#service-strip").border_title = "service + actions"
self._refresh_timer = self.set_interval(
self.refresh_interval_s, self.on_refresh_tick
)
self._refresh()
def on_refresh_tick(self) -> None:
"""Refresh dashboard and dismiss launch-only authentication guidance."""
self._show_auth_notice = False
self._refresh()
def _headline_prefix(self) -> str:
"""Render identity and any launch-only guidance above drive state."""
lines = ["[bold]Fenris — NVMe endurance monitor[/bold]"]
if self._show_auth_notice:
lines.append("[dim]privileged actions will prompt for authentication (polkit)[/dim]")
return "\n".join(lines)
def _render_headline(self, body: str = "") -> None:
"""Render full-width identity, guidance, and current drive state."""
text = self._headline_prefix()
if body:
text += "\n\n" + body
self.query_one("#headline-band").update(text)
def _open_store(self) -> Optional[sqlite3.Connection]:
"""Open store read-only, handling faults."""
try:
@@ -366,28 +416,33 @@ class FenrisTuiApp(App):
def _render_empty_or_fault(self) -> None:
"""Render empty store greeting or store fault."""
self._hide_paused_banner()
if not self.store_path.exists():
# Empty store — greeting with enable hint (IN-3)
self.query_one("#headline-band").update(
self._render_headline(
"[bold]No observations yet[/bold]\n\n"
"Enable monitoring: fenris monitor resume"
)
self.query_one("#usage-history").update("")
self.query_one("#drive-health").update("")
self.query_one("#service-strip").update(
"boot: disabled · timer: inactive · last collect: unknown · freshness: empty\n"
"p pause · r resume · c collect · d disclosures · q quit"
"boot: disabled · timer: inactive · last collect: unknown · freshness: empty · "
"[dim]by Bongbetic[/dim]\n"
"[bold]CONTINUITY[/bold] %s\n"
"p pause · r resume · c collect · d disclosures"
% monitoring_continuity({"boot_enabled": False})
)
else:
# Store fault (FL-4)
self.query_one("#headline-band").update(
self._render_headline(
"[bold red]Observation store unreadable[/bold red]\n"
"Check journalctl -u fenris-collect.service"
)
self.query_one("#usage-history").update("")
self.query_one("#drive-health").update("")
self.query_one("#service-strip").update(
"p pause · r resume · c collect · d disclosures · q quit"
"[dim]by Bongbetic[/dim]\n"
"p pause · r resume · c collect · d disclosures"
)
def _render_all_regions(self, conn: sqlite3.Connection) -> None:
@@ -398,13 +453,9 @@ class FenrisTuiApp(App):
headline = self._format_headline(proj)
confidence = self._format_confidence(proj)
scenario = self._format_scenario(proj)
self.query_one("#headline-band").update(
headline + "\n" + confidence + "\n" + scenario
)
self._render_headline(headline + "\n" + confidence + "\n" + scenario)
except Exception:
self.query_one("#headline-band").update(
"[bold]No projection available[/bold]"
)
self._render_headline("[bold]No projection available[/bold]")
# --- Usage-history pane (§7.2 left) ---
history = _query_usage_history(conn)
@@ -448,17 +499,47 @@ class FenrisTuiApp(App):
collect = "ok" if svc.get("last_collect_ok") else "FAILED"
freshness = svc.get("freshness", "unknown")
self.query_one("#service-strip").update(
"boot: %s · timer: %s · last collect: %s · freshness: %s\n"
"boot: %s · timer: %s · last collect: %s · freshness: %s · "
"[dim]by Bongbetic[/dim]\n"
"[bold]CONTINUITY[/bold] %s\n"
"%s\n"
"p pause · r resume · c collect · d disclosures · q quit"
% (boot, activity, collect, freshness, svc.get("period", ""))
"p pause · r resume · c collect · d disclosures"
% (
boot, activity, collect, freshness,
monitoring_continuity(svc), svc.get("period", ""),
)
)
self._render_paused_banner(svc)
except Exception:
self._hide_paused_banner()
self.query_one("#service-strip").update(
"boot: unknown · timer: unknown · last collect: unknown · freshness: unknown\n"
"p pause · r resume · c collect · d disclosures · q quit"
"boot: unknown · timer: unknown · last collect: unknown · freshness: unknown · "
"[dim]by Bongbetic[/dim]\n"
"p pause · r resume · c collect · d disclosures"
)
def _render_paused_banner(self, service: Dict[str, Any]) -> None:
"""Show the high-contrast Deliberate disable block only when sanctioned."""
banner = self.query_one("#paused-banner")
if service.get("deliberately_paused"):
banner.update(
"[bold black on red]%s[/bold black on red]\n%s"
% tuple(deliberate_pause_lines())
)
banner.styles.display = "block"
main_grid = self.query_one("#main-grid")
main_grid.add_class("paused")
main_grid.refresh(layout=True)
else:
self._hide_paused_banner()
def _hide_paused_banner(self) -> None:
"""Ensure an unavailable store cannot retain a stale paused presentation."""
self.query_one("#paused-banner").styles.display = "none"
main_grid = self.query_one("#main-grid")
main_grid.remove_class("paused")
main_grid.refresh(layout=True)
def _format_headline(self, proj: ProjectionResult) -> str:
"""Format the lifespan headline (spec §6.11)."""
if proj.headline_remaining_seconds is None:
+83
View File
@@ -399,6 +399,89 @@ class TestCI2Parity:
assert "last collect:" in status
assert "freshness:" in status
def test_dashboard_clarity_parity_strings_have_one_status_source(self):
"""DC-2/DC-3 wording originates in status and the TUI imports it."""
status_src = (FENRIS_PKG / "status.py").read_text()
tui_src = (FENRIS_PKG / "tui.py").read_text()
for wording in (
"monitoring: active in background · persists across reboots",
"monitoring: does not start on next boot",
"monitoring: paused — deliberate disable",
"paused time is excluded from your usage habit · resume: fenris monitor resume",
):
assert status_src.count(wording) == 1
assert wording not in tui_src
@pytest.mark.asyncio
@pytest.mark.parametrize(
("state", "service", "expected_lines"),
[
(
"active_enabled",
{"boot_enabled": True, "timer_active": True},
["monitoring: active in background · persists across reboots"],
),
(
"boot_disabled",
{"boot_enabled": False, "timer_active": False},
["monitoring: does not start on next boot"],
),
(
"deliberately_paused",
{"boot_enabled": False, "timer_active": False},
[
"monitoring: does not start on next boot",
"monitoring: paused — deliberate disable",
"paused time is excluded from your usage habit · resume: fenris monitor resume",
],
),
],
)
async def test_dashboard_clarity_monitoring_lines_match_both_views(
self, tmp_path, state, service, expected_lines
):
"""CI-2 synthetic-store sweep covers active, disabled, and paused states."""
db = tmp_path / (state + ".db")
conn = init_store(db)
if state == "active_enabled":
ensure_period_open(conn, _clock())
elif state == "deliberately_paused":
conn.execute(
"INSERT INTO monitoring_periods (started_at, ended_at, end_cause) "
"VALUES (?, ?, ?)",
("2026-09-30T09:00:00+00:00", "2026-09-30T10:00:00+00:00", "user_disabled"),
)
conn.commit()
conn.close()
service_state = {
**service,
"last_collect_ok": None,
"last_collect_age_s": None,
"last_collect_reason": None,
}
with patch("fenris.status.query_service_state", return_value=service_state), patch(
"fenris.tui.query_service_state", return_value=service_state
):
status = get_status(
store_path=db, clock_now=_clock(), query_services=True, query_journal=False
).lower()
app = FenrisTuiApp(store_path=db)
async with app.run_test(size=(100, 40)):
tui_text = "\n".join(
(
str(app.query_one("#service-strip").render()),
str(app.query_one("#paused-banner").render()),
)
).lower()
for expected in expected_lines:
assert expected in status
assert expected in tui_text
if state != "deliberately_paused":
assert "monitoring: paused — deliberate disable" not in status
assert "monitoring: paused — deliberate disable" not in tui_text
def test_pause_resume_action_names(self):
tui_keys = {b.key for b in FenrisTuiApp.BINDINGS}
assert "p" in tui_keys
+211
View File
@@ -0,0 +1,211 @@
"""Release-note changelog extraction tests (DC-6, DC-7)."""
import importlib.util
import json
import subprocess
import sys
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
EXTRACTOR_PATH = REPO_ROOT / "scripts" / "extract_changelog.py"
RELEASE_REQUEST_PATH = REPO_ROOT / "scripts" / "release_request.py"
CHANGELOG_PATH = REPO_ROOT / "CHANGELOG.md"
def _extractor_module():
spec = importlib.util.spec_from_file_location("extract_changelog", EXTRACTOR_PATH)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
def test_extracts_the_requested_version_section_verbatim():
extractor = _extractor_module()
changelog = """# Changelog
## [Unreleased]
## [1.4.0] - 2026-09-10
### Added
- Show a release summary to consumers.
## [1.3.0] - 2026-09-01
### Fixed
- Preserve the observation history during upgrades.
"""
expected = """## [1.4.0] - 2026-09-10
### Added
- Show a release summary to consumers.
"""
assert extractor.extract_version_section(changelog, "1.4.0") == expected
def test_checked_in_changelog_keeps_unreleased_first_and_categories_limited():
lines = CHANGELOG_PATH.read_text(encoding="utf-8").splitlines()
unreleased = lines.index("## [Unreleased]")
version_headings = [
index for index, line in enumerate(lines)
if line.startswith("## [") and line != "## [Unreleased]"
]
first_version = version_headings[0] if version_headings else len(lines)
categories = [
line.removeprefix("### ")
for line in lines[unreleased + 1:first_version]
if line.startswith("### ")
]
assert unreleased < first_version
assert set(categories) <= {"Added", "Changed", "Fixed"}
def test_release_footer_verifies_the_clearsigned_checksum_asset():
footer = (REPO_ROOT / "packaging" / "release-footer.md").read_text(
encoding="utf-8"
)
assert "gpg --output SHA256SUMS --decrypt SHA256SUMS.asc" in footer
assert "sha256sum -c SHA256SUMS" in footer
@pytest.mark.parametrize(
("changelog", "expected_error"),
[
("# Changelog\n\n## [Unreleased]\n", "missing"),
(
"# Changelog\n\n## [Unreleased]\n\n## [1.4.0] - 2026-09-10\n",
"empty",
),
(
"# Changelog\n\n## [Unreleased]\n\n## [1.4.0] - 2026-02-30\n\n- Add a note.\n",
"malformed release date",
),
],
)
def test_fails_closed_for_missing_empty_or_malformed_sections(
changelog, expected_error
):
extractor = _extractor_module()
with pytest.raises(extractor.ChangelogError, match=expected_error):
extractor.extract_version_section(changelog, "1.4.0")
def test_command_emits_a_workflow_error_and_nonzero_status(tmp_path):
changelog = tmp_path / "CHANGELOG.md"
changelog.write_text("# Changelog\n\n## [Unreleased]\n", encoding="utf-8")
result = subprocess.run(
[sys.executable, str(EXTRACTOR_PATH), str(changelog), "1.4.0"],
capture_output=True,
text=True,
check=False,
)
assert result.returncode != 0
assert result.stderr.startswith("::error::")
assert "missing" in result.stderr
def test_assembles_a_release_body_without_changing_the_section():
extractor = _extractor_module()
section = "## [1.4.0] - 2026-09-10\n\n### Added\n\n- Show a release summary.\n"
footer = "## Install\n\nUse the package channel.\n"
assert extractor.assemble_release_body(section, footer) == (
section + "\n" + footer
)
def test_command_can_write_the_complete_release_body(tmp_path):
changelog = tmp_path / "CHANGELOG.md"
changelog.write_text(
"# Changelog\n\n## [Unreleased]\n\n## [1.4.0] - 2026-09-10\n\n"
"### Added\n\n- Show a release summary.\n",
encoding="utf-8",
)
footer = tmp_path / "footer.md"
footer.write_text("## Install\n\nUse the package channel.\n", encoding="utf-8")
result = subprocess.run(
[
sys.executable,
str(EXTRACTOR_PATH),
str(changelog),
"1.4.0",
"--footer",
str(footer),
],
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0
assert result.stdout == (
"## [1.4.0] - 2026-09-10\n\n### Added\n\n- Show a release summary.\n\n"
"## Install\n\nUse the package channel.\n"
)
def test_release_request_command_reports_create_or_patch_decisions(tmp_path):
body = tmp_path / "release-body.md"
body.write_text("## [1.4.0] - 2026-09-10\n", encoding="utf-8")
create = subprocess.run(
[
sys.executable,
str(RELEASE_REQUEST_PATH),
"--version",
"1.4.0",
"--body-file",
str(body),
],
capture_output=True,
text=True,
check=False,
)
existing = tmp_path / "existing-release.json"
existing.write_text('{"id": 17, "assets": []}', encoding="utf-8")
patch = subprocess.run(
[
sys.executable,
str(RELEASE_REQUEST_PATH),
"--version",
"1.4.0",
"--body-file",
str(body),
"--existing-release",
str(existing),
],
capture_output=True,
text=True,
check=False,
)
assert create.returncode == patch.returncode == 0
assert json.loads(create.stdout) == {
"method": "POST",
"path": "/releases",
"payload": {
"tag_name": "v1.4.0",
"name": "v1.4.0",
"body": "## [1.4.0] - 2026-09-10\n",
},
}
assert json.loads(patch.stdout) == {
"method": "PATCH",
"path": "/releases/17",
"payload": {"body": "## [1.4.0] - 2026-09-10\n"},
}
+15
View File
@@ -52,6 +52,21 @@ class TestIsRoot:
class TestEnableIdempotentMatrix:
"""§8.6: Period-row idempotent matrix."""
def test_first_enable_creates_missing_store(self, store_path):
"""A fresh package install has a store directory but no database yet."""
args = MagicMock(now=False, store_path=store_path)
with patch("fenris.monitor.subprocess") as mock_sub:
mock_sub.run.return_value = MagicMock(returncode=0)
cmd_enable(args)
conn = init_store(store_path)
row = conn.execute(
"SELECT ended_at FROM monitoring_periods WHERE ended_at IS NULL"
).fetchone()
conn.close()
assert row is not None
def test_first_opens_period(self, store_path):
"""First-ever enable opens a period at the enable moment."""
# Initialize store
+27 -17
View File
@@ -78,6 +78,7 @@ DEB_TARGETS = [
RPM_TARGETS = [
("fedora:40", "rpm"),
("opensuse/tumbleweed", "rpm"),
]
ALL_TARGETS = DEB_TARGETS + RPM_TARGETS
@@ -101,11 +102,14 @@ def _build_deb_dockerfile(image: str, pkg_name: str) -> str:
def _build_rpm_dockerfile(image: str, pkg_name: str) -> str:
"""Dockerfile for testing rpm installation."""
install_command = (
"zypper --non-interactive install --no-recommends python3 smartmontools systemd dbus-1 && zypper clean --all"
if image.startswith("opensuse/")
else "dnf install -y --setopt=install_weak_deps=False python3 smartmontools systemd dbus && dnf clean all"
)
return textwrap.dedent(f"""\
FROM {image}
RUN dnf install -y --setopt=install_weak_deps=False \
python3 smartmontools systemd dbus && \
dnf clean all
RUN {install_command}
COPY dist/{pkg_name} /pkg/{pkg_name}
RUN rpm -ivh /pkg/{pkg_name}
""")
@@ -132,13 +136,16 @@ def skip_no_docker():
def _assert_dormant_layout(container: str, fmt: str, version: str) -> None:
"""Assert the dormant-install contract (spec §6, §7)."""
# Venv directory exists with expected structure
# Version-neutral vendored runtime exists. It must not contain a copied
# Python binary tied to the package build host.
rc, out = _container_exec(container, "test -d /opt/fenris && echo OK")
assert "OK" in out, "Bundled venv not found at /opt/fenris"
assert "OK" in out, "Bundled runtime not found at /opt/fenris"
# Venv Python binary exists (may not execute if shared libs differ)
rc, _ = _container_exec(container, "test -f /opt/fenris/bin/python3")
assert rc == 0, "Venv Python binary not found"
rc, _ = _container_exec(container, "test -f /opt/fenris/vendor/fenris/__init__.py")
assert rc == 0, "Fenris runtime package not found"
rc, _ = _container_exec(container, "test ! -e /opt/fenris/bin/python3")
assert rc == 0, "Package must not ship a copied Python interpreter"
# Wrapper on PATH
rc, out = _container_exec(container, "command -v fenris")
@@ -152,6 +159,11 @@ def _assert_dormant_layout(container: str, fmt: str, version: str) -> None:
rc, out = _container_exec(container, f"grep -q '{version}' /usr/bin/fenris && echo OK")
assert "OK" in out, f"Version {version} not found in wrapper script"
# This catches launcher import-path errors and copied-interpreter ABI
# breakage. Status is read-only and succeeds before monitoring setup.
rc, out = _container_exec(container, "fenris status")
assert rc == 0, f"Installed CLI cannot run: {out}"
# Helpers in /usr/libexec/fenris
for helper in ("fenris-monitor", "fenris-collect"):
rc, _ = _container_exec(container, f"test -x /usr/libexec/fenris/{helper}")
@@ -284,8 +296,7 @@ def _assert_upgrade_semantics(container: str, fmt: str, pkg_name: str, version:
4. Removal scripts are no-ops during upgrade
5. Downgrade refusal via forward-only version check (tested at Python level)
"""
# Create a fake observation store using system Python
# (venv Python may not execute if host shared libs differ)
# Create a fake observation store using the target system Python.
_container_exec(
container,
"mkdir -p /var/lib/fenris && "
@@ -391,7 +402,7 @@ def _assert_rpm_upgrade_full(container: str, pkg_name: str) -> None:
# Modify the config file (simulate hand-edited device selector)
_container_exec(
container,
"echo 'devices = /dev/nvme0n1' > /etc/fenris/fenris.conf",
"echo 'device = /dev/nvme0n1' > /etc/fenris/fenris.conf",
)
# Record original config hash
rc, original_hash = _container_exec(
@@ -447,8 +458,8 @@ def _assert_package_files_removed(container: str) -> None:
)
assert rc != 0, "Helper should be removed"
rc, _ = _container_exec(container, "test -f /opt/fenris/bin/python3")
assert rc != 0, "Venv Python should be removed"
rc, _ = _container_exec(container, "test -d /opt/fenris/vendor")
assert rc != 0, "Vendored runtime packages should be removed"
def _assert_deb_remove_preserves(container: str) -> None:
@@ -587,10 +598,9 @@ def test_python_floor(skip_no_docker, version):
assert "python3" in out, f"python3 dependency not declared: {out}"
assert "3.10" in out, f"Python 3.10 floor not in dependency: {out}"
# Verify the bundled venv exists (binary may not execute on the host
# interpreter — that's tested separately by the dormant-install test)
rc, _ = _container_exec(container, "test -f /opt/fenris/bin/python3")
assert rc == 0, "Bundled venv Python binary not found"
# Verify the version-neutral bundled runtime exists.
rc, _ = _container_exec(container, "test -f /opt/fenris/vendor/fenris/__init__.py")
assert rc == 0, "Bundled runtime package not found"
# fenris on PATH
rc, _ = _container_exec(container, "command -v fenris")
+23
View File
@@ -324,6 +324,29 @@ class TestCIWorkflow:
assert "upload" in content.lower() or "publish" in content.lower(), \
"Workflow must include upload/publish step"
def test_workflow_validates_notes_before_publication(self):
content = _read(".gitea/workflows/release.yml")
assert "scripts/extract_changelog.py" in content, \
"Workflow must fail before publication if release notes cannot be extracted"
assert "--footer packaging/release-footer.md" in content, \
"Workflow must assemble the body from the standing release footer"
def test_workflow_resynchronizes_existing_release_bodies(self):
content = _read(".gitea/workflows/release.yml")
assert "scripts/release_request.py" in content, \
"Workflow must make the create-versus-update decision through the request seam"
assert '"${METHOD}"' in content, \
"Workflow must execute the helper-selected create-or-update request"
assert 'RELEASE_PATH="$(printf' in content and '\n PATH="$(printf' not in content, \
"Workflow must not overwrite the shell PATH while preparing the request URL"
def test_readme_points_consumers_to_release_notes():
readme = _read("README.md")
assert "Per-release notes live on the [releases page]" in readme
assert "standing install and verification instructions" in readme
# ---------------------------------------------------------------------------
# Tests — Makefile release targets
+148
View File
@@ -415,6 +415,131 @@ class TestServiceFacts:
assert "last collect:" in result
assert "freshness:" in result
def test_continuity_reports_boot_enabled_independently_of_runtime(self, tmp_path):
"""Status names reboot continuity while retaining the timer fact."""
from fenris.status import get_status
db = tmp_path / "observations.db"
init_store(db)
now = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
with patch("fenris.status.query_service_state", return_value={
"boot_enabled": True, "timer_active": False,
"last_collect_ok": None, "last_collect_age_s": None,
"last_collect_reason": None,
}):
result = get_status(store_path=db, clock_now=now,
query_services=True, query_journal=False)
assert "monitoring: active in background · persists across reboots" in result
assert "timer: inactive" in result
def test_continuity_and_deliberate_pause_are_reported_separately(self, tmp_path):
"""Only a sanctioned user_disabled period renders the paused wording."""
from fenris.status import get_status
db = tmp_path / "observations.db"
conn = init_store(db)
conn.execute(
"INSERT INTO monitoring_periods (started_at, ended_at, end_cause) "
"VALUES (?, ?, ?)",
("2026-09-01T09:00:00+00:00", "2026-09-01T10:00:00+00:00", "user_disabled"),
)
conn.commit()
conn.close()
now = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
with patch("fenris.status.query_service_state", return_value={
"boot_enabled": False, "timer_active": False,
"last_collect_ok": None, "last_collect_age_s": None,
"last_collect_reason": None,
}):
result = get_status(store_path=db, clock_now=now,
query_services=True, query_journal=False)
assert "monitoring: does not start on next boot" in result
assert "monitoring: paused — deliberate disable" in result
assert "paused time is excluded from your usage habit · resume: fenris monitor resume" in result
def test_raw_system_state_without_user_disabled_is_not_a_deliberate_pause(self, tmp_path):
"""A non-sanctioned stop never acquires the deliberate-disable label."""
from fenris.status import get_status
db = tmp_path / "observations.db"
conn = init_store(db)
conn.execute(
"INSERT INTO monitoring_periods (started_at, ended_at, end_cause) "
"VALUES (?, ?, ?)",
("2026-09-01T09:00:00+00:00", "2026-09-01T10:00:00+00:00", "migrated"),
)
conn.commit()
conn.close()
now = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
with patch("fenris.status.query_service_state", return_value={
"boot_enabled": False, "timer_active": False,
"last_collect_ok": None, "last_collect_age_s": None,
"last_collect_reason": None,
}):
result = get_status(store_path=db, clock_now=now,
query_services=True, query_journal=False)
assert "monitoring: paused — deliberate disable" not in result
def test_resumed_open_period_clears_a_previous_deliberate_pause(self, tmp_path):
"""A later sanctioned resume takes precedence over an older pause."""
from fenris.status import get_status
db = tmp_path / "observations.db"
conn = init_store(db)
conn.execute(
"INSERT INTO monitoring_periods (started_at, ended_at, end_cause) "
"VALUES (?, ?, ?)",
("2026-09-01T09:00:00+00:00", "2026-09-01T10:00:00+00:00", "user_disabled"),
)
conn.execute(
"INSERT INTO monitoring_periods (started_at) VALUES (?)",
("2026-09-01T11:00:00+00:00",),
)
conn.commit()
conn.close()
now = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
with patch("fenris.status.query_service_state", return_value={
"boot_enabled": True, "timer_active": True,
"last_collect_ok": None, "last_collect_age_s": None,
"last_collect_reason": None,
}):
result = get_status(store_path=db, clock_now=now,
query_services=True, query_journal=False)
assert "monitoring: paused — deliberate disable" not in result
def test_live_enabled_service_suppresses_a_stale_pause_marker(self, tmp_path):
"""A raw re-enable cannot leave a contradictory paused presentation."""
from fenris.status import get_status
db = tmp_path / "observations.db"
conn = init_store(db)
conn.execute(
"INSERT INTO monitoring_periods (started_at, ended_at, end_cause) "
"VALUES (?, ?, ?)",
("2026-09-01T09:00:00+00:00", "2026-09-01T10:00:00+00:00", "user_disabled"),
)
conn.commit()
conn.close()
now = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
with patch("fenris.status.query_service_state", return_value={
"boot_enabled": True, "timer_active": True,
"last_collect_ok": None, "last_collect_age_s": None,
"last_collect_reason": None,
}):
result = get_status(store_path=db, clock_now=now,
query_services=True, query_journal=False)
assert "monitoring: paused — deliberate disable" not in result
# ---------------------------------------------------------------------------
# Status output structure (§8.8, LC-9)
@@ -441,6 +566,29 @@ class TestStatusOutput:
assert isinstance(result, str)
assert len(result) > 0
def test_status_excludes_tui_identity_and_auth_notice(self, tmp_path):
"""CLI status never renders TUI-only identity or launch guidance."""
from fenris.status import get_status
db = tmp_path / "observations.db"
init_store(db)
now = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
with patch("fenris.status.query_service_state", return_value={
"boot_enabled": False, "timer_active": False,
"last_collect_ok": None, "last_collect_age_s": None,
"last_collect_reason": None,
}):
result = get_status(store_path=db, clock_now=now,
query_services=True, query_journal=False)
for tui_only in (
"Fenris — NVMe endurance monitor",
"by Bongbetic",
"privileged actions will prompt for authentication (polkit)",
):
assert tui_only not in result
def test_status_never_writes(self, tmp_path):
"""Status never writes to the store."""
from fenris.status import get_status
+56
View File
@@ -0,0 +1,56 @@
"""Store path resolution from config — regression coverage for issue #53.
A fresh install ships a placeholder-commented config whose only required
key is the device selector. The collector must not crash with
KeyError 'store_path' when the key is absent.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from fenris.store import DEFAULT_STORE_PATH, get_store_path
REPO_ROOT = Path(__file__).resolve().parent.parent
TEMPLATE = REPO_ROOT / "packaging" / "fenris.conf"
def _parse_like_load_config(text: str) -> dict:
"""Mirror collect.load_config()'s key=value parsing rules."""
config = {}
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
if "=" in line:
key, value = line.split("=", 1)
config[key.strip()] = value.strip()
return config
def test_missing_store_path_falls_back_to_default():
"""Config with only the device selector resolves to the packaged default."""
assert get_store_path({"device": "/dev/nvme0n1"}) == DEFAULT_STORE_PATH
def test_explicit_store_path_wins():
"""An explicit store_path override is honored."""
assert get_store_path({"store_path": "/tmp/other.db"}) == Path("/tmp/other.db")
def test_packaged_template_yields_collectable_config():
"""The packaged template, once a device is set, must be collector-ready.
Reproduces the fresh-install path: parse packaging/fenris.conf the way
collect.load_config() does, add the device selector, then resolve the
store. Issue #53 made this raise KeyError.
"""
config = _parse_like_load_config(TEMPLATE.read_text())
config["device"] = "/dev/nvme0n1"
assert get_store_path(config) == DEFAULT_STORE_PATH
def test_template_documents_store_path():
"""The template must mention store_path so admins know it is overridable."""
assert "store_path" in TEMPLATE.read_text()
+79
View File
@@ -0,0 +1,79 @@
"""Group access to the observation store — regression coverage for issue #54.
Two defects: (1) a non-group user's stat() on the store directory raised
PermissionError straight through open_store_readonly(), crashing status/TUI
instead of degrading to the Store fault view; (2) even group members could
not open the WAL-mode store because root-created sidecars lacked group write
and the store directory lacked group execute-then-write.
"""
import sqlite3
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from fenris.store import DEFAULT_STORE_PATH, init_store
from fenris.status import StoreFault, open_store_readonly
def test_stat_permission_error_becomes_store_fault(monkeypatch, tmp_path):
"""stat() denied (non-group user on a 2750 dir) → StoreFault, not crash."""
store = tmp_path / "observations.db"
store.write_bytes(b"")
import pathlib
def denied(self, follow_symlinks=True):
raise PermissionError(13, "Permission denied")
monkeypatch.setattr(pathlib.Path, "exists", denied)
with pytest.raises(StoreFault):
open_store_readonly(store)
def test_connect_failure_becomes_store_fault(tmp_path):
"""sqlite failures stay wrapped as StoreFault (existing contract)."""
garbage = tmp_path / "observations.db"
garbage.write_bytes(b"not a database" * 100)
with pytest.raises(StoreFault):
open_store_readonly(garbage)
def test_init_store_leaves_files_group_writable(tmp_path):
"""Root-created stores must stay readable by WAL readers: db and sidecars
need group write after init_store (issue #54)."""
store = tmp_path / "observations.db"
conn = init_store(store)
try:
assert (store.stat().st_mode & 0o060) == 0o060, "db not group rw"
wal = store.with_name(store.name + "-wal")
shm = store.with_name(store.name + "-shm")
if wal.exists():
assert (wal.stat().st_mode & 0o060) == 0o060, "wal not group rw"
if shm.exists():
assert (shm.stat().st_mode & 0o060) == 0o060, "shm not group rw"
finally:
conn.close()
def test_readonly_open_works_after_init_store(tmp_path):
"""The shipped read path opens a store created by init_store."""
store = tmp_path / "observations.db"
writer = init_store(store)
writer.execute("INSERT INTO monitoring_periods (started_at) VALUES ('2026-01-01T00:00:00+00:00')")
writer.commit()
conn = open_store_readonly(store)
assert conn is not None
conn.close()
writer.close()
def test_packaging_ships_group_access():
"""tmpfiles must create the store dir group-writable; collect unit must
keep the umask loose so root-created sidecars stay group-accessible."""
repo = Path(__file__).resolve().parent.parent
assert "2770" in (repo / "packaging" / "tmpfiles.d" / "fenris.conf").read_text()
assert "2750" not in (repo / "packaging" / "tmpfiles.d" / "fenris.conf").read_text()
assert "UMask=002" in (repo / "units" / "fenris-collect.service").read_text()
+155 -1
View File
@@ -10,6 +10,7 @@ Covers:
Criteria: TUI-1, TUI-4, CI-1, CI-4, IN-3.
"""
import sqlite3
from xml.etree import ElementTree
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import patch, MagicMock
@@ -384,6 +385,160 @@ class TestDenseScreen:
assert "timer:" in strip
assert "last collect:" in strip
assert "freshness:" in strip
assert "by Bongbetic" in strip
@pytest.mark.asyncio
async def test_service_strip_shows_continuity_and_separate_quit_rail(self, tmp_path):
"""The visible action footer excludes quit because the rail owns it."""
conn = init_store(tmp_path / "test.db")
_open_period(conn)
conn.close()
app = FenrisTuiApp(store_path=tmp_path / "test.db")
with patch("fenris.tui.query_service_state", return_value={
"boot_enabled": True, "timer_active": True,
"last_collect_ok": True, "last_collect_age_s": 60,
"last_collect_reason": None,
}):
async with app.run_test():
strip = str(app.query_one("#service-strip").render()).lower()
rail = str(app.query_one("#quit-rail").render())
usage = app.query_one("#usage-history")
service = app.query_one("#service-strip")
quit_rail = app.query_one("#quit-rail")
assert "continuity" in strip
assert "monitoring: active in background · persists across reboots" in strip
assert "p pause · r resume · c collect · d disclosures" in strip
assert "q quit" not in strip
assert rail == "q QUIT TUI"
assert usage.region.y < service.region.y < quit_rail.region.y
assert usage.region.bottom <= service.region.y
assert service.region.bottom <= quit_rail.region.y
@pytest.mark.asyncio
async def test_deliberate_pause_banner_is_visible_and_quit_preserves_periods(self, tmp_path):
"""The Pilot sees the paused block; q leaves persisted monitoring state alone."""
db = tmp_path / "test.db"
conn = init_store(db)
conn.execute(
"INSERT INTO monitoring_periods (started_at, ended_at, end_cause) "
"VALUES (?, ?, ?)",
("2026-09-01T09:00:00+00:00", "2026-09-01T10:00:00+00:00", "user_disabled"),
)
conn.commit()
conn.close()
app = FenrisTuiApp(store_path=db)
with patch("fenris.tui.query_service_state", return_value={
"boot_enabled": False, "timer_active": False,
"last_collect_ok": None, "last_collect_age_s": None,
"last_collect_reason": None,
}), patch("fenris.tui.subprocess.run") as subprocess_run, patch.object(
app, "_run_helper"
) as run_helper:
async with app.run_test(size=(80, 24)) as pilot:
await pilot.pause()
paused_banner = app.query_one("#paused-banner")
main_grid = app.query_one("#main-grid")
assert str(main_grid.styles.layout) == "<grid>"
assert main_grid.has_class("paused")
assert len(main_grid.styles.grid_rows) == 5
banner = str(paused_banner.render()).lower()
assert "monitoring: paused — deliberate disable" in banner
assert "paused time is excluded from your usage habit · resume: fenris monitor resume" in banner
assert paused_banner.region.height >= 5
assert paused_banner.region.y < app.query_one("#usage-history").region.y
assert app.query_one("#usage-history").region.bottom <= app.query_one(
"#service-strip"
).region.y
screenshot = app.export_screenshot()
visible_text = " ".join(
"".join(ElementTree.fromstring(screenshot).itertext()).split()
)
assert "paused time is excluded from your usage habit" in visible_text
assert "resume: fenris monitor resume" in visible_text
assert "monitoring: does not start on next boot" in str(
app.query_one("#service-strip").render()
).lower()
dashboard_scroll = app.query_one("#dashboard-scroll")
assert dashboard_scroll.max_scroll_y > 0
dashboard_scroll.focus()
await pilot.press("end")
assert dashboard_scroll.scroll_y == dashboard_scroll.max_scroll_y
footer_text = " ".join(
"".join(
ElementTree.fromstring(app.export_screenshot()).itertext()
).split()
)
assert "q QUIT TUI" in footer_text
await pilot.press("q")
assert not app.is_running
subprocess_run.assert_not_called()
run_helper.assert_not_called()
conn = sqlite3.connect(db)
row = conn.execute(
"SELECT ended_at, end_cause FROM monitoring_periods"
).fetchone()
conn.close()
assert row == ("2026-09-01T10:00:00+00:00", "user_disabled")
@pytest.mark.asyncio
async def test_quit_preserves_an_active_monitoring_period(self, tmp_path):
"""Quitting an active dashboard never closes or mutates its period."""
db = tmp_path / "test.db"
conn = init_store(db)
_open_period(conn, "2026-09-01T09:00:00+00:00")
conn.close()
app = FenrisTuiApp(store_path=db)
with patch("fenris.tui.query_service_state", return_value={
"boot_enabled": True, "timer_active": True,
"last_collect_ok": None, "last_collect_age_s": None,
"last_collect_reason": None,
}), patch("fenris.tui.subprocess.run") as subprocess_run, patch.object(
app, "_run_helper"
) as run_helper:
async with app.run_test() as pilot:
await pilot.press("q")
assert not app.is_running
subprocess_run.assert_not_called()
run_helper.assert_not_called()
conn = sqlite3.connect(db)
row = conn.execute(
"SELECT started_at, ended_at, end_cause FROM monitoring_periods"
).fetchone()
conn.close()
assert row == ("2026-09-01T09:00:00+00:00", None, None)
@pytest.mark.asyncio
async def test_branding_and_one_time_auth_banner(self, tmp_path):
"""Identity is visible at launch; auth notice clears once per session."""
app = FenrisTuiApp(
store_path=tmp_path / "nonexistent.db",
refresh_interval_s=0.2,
)
auth_notice = "privileged actions will prompt for authentication (polkit)"
async with app.run_test() as pilot:
headline = str(app.query_one("#headline-band").render())
assert "Fenris — NVMe endurance monitor" in headline
assert auth_notice in headline
assert "by Bongbetic" in str(app.query_one("#service-strip").render())
await pilot.pause(0.25)
assert auth_notice not in str(app.query_one("#headline-band").render())
await pilot.pause(0.25)
assert auth_notice not in str(app.query_one("#headline-band").render())
fresh_app = FenrisTuiApp(
store_path=tmp_path / "nonexistent.db",
refresh_interval_s=0.2,
)
async with fresh_app.run_test():
assert auth_notice in str(fresh_app.query_one("#headline-band").render())
# ---------------------------------------------------------------------------
@@ -524,4 +679,3 @@ class TestStateMatrixCombinations:
# Incomplete provenance → UNVERIFIED tier
assert proj.baseline_tier.value == "unverified_override"
conn.close()
+1
View File
@@ -7,3 +7,4 @@ After=local-fs.target
Type=oneshot
ExecStart=/usr/libexec/fenris/fenris-collect
TimeoutStartSec=90
UMask=002