Compare commits
16
Commits
v0.3.0
...
4f2f30abac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f2f30abac | ||
|
|
5d916ee97f | ||
|
|
6917a658cb | ||
|
|
d790ff84c5 | ||
|
|
f406285a0f | ||
|
|
608ad7f823 | ||
|
|
cfdef63388 | ||
|
|
f077fa671e | ||
|
|
d01df6468f | ||
|
|
7bbe5cede7 | ||
|
|
bb5bc9a72e | ||
|
|
4a7661d81c | ||
|
|
fb683f52ba | ||
|
|
512df2ae83 | ||
|
|
bcbc97a947 | ||
|
|
b593a2742e |
@@ -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
@@ -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/
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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,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
@@ -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
|
||||
|
||||
@@ -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}'))
|
||||
|
||||
@@ -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).
|
||||
@@ -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
@@ -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,2 +1,2 @@
|
||||
# Type Path Mode User Group Age Argument
|
||||
d /var/lib/fenris 2750 root fenris - -
|
||||
d /var/lib/fenris 2770 root fenris - -
|
||||
|
||||
+1
-1
@@ -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 = [
|
||||
|
||||
Executable
+99
@@ -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
@@ -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:
|
||||
|
||||
Executable
+70
@@ -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,2 +1,2 @@
|
||||
"""Fenris: NVMe wear monitor with persistent TUI."""
|
||||
__version__ = "0.3.0"
|
||||
__version__ = "0.3.1"
|
||||
|
||||
@@ -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
|
||||
|
||||
+40
-5
@@ -16,6 +16,8 @@ from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
from .store import init_store, get_store_path
|
||||
from .monitoring_periods import ensure_period_open
|
||||
from .derive import find_previous_sample, derive_hours_from_interval
|
||||
|
||||
|
||||
class AcquisitionError(Exception):
|
||||
@@ -221,7 +223,11 @@ def write_sample(
|
||||
open_segment(conn, now, identity, identity_key, identity_degraded)
|
||||
segment_opened = True
|
||||
|
||||
# Insert sample
|
||||
# Get current segment_id for provenance
|
||||
current_segment = find_current_segment(conn)
|
||||
segment_id = current_segment["id"] if current_segment else None
|
||||
|
||||
# Insert sample with segment_id
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
INSERT INTO samples (
|
||||
@@ -229,8 +235,8 @@ def write_sample(
|
||||
percentage_used, available_spare, media_errors, power_on_hours,
|
||||
power_cycles, unsafe_shutdowns, temperature_c,
|
||||
data_units_written, data_units_read, bytes_written, bytes_read,
|
||||
critical_warning
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
critical_warning, segment_id
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
sample["ts"],
|
||||
@@ -252,6 +258,7 @@ def write_sample(
|
||||
sample["bytes_written"],
|
||||
sample["bytes_read"],
|
||||
sample["critical_warning"],
|
||||
segment_id,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -262,6 +269,7 @@ def write_sample(
|
||||
"segment_reason": reason,
|
||||
"identity_key": identity_key,
|
||||
"identity_degraded": identity_degraded,
|
||||
"segment_id": segment_id,
|
||||
}
|
||||
|
||||
|
||||
@@ -303,11 +311,38 @@ def run_collection(
|
||||
|
||||
|
||||
try:
|
||||
# Ensure monitoring period is open (issue #73 AC2)
|
||||
ensure_period_open(conn, clock.utcnow())
|
||||
|
||||
# Validate invariants
|
||||
validate_sample_invariants(sample, conn)
|
||||
|
||||
# Write sample
|
||||
write_sample(sample, identity, conn, clock)
|
||||
# Write sample and get segment info
|
||||
seg_info = write_sample(sample, identity, conn, clock)
|
||||
|
||||
# Derive hour observations from interval with previous sample
|
||||
try:
|
||||
# Find the sample we just wrote
|
||||
cursor = conn.execute("SELECT id FROM samples ORDER BY id DESC LIMIT 1")
|
||||
current_id = cursor.fetchone()[0]
|
||||
|
||||
prev = find_previous_sample(conn, seg_info.get("segment_id"), current_id)
|
||||
if prev is not None:
|
||||
# Build current sample dict for derivation
|
||||
current = {
|
||||
"id": current_id,
|
||||
"ts": sample["ts"],
|
||||
"bytes_written": sample["bytes_written"],
|
||||
"bytes_read": sample["bytes_read"],
|
||||
"power_on_hours": sample["power_on_hours"],
|
||||
"temperature_c": sample["temperature_c"],
|
||||
"data_units_written": sample["data_units_written"],
|
||||
"data_units_read": sample["data_units_read"],
|
||||
}
|
||||
derive_hours_from_interval(conn, prev, current)
|
||||
except Exception:
|
||||
# Derivation failure must not prevent sample persistence (issue #73 AC6)
|
||||
pass
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
"""Interval derivation: samples → hour observations → day aggregates.
|
||||
|
||||
After each collection run, the collector calls into this module to:
|
||||
1. Find the previous sample in the same segment
|
||||
2. Compute deltas (bytes, POH, temperature)
|
||||
3. Classify the hour(s) the interval spans
|
||||
4. Write/update hour_observations for each affected hour
|
||||
5. Update day_aggregates with unattributed cross-hour bytes
|
||||
|
||||
Cross-hour deltas are retained once with unknown shares explicit (issue #73 AC4).
|
||||
No proportional allocation, endpoint assignment, or double counting.
|
||||
"""
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from .hour_classify import classify_hour, HourSplit
|
||||
|
||||
|
||||
def find_previous_sample(
|
||||
conn: sqlite3.Connection,
|
||||
segment_id: Optional[int],
|
||||
current_sample_id: int,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Find the most recent sample before current_sample_id in the same segment.
|
||||
|
||||
Returns None if no previous sample exists (first sample in segment).
|
||||
"""
|
||||
if segment_id is not None:
|
||||
cursor = conn.execute(
|
||||
"SELECT id, ts, bytes_written, bytes_read, power_on_hours, "
|
||||
" temperature_c, data_units_written, data_units_read "
|
||||
"FROM samples WHERE id < ? AND segment_id = ? "
|
||||
"ORDER BY id DESC LIMIT 1",
|
||||
(current_sample_id, segment_id),
|
||||
)
|
||||
else:
|
||||
cursor = conn.execute(
|
||||
"SELECT id, ts, bytes_written, bytes_read, power_on_hours, "
|
||||
" temperature_c, data_units_written, data_units_read "
|
||||
"FROM samples WHERE id < ? "
|
||||
"ORDER BY id DESC LIMIT 1",
|
||||
(current_sample_id,),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return {
|
||||
"id": row[0], "ts": row[1], "bytes_written": row[2],
|
||||
"bytes_read": row[3], "power_on_hours": row[4],
|
||||
"temperature_c": row[5], "data_units_written": row[6],
|
||||
"data_units_read": row[7],
|
||||
}
|
||||
|
||||
|
||||
def _parse_ts(ts: str) -> datetime:
|
||||
"""Parse ISO timestamp to datetime with UTC."""
|
||||
dt = datetime.fromisoformat(ts)
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt
|
||||
|
||||
|
||||
def _hour_floor(dt: datetime) -> datetime:
|
||||
"""Floor a datetime to its UTC hour boundary."""
|
||||
return dt.replace(minute=0, second=0, microsecond=0)
|
||||
|
||||
|
||||
def _hours_spanned(start: datetime, end: datetime) -> List[datetime]:
|
||||
"""Return list of UTC hour boundaries spanned by [start, end)."""
|
||||
hours = []
|
||||
h = _hour_floor(start)
|
||||
while h < end:
|
||||
hours.append(h)
|
||||
h += timedelta(hours=1)
|
||||
return hours
|
||||
|
||||
|
||||
def _compute_sampled_seconds_in_hour(
|
||||
start: datetime, end: datetime, hour_start: datetime
|
||||
) -> int:
|
||||
"""How many seconds of the sample interval fall within this hour."""
|
||||
hour_end = hour_start + timedelta(hours=1)
|
||||
effective_start = max(start, hour_start)
|
||||
effective_end = min(end, hour_end)
|
||||
if effective_start >= effective_end:
|
||||
return 0
|
||||
return int((effective_end - effective_start).total_seconds())
|
||||
|
||||
|
||||
def derive_hours_from_interval(
|
||||
conn: sqlite3.Connection,
|
||||
prev_sample: Dict[str, Any],
|
||||
next_sample: Dict[str, Any],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Derive hour observations from a sample pair interval.
|
||||
|
||||
Returns list of hour observation dicts that were written/updated.
|
||||
"""
|
||||
prev_ts = _parse_ts(prev_sample["ts"])
|
||||
next_ts = _parse_ts(next_sample["ts"])
|
||||
|
||||
# Deltas
|
||||
bw_delta = max(0, next_sample["bytes_written"] - prev_sample["bytes_written"])
|
||||
br_delta = max(0, next_sample["bytes_read"] - prev_sample["bytes_read"])
|
||||
poh_delta_s = max(0, (next_sample["power_on_hours"] - prev_sample["power_on_hours"])) * 3600
|
||||
|
||||
hours = _hours_spanned(prev_ts, next_ts)
|
||||
total_span_s = int((next_ts - prev_ts).total_seconds())
|
||||
|
||||
results = []
|
||||
|
||||
if len(hours) == 1:
|
||||
# Same-hour interval: fully attributed to this hour
|
||||
hour_key = hours[0].strftime("%Y-%m-%dT%H:00:00+00:00")
|
||||
sampled_s = total_span_s
|
||||
|
||||
# Classify hour
|
||||
split = classify_hour(
|
||||
wall_clock_seconds=3600,
|
||||
poh_delta=poh_delta_s,
|
||||
duw_delta=bw_delta,
|
||||
dur_delta=br_delta,
|
||||
sampled_seconds=sampled_s,
|
||||
)
|
||||
|
||||
_upsert_hour_observation(
|
||||
conn, hour_key, split,
|
||||
bw_delta, br_delta,
|
||||
prev_sample.get("temperature_c"), next_sample.get("temperature_c"),
|
||||
2, # 2 samples contributed (prev + next)
|
||||
)
|
||||
results.append({"hour": hour_key, "bytes_written": bw_delta, "attributed": True})
|
||||
|
||||
elif len(hours) >= 2:
|
||||
# Cross-hour interval: split wall-clock time, bytes unattributed
|
||||
for h in hours:
|
||||
hour_key = h.strftime("%Y-%m-%dT%H:00:00+00:00")
|
||||
sampled_s = _compute_sampled_seconds_in_hour(prev_ts, next_ts, h)
|
||||
|
||||
# For cross-hour, we classify based on time only (no byte attribution)
|
||||
# The hour gets its time split but NOT the byte delta
|
||||
split = classify_hour(
|
||||
wall_clock_seconds=3600,
|
||||
poh_delta=0, # POH attribution unknown for cross-hour
|
||||
duw_delta=0, # Bytes unattributed
|
||||
dur_delta=0,
|
||||
sampled_seconds=sampled_s,
|
||||
)
|
||||
|
||||
_upsert_hour_observation(
|
||||
conn, hour_key, split,
|
||||
0, 0, # No byte attribution for cross-hour
|
||||
None, None,
|
||||
0, # No sample falls IN this hour
|
||||
)
|
||||
results.append({"hour": hour_key, "bytes_written": 0, "attributed": False})
|
||||
|
||||
# Track unattributed bytes at day level
|
||||
_add_unattributed_bytes(conn, prev_ts, next_ts, bw_delta, br_delta)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _upsert_hour_observation(
|
||||
conn: sqlite3.Connection,
|
||||
hour_key: str,
|
||||
split: HourSplit,
|
||||
bw_delta: int,
|
||||
br_delta: int,
|
||||
temp_min: Optional[int],
|
||||
temp_max: Optional[int],
|
||||
sample_count: int,
|
||||
) -> None:
|
||||
"""Insert or update an hour observation."""
|
||||
# Check if hour exists
|
||||
existing = conn.execute(
|
||||
"SELECT id, bytes_written_delta, sample_count FROM hour_observations WHERE hour = ?",
|
||||
(hour_key,),
|
||||
).fetchone()
|
||||
|
||||
if existing is None:
|
||||
temp_avg = ((temp_min or 0) + (temp_max or 0)) / 2 if temp_min is not None else None
|
||||
coverage = (split.seconds_active + split.seconds_idle + split.seconds_powered_off) / 3600.0
|
||||
conn.execute(
|
||||
"""INSERT INTO hour_observations
|
||||
(hour, active_seconds, idle_seconds, powered_off_seconds, unknown_seconds,
|
||||
bytes_written_delta, bytes_read_delta,
|
||||
temperature_min, temperature_avg, temperature_max,
|
||||
sample_count, coverage)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(hour_key, split.seconds_active, split.seconds_idle,
|
||||
split.seconds_powered_off, split.seconds_unknown,
|
||||
bw_delta, br_delta,
|
||||
temp_min, temp_avg, temp_max,
|
||||
sample_count, coverage),
|
||||
)
|
||||
else:
|
||||
# Merge: accumulate bytes and sample count
|
||||
new_bw = existing[1] + bw_delta
|
||||
new_samples = existing[2] + sample_count
|
||||
conn.execute(
|
||||
"UPDATE hour_observations SET bytes_written_delta = ?, sample_count = ? WHERE id = ?",
|
||||
(new_bw, new_samples, existing[0]),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _add_unattributed_bytes(
|
||||
conn: sqlite3.Connection,
|
||||
prev_ts: datetime,
|
||||
next_ts: datetime,
|
||||
bw_delta: int,
|
||||
br_delta: int,
|
||||
) -> None:
|
||||
"""Add unattributed byte deltas to day aggregates for each day touched."""
|
||||
prev_day = prev_ts.strftime("%Y-%m-%d")
|
||||
next_day = next_ts.strftime("%Y-%m-%d")
|
||||
|
||||
days = {prev_day, next_day}
|
||||
for day in days:
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM day_aggregates WHERE day = ?", (day,)
|
||||
).fetchone()
|
||||
if existing is None:
|
||||
conn.execute(
|
||||
"INSERT INTO day_aggregates (day, unattributed_bytes_written, unattributed_bytes_read) "
|
||||
"VALUES (?, ?, ?)",
|
||||
(day, bw_delta, br_delta),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE day_aggregates SET unattributed_bytes_written = unattributed_bytes_written + ?, "
|
||||
"unattributed_bytes_read = unattributed_bytes_read + ? WHERE day = ?",
|
||||
(bw_delta, br_delta, day),
|
||||
)
|
||||
conn.commit()
|
||||
@@ -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)
|
||||
|
||||
|
||||
+39
-11
@@ -73,6 +73,7 @@ class ScenarioRange:
|
||||
rates: Dict[int, float]
|
||||
min_days: int
|
||||
max_days: int
|
||||
horizon_reasons: Dict[int, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -234,20 +235,39 @@ def _compute_regime_rate(days, conn, regime_start_day, clock_now):
|
||||
return regime_bytes / regime_wc, regime_bytes, regime_wc
|
||||
|
||||
|
||||
def _compute_horizon_rate(days, conn, horizon_days, clock_now):
|
||||
cutoff = (clock_now - timedelta(days=horizon_days)).strftime("%Y-%m-%d")
|
||||
def _compute_horizon_rate(days, conn, horizon_days, evidence_endpoint):
|
||||
"""Compute horizon rate anchored at the latest evidence endpoint T.
|
||||
|
||||
Uses exact trailing horizon_days × 86400 seconds from T, not clock_now.
|
||||
Reader refresh alone never moves T or dilutes rates.
|
||||
|
||||
Returns (rate, reason) where reason is None on success or a string
|
||||
describing why the rate is unavailable.
|
||||
"""
|
||||
if not days:
|
||||
return None, "no observation history"
|
||||
|
||||
# T is the latest evidence endpoint — the end of the last day aggregate
|
||||
T = datetime.fromisoformat(days[-1]["day"] + "T23:59:59+00:00")
|
||||
|
||||
# Exact trailing start: T minus horizon_days × 86400 seconds
|
||||
h_start = T - timedelta(days=horizon_days)
|
||||
cutoff = h_start.strftime("%Y-%m-%d")
|
||||
|
||||
# History must span the full horizon — no placeholders
|
||||
if not days or days[0]["day"] > cutoff:
|
||||
return None
|
||||
if days[0]["day"] > cutoff:
|
||||
return None, "%d-day window starts before earliest data" % horizon_days
|
||||
|
||||
h_bytes = sum(d["bytes_written"] for d in days if d["day"] >= cutoff)
|
||||
covered = sum(1 for d in days if d["day"] >= cutoff)
|
||||
if covered == 0:
|
||||
return None
|
||||
h_start = datetime.fromisoformat(cutoff + "T00:00:00+00:00")
|
||||
h_wc = _wall_clock_in_range(conn, h_start, clock_now)
|
||||
return None, "%d-day window has no data" % horizon_days
|
||||
|
||||
h_wc = _wall_clock_in_range(conn, h_start, T)
|
||||
if h_wc <= 0:
|
||||
return None
|
||||
return h_bytes / h_wc
|
||||
return None, "%d-day window has no monitored wall-clock time" % horizon_days
|
||||
|
||||
return h_bytes / h_wc, None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -508,12 +528,20 @@ def compute_projection(conn, clock_now):
|
||||
|
||||
scenario = None
|
||||
horizon_rates = {}
|
||||
horizon_reasons = {}
|
||||
for h in HORIZON_DAYS:
|
||||
hr = _compute_horizon_rate(all_days, conn, h, clock_now)
|
||||
hr, reason = _compute_horizon_rate(all_days, conn, h, clock_now)
|
||||
if hr is not None:
|
||||
horizon_rates[h] = hr
|
||||
else:
|
||||
horizon_reasons[h] = reason
|
||||
if horizon_rates:
|
||||
scenario = ScenarioRange(rates=horizon_rates, min_days=min(horizon_rates), max_days=max(horizon_rates))
|
||||
scenario = ScenarioRange(
|
||||
rates=horizon_rates,
|
||||
min_days=min(horizon_rates),
|
||||
max_days=max(horizon_rates),
|
||||
horizon_reasons=horizon_reasons,
|
||||
)
|
||||
|
||||
total_days_count = len(segment_days)
|
||||
days_below_coverage = sum(1 for d in segment_days
|
||||
|
||||
+133
-2
@@ -2,6 +2,7 @@
|
||||
|
||||
Raw samples are pruned opportunistically to 14 days.
|
||||
Hour observations and day aggregates are retained indefinitely.
|
||||
Boundary anchors required for successor evidence are retained.
|
||||
"""
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta, timezone
|
||||
@@ -10,6 +11,117 @@ from datetime import datetime, timedelta, timezone
|
||||
RAW_SAMPLE_RETENTION_DAYS = 14
|
||||
|
||||
|
||||
def needs_boundary_anchor(
|
||||
conn: sqlite3.Connection,
|
||||
sample_ts: str,
|
||||
now: datetime,
|
||||
) -> bool:
|
||||
"""Check if a sample is needed as a boundary anchor for derivation.
|
||||
|
||||
A sample is a boundary anchor if:
|
||||
1. It's older than retention_days (strictly before cutoff)
|
||||
2. It has a next sample that forms an interval spanning the retention boundary
|
||||
3. The interval hasn't been derived yet
|
||||
|
||||
The interval spans the boundary if:
|
||||
- The sample is before the cutoff, AND
|
||||
- The next sample is strictly after the cutoff (or within retention)
|
||||
"""
|
||||
from .derive import _parse_ts
|
||||
|
||||
sample_dt = _parse_ts(sample_ts)
|
||||
retention_cutoff = now - timedelta(days=RAW_SAMPLE_RETENTION_DAYS)
|
||||
|
||||
# If sample is within retention (strictly after cutoff), not an anchor
|
||||
if sample_dt > retention_cutoff:
|
||||
return False
|
||||
|
||||
# Check if this sample has a next sample
|
||||
cursor = conn.execute(
|
||||
"""SELECT ts, segment_id FROM samples WHERE ts > ? ORDER BY ts LIMIT 1""",
|
||||
(sample_ts,),
|
||||
)
|
||||
next_row = cursor.fetchone()
|
||||
|
||||
if next_row is None:
|
||||
# No next sample - this is the last sample
|
||||
# It's not needed for derivation (no interval to derive)
|
||||
return False
|
||||
|
||||
next_ts_str = next_row[0]
|
||||
next_segment_id = next_row[1]
|
||||
next_dt = _parse_ts(next_ts_str)
|
||||
|
||||
# Check if the next sample is strictly after the cutoff (i.e., interval spans boundary)
|
||||
if next_dt > retention_cutoff:
|
||||
# The interval spans the retention boundary
|
||||
# Check if the interval needs derivation
|
||||
# Get current sample's segment_id
|
||||
cursor = conn.execute(
|
||||
"SELECT segment_id FROM samples WHERE ts = ?",
|
||||
(sample_ts,),
|
||||
)
|
||||
current_segment_row = cursor.fetchone()
|
||||
current_segment_id = current_segment_row[0] if current_segment_row else None
|
||||
|
||||
# If different segments, no interval to derive
|
||||
if current_segment_id != next_segment_id:
|
||||
return False
|
||||
|
||||
# Check if the interval [sample_ts, next_ts] needs derivation
|
||||
# It needs derivation if any hour in the span lacks an observation
|
||||
current_hour = sample_dt.replace(minute=0, second=0, microsecond=0)
|
||||
end_hour = next_dt.replace(minute=0, second=0, microsecond=0)
|
||||
|
||||
while current_hour <= end_hour:
|
||||
cursor = conn.execute(
|
||||
"SELECT id FROM hour_observations WHERE hour = ?",
|
||||
(current_hour.isoformat(),),
|
||||
)
|
||||
if cursor.fetchone() is None:
|
||||
# This hour lacks an observation - interval needs derivation
|
||||
return True
|
||||
current_hour += timedelta(hours=1)
|
||||
|
||||
# All hours in the span have observations - interval is derived
|
||||
return False
|
||||
else:
|
||||
# The interval doesn't span the boundary (both samples are old)
|
||||
# Check if the interval needs derivation
|
||||
# Get current sample's segment_id
|
||||
cursor = conn.execute(
|
||||
"SELECT segment_id FROM samples WHERE ts = ?",
|
||||
(sample_ts,),
|
||||
)
|
||||
current_segment_row = cursor.fetchone()
|
||||
current_segment_id = current_segment_row[0] if current_segment_row else None
|
||||
|
||||
# If different segments, no interval to derive
|
||||
if current_segment_id != next_segment_id:
|
||||
return False
|
||||
|
||||
# Check if the interval [sample_ts, next_ts] needs derivation
|
||||
current_hour = sample_dt.replace(minute=0, second=0, microsecond=0)
|
||||
end_hour = next_dt.replace(minute=0, second=0, microsecond=0)
|
||||
|
||||
while current_hour <= end_hour:
|
||||
cursor = conn.execute(
|
||||
"SELECT id FROM hour_observations WHERE hour = ?",
|
||||
(current_hour.isoformat(),),
|
||||
)
|
||||
if cursor.fetchone() is None:
|
||||
# This hour lacks an observation - interval needs derivation
|
||||
# But only keep if the interval is significant (spans multiple hours)
|
||||
# or if the next sample is the last sample before a gap
|
||||
gap = (next_dt - sample_dt).total_seconds()
|
||||
if gap > 24 * 3600: # Significant gap (> 24 hours)
|
||||
return True
|
||||
current_hour += timedelta(hours=1)
|
||||
|
||||
# All hours in the span have observations or gap is not significant
|
||||
return False
|
||||
|
||||
|
||||
def prune_old_samples(
|
||||
conn: sqlite3.Connection,
|
||||
now: datetime,
|
||||
@@ -17,6 +129,8 @@ def prune_old_samples(
|
||||
) -> int:
|
||||
"""Remove raw samples older than retention_days.
|
||||
|
||||
Retains boundary anchors required for successor evidence.
|
||||
|
||||
Args:
|
||||
conn: Connection to the observation store.
|
||||
now: Current UTC time.
|
||||
@@ -26,6 +140,23 @@ def prune_old_samples(
|
||||
Number of samples removed.
|
||||
"""
|
||||
cutoff = (now - timedelta(days=retention_days)).isoformat()
|
||||
cursor = conn.execute("DELETE FROM samples WHERE ts < ?", (cutoff,))
|
||||
|
||||
# Get all samples older than cutoff
|
||||
cursor = conn.execute(
|
||||
"SELECT id, ts FROM samples WHERE ts < ? ORDER BY ts",
|
||||
(cutoff,),
|
||||
)
|
||||
old_samples = cursor.fetchall()
|
||||
|
||||
removed = 0
|
||||
for sample_id, sample_ts in old_samples:
|
||||
# Check if this sample is a boundary anchor
|
||||
if needs_boundary_anchor(conn, sample_ts, now):
|
||||
continue # Skip - it's a boundary anchor
|
||||
|
||||
# Remove the sample
|
||||
conn.execute("DELETE FROM samples WHERE id = ?", (sample_id,))
|
||||
removed += 1
|
||||
|
||||
conn.commit()
|
||||
return cursor.rowcount
|
||||
return removed
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
"""Historical repair and retention (issue #74).
|
||||
|
||||
Re-derives hour observations and day aggregates from surviving raw samples,
|
||||
with idempotent and interruption-safe guarantees. Preserves import markers,
|
||||
boundary anchors, and valid historical summaries.
|
||||
|
||||
Contracts:
|
||||
- Idempotent: running repair multiple times produces no duplicates
|
||||
- Interruption-safe: partial repair preserves prior valid history
|
||||
- Preserves existing valid data: never overwrites valid derived data
|
||||
- Surfaces failures explicitly for retry
|
||||
"""
|
||||
import logging
|
||||
import sqlite3
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional, List, Tuple
|
||||
|
||||
from .derive import find_previous_sample, derive_hours_from_interval, _parse_ts
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RepairResult:
|
||||
"""Result of a repair operation."""
|
||||
ok: bool
|
||||
hours_created: int = 0
|
||||
hours_updated: int = 0
|
||||
days_created: int = 0
|
||||
days_updated: int = 0
|
||||
intervals_derived: int = 0
|
||||
boundary_anchors_retained: int = 0
|
||||
legacy_summaries_preserved: int = 0
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RepairStatus:
|
||||
"""Current repair status for read-only views."""
|
||||
last_repair: Optional[str] = None # ISO timestamp of last successful repair
|
||||
repair_in_progress: bool = False
|
||||
hours_derived: int = 0
|
||||
days_derived: int = 0
|
||||
|
||||
|
||||
def _ensure_repair_metadata(conn: sqlite3.Connection) -> None:
|
||||
"""Ensure metadata table exists for tracking repair state."""
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS store_metadata (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
def is_repair_in_progress(conn: sqlite3.Connection) -> bool:
|
||||
"""Check if a repair operation is currently in progress."""
|
||||
_ensure_repair_metadata(conn)
|
||||
cursor = conn.execute(
|
||||
"SELECT value FROM store_metadata WHERE key = 'repair_in_progress'"
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
return row is not None and row[0] == "true"
|
||||
|
||||
|
||||
def _set_repair_in_progress(conn: sqlite3.Connection, in_progress: bool) -> None:
|
||||
"""Mark repair as in progress or complete."""
|
||||
_ensure_repair_metadata(conn)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO store_metadata (key, value) VALUES (?, ?)",
|
||||
("repair_in_progress", "true" if in_progress else "false"),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _update_repair_status(conn: sqlite3.Connection, result: RepairResult) -> None:
|
||||
"""Update repair status after successful completion."""
|
||||
_ensure_repair_metadata(conn)
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
# Update last repair timestamp
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO store_metadata (key, value) VALUES (?, ?)",
|
||||
("last_repair", now),
|
||||
)
|
||||
|
||||
# Update derived counts
|
||||
cursor = conn.execute("SELECT COUNT(*) FROM hour_observations")
|
||||
hours = cursor.fetchone()[0]
|
||||
|
||||
cursor = conn.execute("SELECT COUNT(*) FROM day_aggregates")
|
||||
days = cursor.fetchone()[0]
|
||||
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO store_metadata (key, value) VALUES (?, ?)",
|
||||
("hours_derived", str(hours)),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO store_metadata (key, value) VALUES (?, ?)",
|
||||
("days_derived", str(days)),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
|
||||
|
||||
def get_repair_status(conn: sqlite3.Connection) -> RepairStatus:
|
||||
"""Get current repair status for read-only views."""
|
||||
_ensure_repair_metadata(conn)
|
||||
|
||||
last_repair = None
|
||||
cursor = conn.execute(
|
||||
"SELECT value FROM store_metadata WHERE key = 'last_repair'"
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
last_repair = row[0]
|
||||
|
||||
in_progress = is_repair_in_progress(conn)
|
||||
|
||||
hours_derived = 0
|
||||
cursor = conn.execute(
|
||||
"SELECT value FROM store_metadata WHERE key = 'hours_derived'"
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
hours_derived = int(row[0])
|
||||
|
||||
days_derived = 0
|
||||
cursor = conn.execute(
|
||||
"SELECT value FROM store_metadata WHERE key = 'days_derived'"
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
days_derived = int(row[0])
|
||||
|
||||
return RepairStatus(
|
||||
last_repair=last_repair,
|
||||
repair_in_progress=in_progress,
|
||||
hours_derived=hours_derived,
|
||||
days_derived=days_derived,
|
||||
)
|
||||
|
||||
|
||||
def needs_boundary_anchor(
|
||||
conn: sqlite3.Connection,
|
||||
sample_ts: str,
|
||||
now: datetime,
|
||||
) -> bool:
|
||||
"""Check if a sample is needed as a boundary anchor for derivation.
|
||||
|
||||
A sample is a boundary anchor if:
|
||||
1. It's older than retention_days
|
||||
2. It has no derived hour observation for its hour
|
||||
3. It's the last sample before a gap that needs derivation (gap > 24 hours)
|
||||
"""
|
||||
from .pruning import RAW_SAMPLE_RETENTION_DAYS
|
||||
|
||||
sample_dt = _parse_ts(sample_ts)
|
||||
retention_cutoff = now - timedelta(days=RAW_SAMPLE_RETENTION_DAYS)
|
||||
|
||||
# If sample is within retention, not an anchor (will be kept anyway)
|
||||
if sample_dt >= retention_cutoff:
|
||||
return False
|
||||
|
||||
# Check if this sample's hour already has a derived observation
|
||||
hour_start = sample_dt.replace(minute=0, second=0, microsecond=0).isoformat()
|
||||
hour_end = (sample_dt + timedelta(hours=1)).replace(minute=0, second=0, microsecond=0).isoformat()
|
||||
|
||||
cursor = conn.execute(
|
||||
"""SELECT COUNT(*) FROM hour_observations
|
||||
WHERE hour >= ? AND hour < ?""",
|
||||
(hour_start, hour_end),
|
||||
)
|
||||
|
||||
# If there's an hour observation in this sample's hour, it's been derived
|
||||
if cursor.fetchone()[0] > 0:
|
||||
return False
|
||||
|
||||
# Check if this sample is the last sample before a gap
|
||||
# (i.e., the next sample is significantly later)
|
||||
cursor = conn.execute(
|
||||
"""SELECT ts FROM samples WHERE ts > ? ORDER BY ts LIMIT 1""",
|
||||
(sample_ts,),
|
||||
)
|
||||
next_row = cursor.fetchone()
|
||||
|
||||
if next_row is None:
|
||||
# No next sample - this is the last sample, might be needed
|
||||
# But if it's old and fully derived, it's not needed
|
||||
return False
|
||||
|
||||
next_ts = _parse_ts(next_row[0])
|
||||
gap = (next_ts - sample_dt).total_seconds()
|
||||
|
||||
# If gap > 24 hours, this sample is a boundary anchor
|
||||
# (needed to derive the interval spanning the gap)
|
||||
return gap > 24 * 3600
|
||||
|
||||
|
||||
def _get_unlinked_intervals(conn: sqlite3.Connection) -> List[Tuple[dict, dict]]:
|
||||
"""Find sample pairs that form intervals but have no hour observations."""
|
||||
cursor = conn.execute(
|
||||
"""SELECT id, ts, bytes_written, bytes_read, power_on_hours,
|
||||
temperature_c, data_units_written, data_units_read, segment_id
|
||||
FROM samples ORDER BY ts"""
|
||||
)
|
||||
|
||||
all_samples = []
|
||||
for row in cursor.fetchall():
|
||||
all_samples.append({
|
||||
"id": row[0], "ts": row[1], "bytes_written": row[2],
|
||||
"bytes_read": row[3], "power_on_hours": row[4],
|
||||
"temperature_c": row[5], "data_units_written": row[6],
|
||||
"data_units_read": row[7], "segment_id": row[8],
|
||||
})
|
||||
|
||||
intervals = []
|
||||
for i in range(len(all_samples) - 1):
|
||||
prev = all_samples[i]
|
||||
next_s = all_samples[i + 1]
|
||||
|
||||
# Skip if different segments
|
||||
if prev["segment_id"] != next_s["segment_id"]:
|
||||
continue
|
||||
|
||||
# Check if the interval spans hours that need derivation
|
||||
prev_dt = _parse_ts(prev["ts"])
|
||||
next_dt = _parse_ts(next_s["ts"])
|
||||
|
||||
# Check if any hour in the span lacks an observation
|
||||
current = prev_dt.replace(minute=0, second=0, microsecond=0)
|
||||
end = next_dt.replace(minute=0, second=0, microsecond=0)
|
||||
|
||||
needs_derivation = False
|
||||
while current <= end:
|
||||
cursor2 = conn.execute(
|
||||
"SELECT id FROM hour_observations WHERE hour = ?",
|
||||
(current.isoformat(),),
|
||||
)
|
||||
if cursor2.fetchone() is None:
|
||||
needs_derivation = True
|
||||
break
|
||||
current += timedelta(hours=1)
|
||||
|
||||
if needs_derivation:
|
||||
intervals.append((prev, next_s))
|
||||
|
||||
return intervals
|
||||
|
||||
|
||||
def _derive_day_aggregate_from_hours(
|
||||
conn: sqlite3.Connection,
|
||||
day: str,
|
||||
) -> Optional[dict]:
|
||||
"""Derive a day aggregate from its hour observations."""
|
||||
cursor = conn.execute(
|
||||
"""SELECT SUM(active_seconds), SUM(idle_seconds),
|
||||
SUM(powered_off_seconds), SUM(unknown_seconds),
|
||||
SUM(bytes_written_delta), SUM(bytes_read_delta),
|
||||
SUM(sample_count)
|
||||
FROM hour_observations WHERE hour LIKE ?""",
|
||||
(day + "T%",),
|
||||
)
|
||||
|
||||
row = cursor.fetchone()
|
||||
if row is None or row[0] is None:
|
||||
return None
|
||||
|
||||
return {
|
||||
"day": day,
|
||||
"active_seconds": row[0] or 0,
|
||||
"idle_seconds": row[1] or 0,
|
||||
"powered_off_seconds": row[2] or 0,
|
||||
"unknown_seconds": row[3] or 0,
|
||||
"bytes_written_delta": row[4] or 0,
|
||||
"bytes_read_delta": row[5] or 0,
|
||||
"sample_count": row[6] or 0,
|
||||
}
|
||||
|
||||
|
||||
def _upsert_day_aggregate(conn: sqlite3.Connection, day_data: dict) -> bool:
|
||||
"""Insert or update a day aggregate. Returns True if created."""
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM day_aggregates WHERE day = ?",
|
||||
(day_data["day"],),
|
||||
).fetchone()
|
||||
|
||||
if existing is None:
|
||||
# Calculate coverage
|
||||
total_seconds = (day_data["active_seconds"] + day_data["idle_seconds"] +
|
||||
day_data["powered_off_seconds"] + day_data["unknown_seconds"])
|
||||
coverage = (day_data["active_seconds"] + day_data["idle_seconds"] +
|
||||
day_data["powered_off_seconds"]) / total_seconds if total_seconds > 0 else 0.0
|
||||
|
||||
conn.execute(
|
||||
"""INSERT INTO day_aggregates
|
||||
(day, active_seconds, idle_seconds, powered_off_seconds, unknown_seconds,
|
||||
bytes_written_delta, bytes_read_delta, sample_count, coverage)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(day_data["day"], day_data["active_seconds"], day_data["idle_seconds"],
|
||||
day_data["powered_off_seconds"], day_data["unknown_seconds"],
|
||||
day_data["bytes_written_delta"], day_data["bytes_read_delta"],
|
||||
day_data["sample_count"], coverage),
|
||||
)
|
||||
return True
|
||||
else:
|
||||
# Update existing (but only if new data is more complete)
|
||||
# This implements the "don't overwrite valid older history" rule
|
||||
cursor = conn.execute(
|
||||
"""SELECT bytes_written_delta, sample_count
|
||||
FROM day_aggregates WHERE day = ?""",
|
||||
(day_data["day"],),
|
||||
)
|
||||
existing_data = cursor.fetchone()
|
||||
|
||||
# Only update if new data has more samples or more bytes
|
||||
if (day_data["sample_count"] > existing_data[1] or
|
||||
day_data["bytes_written_delta"] > existing_data[0]):
|
||||
|
||||
total_seconds = (day_data["active_seconds"] + day_data["idle_seconds"] +
|
||||
day_data["powered_off_seconds"] + day_data["unknown_seconds"])
|
||||
coverage = (day_data["active_seconds"] + day_data["idle_seconds"] +
|
||||
day_data["powered_off_seconds"]) / total_seconds if total_seconds > 0 else 0.0
|
||||
|
||||
conn.execute(
|
||||
"""UPDATE day_aggregates SET
|
||||
active_seconds = ?, idle_seconds = ?, powered_off_seconds = ?,
|
||||
unknown_seconds = ?, bytes_written_delta = ?, bytes_read_delta = ?,
|
||||
sample_count = ?, coverage = ?
|
||||
WHERE day = ?""",
|
||||
(day_data["active_seconds"], day_data["idle_seconds"],
|
||||
day_data["powered_off_seconds"], day_data["unknown_seconds"],
|
||||
day_data["bytes_written_delta"], day_data["bytes_read_delta"],
|
||||
day_data["sample_count"], coverage, day_data["day"]),
|
||||
)
|
||||
return False # Updated, not created
|
||||
else:
|
||||
return False # No update needed
|
||||
|
||||
|
||||
def repair_derivation(
|
||||
conn: sqlite3.Connection,
|
||||
clock=None,
|
||||
) -> RepairResult:
|
||||
"""Repair hour observations and day aggregates from surviving samples.
|
||||
|
||||
This is the main entry point for historical repair. It:
|
||||
1. Finds sample pairs that need interval derivation
|
||||
2. Derives hour observations from those intervals
|
||||
3. Updates day aggregates from the hour observations
|
||||
4. Preserves existing valid data
|
||||
5. Is idempotent and interruption-safe
|
||||
|
||||
Args:
|
||||
conn: Connection to the observation store
|
||||
clock: Injected clock (for testing)
|
||||
|
||||
Returns:
|
||||
RepairResult with operation details
|
||||
"""
|
||||
if clock is None:
|
||||
clock = datetime.now(timezone.utc)
|
||||
elif hasattr(clock, 'utcnow'):
|
||||
clock = clock.utcnow()
|
||||
|
||||
# Check if repair is already in progress
|
||||
if is_repair_in_progress(conn):
|
||||
return RepairResult(
|
||||
ok=False,
|
||||
error="Repair already in progress",
|
||||
)
|
||||
|
||||
# Mark repair as in progress
|
||||
_set_repair_in_progress(conn, True)
|
||||
|
||||
result = RepairResult(ok=True)
|
||||
|
||||
try:
|
||||
# Begin transaction
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
|
||||
# 1. Find and derive intervals from sample pairs
|
||||
intervals = _get_unlinked_intervals(conn)
|
||||
|
||||
for prev, next_s in intervals:
|
||||
try:
|
||||
derived_hours = derive_hours_from_interval(conn, prev, next_s)
|
||||
result.intervals_derived += 1
|
||||
result.hours_created += len([h for h in derived_hours if h.get("attributed", True)])
|
||||
except Exception as e:
|
||||
logger.warning("Failed to derive interval %s -> %s: %s",
|
||||
prev["ts"], next_s["ts"], e)
|
||||
# Continue with other intervals (resilient)
|
||||
|
||||
# 2. Update day aggregates from hour observations
|
||||
cursor = conn.execute(
|
||||
"SELECT DISTINCT substr(hour, 1, 10) as day FROM hour_observations ORDER BY day"
|
||||
)
|
||||
days = [row[0] for row in cursor.fetchall()]
|
||||
|
||||
for day in days:
|
||||
day_data = _derive_day_aggregate_from_hours(conn, day)
|
||||
if day_data is not None:
|
||||
created = _upsert_day_aggregate(conn, day_data)
|
||||
if created:
|
||||
result.days_created += 1
|
||||
else:
|
||||
result.days_updated += 1
|
||||
|
||||
# 3. Count boundary anchors retained
|
||||
now = clock if isinstance(clock, datetime) else datetime.now(timezone.utc)
|
||||
cursor = conn.execute("SELECT ts FROM samples ORDER BY ts")
|
||||
anchor_count = 0
|
||||
for row in cursor.fetchall():
|
||||
if needs_boundary_anchor(conn, row[0], now):
|
||||
anchor_count += 1
|
||||
result.boundary_anchors_retained = anchor_count
|
||||
|
||||
# 4. Count preserved legacy summaries
|
||||
# Legacy summaries are day aggregates without corresponding hour observations
|
||||
cursor = conn.execute(
|
||||
"""SELECT COUNT(*) FROM day_aggregates d
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM hour_observations h
|
||||
WHERE h.hour LIKE d.day || 'T%'
|
||||
)"""
|
||||
)
|
||||
result.legacy_summaries_preserved = cursor.fetchone()[0]
|
||||
|
||||
# Commit transaction
|
||||
conn.commit()
|
||||
|
||||
# Update repair status
|
||||
_update_repair_status(conn, result)
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logger.error("Repair failed: %s", e)
|
||||
return RepairResult(
|
||||
ok=False,
|
||||
error=str(e),
|
||||
)
|
||||
finally:
|
||||
# Mark repair as complete
|
||||
_set_repair_in_progress(conn, False)
|
||||
|
||||
return result
|
||||
+92
-8
@@ -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:
|
||||
@@ -325,7 +331,8 @@ def check_retired_flag(flag: str) -> Optional[str]:
|
||||
def _format_projection(proj, freshness: str, service: Dict[str, Any],
|
||||
drive_facts: List[str], config_error: Optional[str],
|
||||
store_fault: Optional[str], newer_schema: Optional[str],
|
||||
journal_hint: Optional[str]) -> str:
|
||||
journal_hint: Optional[str],
|
||||
sample_count: int = 0, day_count: int = 0) -> str:
|
||||
"""Format the complete status output."""
|
||||
lines = []
|
||||
|
||||
@@ -355,6 +362,16 @@ def _format_projection(proj, freshness: str, service: Dict[str, Any],
|
||||
_append_service_facts(lines, service)
|
||||
return "\n".join(lines)
|
||||
|
||||
# --- Single sample: awaiting another sample (issue #73 AC3) ---
|
||||
# Only show awaiting state when there are no day aggregates (e.g., legacy import
|
||||
# or hand-crafted stores can have 1 sample but sufficient day data for projection)
|
||||
if sample_count <= 1 and day_count == 0:
|
||||
lines.append("awaiting another sample")
|
||||
lines.append("")
|
||||
lines.append("Collecting usage data — the first projection requires at least two samples.")
|
||||
_append_service_facts(lines, service)
|
||||
return "\n".join(lines)
|
||||
|
||||
# --- Projection headline ---
|
||||
headline = _format_headline(proj)
|
||||
lines.append(headline)
|
||||
@@ -369,14 +386,17 @@ def _format_projection(proj, freshness: str, service: Dict[str, Any],
|
||||
lines.append("%s evidence" % state_label)
|
||||
lines.append("")
|
||||
|
||||
# --- Scenario range (§6.5) ---
|
||||
if proj.scenario_range and proj.scenario_range.rates:
|
||||
# --- Scenario range (§6.5) with horizon reasons ---
|
||||
if proj.scenario_range:
|
||||
parts = []
|
||||
for horizon in sorted(proj.scenario_range.rates.keys()):
|
||||
rate_gb_day = proj.scenario_range.rates[horizon] * 86400 / 1e9
|
||||
parts.append("%dd: %.2f GB/day" % (horizon, rate_gb_day))
|
||||
lines.append("scenario range: %s" % " · ".join(parts))
|
||||
lines.append("")
|
||||
for horizon, reason in sorted(proj.scenario_range.horizon_reasons.items()):
|
||||
parts.append("%dd: %s" % (horizon, reason))
|
||||
if parts:
|
||||
lines.append("scenario range: %s" % " · ".join(parts))
|
||||
lines.append("")
|
||||
|
||||
# --- PU context line (§6.1) ---
|
||||
lines.append(proj.pu_context_line)
|
||||
@@ -443,7 +463,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 +487,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:
|
||||
@@ -543,6 +612,17 @@ def get_status(store_path: Optional[Path] = None, clock_now: Optional[datetime]
|
||||
|
||||
freshness = grade_freshness(newest_ts, clock_now)
|
||||
|
||||
# --- Sample count for single-sample state (issue #73 AC3) ---
|
||||
sample_count = 0
|
||||
day_count = 0
|
||||
try:
|
||||
cursor = conn.execute("SELECT COUNT(*) FROM samples")
|
||||
sample_count = cursor.fetchone()[0]
|
||||
cursor = conn.execute("SELECT COUNT(*) FROM day_aggregates")
|
||||
day_count = cursor.fetchone()[0]
|
||||
except sqlite3.Error:
|
||||
pass
|
||||
|
||||
# Freshness age for the service fact
|
||||
freshness_age_s = None
|
||||
if newest_ts:
|
||||
@@ -556,6 +636,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 = []
|
||||
@@ -578,7 +662,7 @@ def get_status(store_path: Optional[Path] = None, clock_now: Optional[datetime]
|
||||
# --- Compose output ---
|
||||
result = _format_projection(
|
||||
proj, freshness, service, drive_facts, config_error,
|
||||
None, None, journal_hint,
|
||||
None, None, journal_hint, sample_count, day_count,
|
||||
)
|
||||
|
||||
conn.close()
|
||||
|
||||
+61
-12
@@ -12,12 +12,22 @@ from typing import Optional
|
||||
|
||||
|
||||
# Schema version - increment on each migration
|
||||
SCHEMA_VERSION = 1
|
||||
SCHEMA_VERSION = 2
|
||||
|
||||
|
||||
# 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]
|
||||
@@ -82,7 +106,8 @@ def _create_schema(conn: sqlite3.Connection):
|
||||
data_units_read INTEGER,
|
||||
bytes_written INTEGER,
|
||||
bytes_read INTEGER,
|
||||
critical_warning INTEGER
|
||||
critical_warning INTEGER,
|
||||
segment_id INTEGER
|
||||
)
|
||||
""")
|
||||
|
||||
@@ -117,7 +142,9 @@ def _create_schema(conn: sqlite3.Connection):
|
||||
bytes_written_delta INTEGER DEFAULT 0,
|
||||
bytes_read_delta INTEGER DEFAULT 0,
|
||||
sample_count INTEGER DEFAULT 0,
|
||||
coverage REAL DEFAULT 0.0
|
||||
coverage REAL DEFAULT 0.0,
|
||||
unattributed_bytes_written INTEGER DEFAULT 0,
|
||||
unattributed_bytes_read INTEGER DEFAULT 0
|
||||
)
|
||||
""")
|
||||
|
||||
@@ -183,11 +210,25 @@ def _apply_migrations(conn: sqlite3.Connection, current_version: int):
|
||||
|
||||
Spec: §3.6, §10.2
|
||||
"""
|
||||
# Migration 1→2: example placeholder
|
||||
# if current_version < 2:
|
||||
# conn.execute("ALTER TABLE ...")
|
||||
# current_version = 2
|
||||
pass
|
||||
# Migration 1→2: add segment_id provenance to samples,
|
||||
# unattributed byte tracking to day_aggregates (issue #73)
|
||||
if current_version < 2:
|
||||
# Defensive: only ALTER if table exists (handles minimal v1 stores)
|
||||
tables = {row[0] for row in conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||
).fetchall()}
|
||||
if "samples" in tables:
|
||||
# Check if column already exists (idempotent)
|
||||
cols = {row[1] for row in conn.execute("PRAGMA table_info(samples)").fetchall()}
|
||||
if "segment_id" not in cols:
|
||||
conn.execute("ALTER TABLE samples ADD COLUMN segment_id INTEGER")
|
||||
if "day_aggregates" in tables:
|
||||
cols = {row[1] for row in conn.execute("PRAGMA table_info(day_aggregates)").fetchall()}
|
||||
if "unattributed_bytes_written" not in cols:
|
||||
conn.execute("ALTER TABLE day_aggregates ADD COLUMN unattributed_bytes_written INTEGER DEFAULT 0")
|
||||
if "unattributed_bytes_read" not in cols:
|
||||
conn.execute("ALTER TABLE day_aggregates ADD COLUMN unattributed_bytes_read INTEGER DEFAULT 0")
|
||||
current_version = 2
|
||||
|
||||
|
||||
def migrate_to_latest(store_path: Path) -> int:
|
||||
@@ -215,6 +256,14 @@ def migrate_to_latest(store_path: Path) -> int:
|
||||
conn.close()
|
||||
return 0 # Already up to date
|
||||
|
||||
# Version 0 means no schema — create fresh (issue #73)
|
||||
if current_version == 0:
|
||||
_create_schema(conn)
|
||||
conn.execute(f"PRAGMA user_version={SCHEMA_VERSION}")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return SCHEMA_VERSION
|
||||
|
||||
steps = SCHEMA_VERSION - current_version
|
||||
_apply_migrations(conn, current_version)
|
||||
conn.execute(f"PRAGMA user_version={SCHEMA_VERSION}")
|
||||
|
||||
+765
-49
@@ -19,12 +19,13 @@ import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Callable, 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.widget import Widget
|
||||
from textual.widgets import Static
|
||||
|
||||
from .projection import (
|
||||
@@ -43,6 +44,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,
|
||||
@@ -109,6 +113,601 @@ def _habit_bar(a: float, i: float, o: float, u: float, width: int = 40) -> str:
|
||||
return bar + "\n" + legend
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bar graph glyphs and constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_GLYPH_ALLOCATED = "\u2588" # \u2588 full block
|
||||
_GLYPH_UNALLOCATED = "\u2593" # \u2593 dark shade
|
||||
_GLYPH_GAP = "\u2591" # \u2591 light shade
|
||||
_GLYPH_ZERO = "\u00b7" # \u00b7 middle dot
|
||||
_GLYPH_PARTIAL = "\u258c" # \u258c left half block
|
||||
|
||||
_RANGE_OPTIONS = (7, 14, 28, 90)
|
||||
_RANGE_DEFAULT = 14
|
||||
_BAR_HEIGHT = 6
|
||||
_BAR_WIDTH = 2
|
||||
_BAR_SPACING = 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Interactive daily bar graph widget
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class DailyBarGraph(Widget):
|
||||
"""Interactive daily writes bar graph with hourly drill-down.
|
||||
|
||||
Renders writes-only daily bars using block glyphs, supports keyboard
|
||||
and mouse navigation, range switching (7/14/28/90 days), day selection,
|
||||
and hourly drill-down. No plotting dependency (issue #75 AC5).
|
||||
"""
|
||||
|
||||
can_focus = True
|
||||
can_focus_children = False
|
||||
|
||||
CSS = """
|
||||
DailyBarGraph {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
layout: vertical;
|
||||
}
|
||||
#bar-range {
|
||||
height: 1;
|
||||
width: 100%;
|
||||
}
|
||||
#bar-render {
|
||||
height: 1fr;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
#bar-legend {
|
||||
height: 1;
|
||||
width: 100%;
|
||||
}
|
||||
#bar-readout {
|
||||
height: 3;
|
||||
width: 100%;
|
||||
}
|
||||
"""
|
||||
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self.range_days: int = _RANGE_DEFAULT
|
||||
self.selected_index: int = -1
|
||||
self.view_mode: str = "daily"
|
||||
self.drill_day: Optional[str] = None
|
||||
self._all_day_data: List[Dict[str, Any]] = []
|
||||
self._day_data: List[Dict[str, Any]] = []
|
||||
self._hour_data: List[Dict[str, Any]] = []
|
||||
self._max_bytes: int = 0
|
||||
self._hourly_selected: int = -1
|
||||
self._on_drill: Optional[Callable[[str], None]] = None
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Static("", id="bar-range")
|
||||
yield Static("", id="bar-render")
|
||||
yield Static("", id="bar-legend")
|
||||
yield Static("", id="bar-readout")
|
||||
|
||||
def set_data(
|
||||
self,
|
||||
day_data: List[Dict[str, Any]],
|
||||
on_drill: Any = None,
|
||||
) -> None:
|
||||
"""Update graph with day data. on_drill(day) called on drill entry."""
|
||||
self._all_day_data = day_data
|
||||
self._on_drill = on_drill
|
||||
self._trim_to_range()
|
||||
self.view_mode = "daily"
|
||||
self.drill_day = None
|
||||
self._hour_data = []
|
||||
self._hourly_selected = -1
|
||||
self._refresh()
|
||||
|
||||
def set_hour_data(self, hour_data: List[Dict[str, Any]]) -> None:
|
||||
"""Set hourly data for drill-down view."""
|
||||
self._hour_data = hour_data
|
||||
self._hourly_selected = -1
|
||||
self._refresh_hourly()
|
||||
|
||||
def _trim_to_range(self) -> None:
|
||||
self._day_data = (
|
||||
self._all_day_data[-self.range_days:]
|
||||
if len(self._all_day_data) > self.range_days
|
||||
else list(self._all_day_data)
|
||||
)
|
||||
# Bar height scales to allocated bytes only; unallocated are shown
|
||||
# separately so cross-day unknowns never inflate a day bar (AC3).
|
||||
self._max_bytes = max(
|
||||
(d.get("allocated_bytes", 0) for d in self._day_data), default=0
|
||||
)
|
||||
|
||||
def _refresh(self) -> None:
|
||||
if not self._day_data:
|
||||
self._show_empty()
|
||||
return
|
||||
if self._is_constrained():
|
||||
self._show_constrained_summary()
|
||||
return
|
||||
self._render_range()
|
||||
self._render_bars()
|
||||
self._render_legend()
|
||||
self._render_readout()
|
||||
|
||||
def _show_empty(self) -> None:
|
||||
self.query_one("#bar-range").update("[dim]usage history[/dim]")
|
||||
self.query_one("#bar-render").update("[dim]awaiting first sample[/dim]")
|
||||
self.query_one("#bar-legend").update("")
|
||||
self.query_one("#bar-readout").update("")
|
||||
|
||||
def _is_constrained(self) -> bool:
|
||||
"""Return True when the widget is too narrow for the bar graph.
|
||||
|
||||
At 80 columns with a 3fr:2fr grid split the pane content width is ~46
|
||||
(after round border + padding). Below 32 the bars become unreadable.
|
||||
"""
|
||||
try:
|
||||
w = self.region.width
|
||||
return 0 < w < 32
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _show_constrained_summary(self) -> None:
|
||||
"""Textual fallback for terminals below 80×24."""
|
||||
self.query_one("#bar-range").update("[dim]usage history[/dim]")
|
||||
if not self._day_data:
|
||||
self.query_one("#bar-render").update("[dim]graph needs ≥80×24[/dim]")
|
||||
self.query_one("#bar-legend").update("")
|
||||
self.query_one("#bar-readout").update("")
|
||||
return
|
||||
|
||||
# Summarise visible days as text
|
||||
total_bytes = sum(d.get("total_bytes", 0) for d in self._day_data)
|
||||
days_with_data = sum(1 for d in self._day_data if d.get("total_bytes", 0) > 0)
|
||||
n = len(self._day_data)
|
||||
first = self._day_data[0].get("local_label", "")
|
||||
last = self._day_data[-1].get("local_label", "")
|
||||
self.query_one("#bar-render").update(
|
||||
"[dim]graph needs ≥80×24[/dim]\n"
|
||||
" %d days · %d with writes · %.3f GB total\n"
|
||||
" %s → %s" % (n, days_with_data, total_bytes / 1e9, first, last)
|
||||
)
|
||||
self.query_one("#bar-legend").update("")
|
||||
|
||||
# Preserve selection readout
|
||||
if 0 <= self.selected_index < len(self._day_data):
|
||||
day = self._day_data[self.selected_index]
|
||||
self.query_one("#bar-readout").update(
|
||||
"[bold]%s[/bold] · %.3f GB"
|
||||
% (day.get("local_label", ""), day.get("total_bytes", 0) / 1e9)
|
||||
)
|
||||
else:
|
||||
self.query_one("#bar-readout").update("[dim]\u2190 \u2192 select[/dim]")
|
||||
|
||||
def _show_constrained_hourly_summary(self) -> None:
|
||||
"""Textual fallback for hourly view when terminal is too small."""
|
||||
total_bytes = sum(h.get("bytes_written", 0) for h in self._hour_data)
|
||||
hours_with_data = sum(
|
||||
1 for h in self._hour_data if h.get("bytes_written", 0) > 0
|
||||
)
|
||||
n = len(self._hour_data)
|
||||
self.query_one("#bar-render").update(
|
||||
"[dim]graph needs ≥80×24[/dim]\n"
|
||||
" %d hours · %d with writes · %.3f GB total" % (n, hours_with_data, total_bytes / 1e9)
|
||||
)
|
||||
self.query_one("#bar-legend").update("")
|
||||
|
||||
if 0 <= self._hourly_selected < len(self._hour_data):
|
||||
h = self._hour_data[self._hourly_selected]
|
||||
self.query_one("#bar-readout").update(
|
||||
"[bold]%s[/bold] \u00b7 %.3f GB"
|
||||
% (h.get("local_label", ""), h.get("bytes_written", 0) / 1e9)
|
||||
)
|
||||
else:
|
||||
self.query_one("#bar-readout").update("[dim]\u2190 \u2192 select hour[/dim]")
|
||||
|
||||
def on_resize(self) -> None:
|
||||
"""Re-render when terminal size changes."""
|
||||
if self.view_mode == "daily":
|
||||
self._refresh()
|
||||
else:
|
||||
self._refresh_hourly()
|
||||
|
||||
def _render_range(self) -> None:
|
||||
parts = []
|
||||
for r in _RANGE_OPTIONS:
|
||||
if r == self.range_days:
|
||||
parts.append("[bold]%d[/bold]" % r)
|
||||
else:
|
||||
parts.append(str(r))
|
||||
label = "range: " + " / ".join(parts)
|
||||
if self.view_mode == "hourly":
|
||||
label += " \u00b7 [bold]%s[/bold] \u00b7 esc back" % (self.drill_day or "")
|
||||
self.query_one("#bar-range").update(label)
|
||||
|
||||
def _render_bars(self) -> None:
|
||||
visible = self._day_data
|
||||
if not visible:
|
||||
self.query_one("#bar-render").update("")
|
||||
return
|
||||
|
||||
max_bytes = self._max_bytes or 1
|
||||
bar_h = _BAR_HEIGHT
|
||||
n = len(visible)
|
||||
|
||||
lines: List[str] = []
|
||||
for row in range(bar_h, 0, -1):
|
||||
line = ""
|
||||
threshold = (row / bar_h) * max_bytes
|
||||
for i, day in enumerate(visible):
|
||||
allocated = day.get("allocated_bytes", 0)
|
||||
is_zero = day.get("is_zero", False)
|
||||
is_gap = day.get("is_gap", False)
|
||||
is_partial = day.get("is_partial", False)
|
||||
|
||||
if is_zero and row == 1:
|
||||
glyph = _GLYPH_ZERO
|
||||
elif is_gap:
|
||||
glyph = _GLYPH_GAP if row <= 2 else " "
|
||||
elif allocated == 0:
|
||||
glyph = " "
|
||||
elif row == 1 and i == self.selected_index:
|
||||
glyph = "\u25b6" # selection arrow
|
||||
elif threshold > 0 and threshold <= allocated:
|
||||
glyph = _GLYPH_ALLOCATED
|
||||
elif is_partial and row == bar_h:
|
||||
glyph = _GLYPH_PARTIAL
|
||||
else:
|
||||
glyph = " "
|
||||
|
||||
line += glyph * _BAR_WIDTH
|
||||
if i < n - 1:
|
||||
line += " " * _BAR_SPACING
|
||||
|
||||
lines.append(line)
|
||||
|
||||
# Unallocated indicator row: show ▓ for days with cross-day unknowns
|
||||
unalloc_line = ""
|
||||
has_unalloc = False
|
||||
for i, day in enumerate(visible):
|
||||
unalloc = day.get("unallocated_bytes", 0)
|
||||
if unalloc > 0:
|
||||
unalloc_line += _GLYPH_UNALLOCATED * _BAR_WIDTH
|
||||
has_unalloc = True
|
||||
else:
|
||||
unalloc_line += " " * _BAR_WIDTH
|
||||
if i < n - 1:
|
||||
unalloc_line += " " * _BAR_SPACING
|
||||
if has_unalloc:
|
||||
lines.append(unalloc_line)
|
||||
|
||||
# Date labels
|
||||
label_line = ""
|
||||
for i, day in enumerate(visible):
|
||||
label = day.get("local_label", day.get("day", ""))[-2:]
|
||||
label_line += label
|
||||
if i < n - 1:
|
||||
label_line += " " * _BAR_SPACING
|
||||
lines.append(label_line)
|
||||
|
||||
self.query_one("#bar-render").update("\n".join(lines))
|
||||
|
||||
def _render_legend(self) -> None:
|
||||
legend = (
|
||||
"%s alloc %s unalloc %s gap %s zero %s partial"
|
||||
% (
|
||||
_GLYPH_ALLOCATED,
|
||||
_GLYPH_UNALLOCATED,
|
||||
_GLYPH_GAP,
|
||||
_GLYPH_ZERO,
|
||||
_GLYPH_PARTIAL,
|
||||
)
|
||||
)
|
||||
self.query_one("#bar-legend").update(legend)
|
||||
|
||||
def _render_readout(self) -> None:
|
||||
if self.selected_index < 0 or self.selected_index >= len(self._day_data):
|
||||
self.query_one("#bar-readout").update(
|
||||
"[dim]\u2190 \u2192 select \u00b7 1-4 range \u00b7 Enter drill[/dim]"
|
||||
)
|
||||
return
|
||||
|
||||
day = self._day_data[self.selected_index]
|
||||
total = day.get("total_bytes", 0)
|
||||
allocated = day.get("allocated_bytes", 0)
|
||||
unallocated = day.get("unallocated_bytes", 0)
|
||||
coverage = day.get("coverage", 0)
|
||||
hours = day.get("evidenced_hours", 0)
|
||||
|
||||
total_gb = total / 1e9
|
||||
parts = [
|
||||
"[bold]%s[/bold] \u00b7 %.3f GB total \u00b7 %d hours \u00b7 %.0f%% coverage"
|
||||
% (
|
||||
day.get("local_label", day.get("day", "")),
|
||||
total_gb,
|
||||
hours,
|
||||
coverage * 100,
|
||||
),
|
||||
]
|
||||
if unallocated > 0:
|
||||
parts.append(
|
||||
" allocated %.3f GB \u00b7 unallocated %.3f GB"
|
||||
% (allocated / 1e9, unallocated / 1e9)
|
||||
)
|
||||
self.query_one("#bar-readout").update("\n".join(parts))
|
||||
|
||||
# -- Hourly drill-down rendering --
|
||||
|
||||
def _refresh_hourly(self) -> None:
|
||||
if not self._hour_data:
|
||||
self.query_one("#bar-render").update("[dim]no hourly data[/dim]")
|
||||
return
|
||||
|
||||
self._render_range()
|
||||
|
||||
if self._is_constrained():
|
||||
self._show_constrained_hourly_summary()
|
||||
return
|
||||
|
||||
max_bytes = max(
|
||||
(h.get("bytes_written", 0) for h in self._hour_data), default=0
|
||||
) or 1
|
||||
bar_h = _BAR_HEIGHT
|
||||
n = len(self._hour_data)
|
||||
|
||||
lines: List[str] = []
|
||||
for row in range(bar_h, 0, -1):
|
||||
line = ""
|
||||
threshold = (row / bar_h) * max_bytes
|
||||
for i, hour in enumerate(self._hour_data):
|
||||
bw = hour.get("bytes_written", 0)
|
||||
is_zero = hour.get("is_zero", False)
|
||||
|
||||
if is_zero and row == 1:
|
||||
glyph = _GLYPH_ZERO
|
||||
elif bw == 0:
|
||||
glyph = " "
|
||||
elif row == 1 and i == self._hourly_selected:
|
||||
glyph = "\u25b6"
|
||||
elif threshold > 0 and threshold <= bw:
|
||||
glyph = _GLYPH_ALLOCATED
|
||||
else:
|
||||
glyph = " "
|
||||
|
||||
line += glyph * _BAR_WIDTH
|
||||
if i < n - 1:
|
||||
line += " " * _BAR_SPACING
|
||||
|
||||
lines.append(line)
|
||||
|
||||
# Hour labels
|
||||
label_line = ""
|
||||
for i, hour in enumerate(self._hour_data):
|
||||
label = hour.get("local_label", hour.get("hour", ""))[-2:]
|
||||
label_line += label
|
||||
if i < n - 1:
|
||||
label_line += " " * _BAR_SPACING
|
||||
lines.append(label_line)
|
||||
|
||||
self.query_one("#bar-render").update("\n".join(lines))
|
||||
self.query_one("#bar-legend").update(
|
||||
"%s writes \u00b7 %s zero" % (_GLYPH_ALLOCATED, _GLYPH_ZERO)
|
||||
)
|
||||
|
||||
# Hourly readout
|
||||
if 0 <= self._hourly_selected < len(self._hour_data):
|
||||
h = self._hour_data[self._hourly_selected]
|
||||
self.query_one("#bar-readout").update(
|
||||
"[bold]%s[/bold] \u00b7 %.3f GB \u00b7 %d%% coverage"
|
||||
% (
|
||||
h.get("local_label", h.get("hour", "")),
|
||||
h.get("bytes_written", 0) / 1e9,
|
||||
h.get("coverage", 0) * 100,
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.query_one("#bar-readout").update("[dim]\u2190 \u2192 select hour[/dim]")
|
||||
|
||||
# -- Event handling --
|
||||
|
||||
def on_key(self, event: Any) -> None:
|
||||
if self.view_mode == "daily":
|
||||
self._handle_daily_key(event)
|
||||
else:
|
||||
self._handle_hourly_key(event)
|
||||
|
||||
def _handle_daily_key(self, event: Any) -> None:
|
||||
if event.key == "left":
|
||||
if self.selected_index < 0:
|
||||
self.selected_index = len(self._day_data) - 1
|
||||
else:
|
||||
self.selected_index = max(0, self.selected_index - 1)
|
||||
self._refresh()
|
||||
event.stop()
|
||||
elif event.key == "right":
|
||||
if self.selected_index < 0:
|
||||
self.selected_index = 0
|
||||
else:
|
||||
self.selected_index = min(
|
||||
len(self._day_data) - 1, self.selected_index + 1
|
||||
)
|
||||
self._refresh()
|
||||
event.stop()
|
||||
elif event.key == "enter" and self.selected_index >= 0:
|
||||
self._enter_drill()
|
||||
event.stop()
|
||||
elif event.key in ("1", "2", "3", "4"):
|
||||
self.range_days = _RANGE_OPTIONS[int(event.key) - 1]
|
||||
self.selected_index = -1
|
||||
self._trim_to_range()
|
||||
self._refresh()
|
||||
event.stop()
|
||||
|
||||
def _enter_drill(self) -> None:
|
||||
if self.selected_index < 0 or self.selected_index >= len(self._day_data):
|
||||
return
|
||||
day = self._day_data[self.selected_index]
|
||||
self.drill_day = day.get("day")
|
||||
self.view_mode = "hourly"
|
||||
self._hourly_selected = -1
|
||||
if self._on_drill:
|
||||
self._on_drill(self.drill_day)
|
||||
self._render_range()
|
||||
self.query_one("#bar-render").update("[dim]loading...[/dim]")
|
||||
self.query_one("#bar-legend").update("")
|
||||
self.query_one("#bar-readout").update("")
|
||||
|
||||
def _exit_drill(self) -> None:
|
||||
self.view_mode = "daily"
|
||||
self.drill_day = None
|
||||
self._hour_data = []
|
||||
self._hourly_selected = -1
|
||||
self._refresh()
|
||||
|
||||
def _handle_hourly_key(self, event: Any) -> None:
|
||||
if event.key in ("escape", "backspace"):
|
||||
self._exit_drill()
|
||||
event.stop()
|
||||
elif event.key == "left":
|
||||
if self._hourly_selected < 0:
|
||||
self._hourly_selected = len(self._hour_data) - 1
|
||||
else:
|
||||
self._hourly_selected = max(0, self._hourly_selected - 1)
|
||||
self._refresh_hourly()
|
||||
event.stop()
|
||||
elif event.key == "right":
|
||||
if self._hourly_selected < 0:
|
||||
self._hourly_selected = 0
|
||||
else:
|
||||
self._hourly_selected = min(
|
||||
len(self._hour_data) - 1, self._hourly_selected + 1
|
||||
)
|
||||
self._refresh_hourly()
|
||||
event.stop()
|
||||
|
||||
def on_click(self, event: Any) -> None:
|
||||
render = self.query_one("#bar-render")
|
||||
offset_x = event.x - render.region.x
|
||||
bar_total = _BAR_WIDTH + _BAR_SPACING
|
||||
idx = offset_x // bar_total
|
||||
|
||||
if self.view_mode == "daily":
|
||||
if 0 <= idx < len(self._day_data):
|
||||
self.selected_index = idx
|
||||
self._refresh()
|
||||
else:
|
||||
if 0 <= idx < len(self._hour_data):
|
||||
self._hourly_selected = idx
|
||||
self._refresh_hourly()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Graph data queries
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _query_daily_graph_data(
|
||||
conn: sqlite3.Connection,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Query day aggregates for the bar graph.
|
||||
|
||||
Returns one dict per day with total/allocated/unallocated bytes,
|
||||
coverage, evidence hours, and classification flags.
|
||||
"""
|
||||
cursor = conn.execute(
|
||||
"SELECT day, bytes_written_delta, unattributed_bytes_written, "
|
||||
"coverage, sample_count, active_seconds, idle_seconds, "
|
||||
"powered_off_seconds, unknown_seconds "
|
||||
"FROM day_aggregates ORDER BY day"
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
result: List[Dict[str, Any]] = []
|
||||
for row in rows:
|
||||
day = row[0]
|
||||
bw_delta = row[1] or 0
|
||||
unattributed = row[2] or 0
|
||||
coverage = row[3] or 0.0
|
||||
sample_count = row[4] or 0
|
||||
active = row[5] or 0
|
||||
idle = row[6] or 0
|
||||
powered_off = row[7] or 0
|
||||
unknown = row[8] or 0
|
||||
|
||||
total_bytes = bw_delta + unattributed
|
||||
evidenced_hours = (active + idle + powered_off) // 3600
|
||||
|
||||
is_zero = total_bytes == 0
|
||||
is_gap = (
|
||||
sample_count == 0
|
||||
and (active + idle + powered_off) == 0
|
||||
and unknown > 0
|
||||
)
|
||||
is_partial = coverage < 0.5
|
||||
|
||||
result.append({
|
||||
"day": day,
|
||||
"local_label": day,
|
||||
"total_bytes": total_bytes,
|
||||
"allocated_bytes": bw_delta,
|
||||
"unallocated_bytes": unattributed,
|
||||
"coverage": coverage,
|
||||
"evidenced_hours": evidenced_hours,
|
||||
"sample_count": sample_count,
|
||||
"is_zero": is_zero,
|
||||
"is_gap": is_gap,
|
||||
"is_partial": is_partial,
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _query_hourly_graph_data(
|
||||
conn: sqlite3.Connection,
|
||||
day: str,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Query hour observations for a specific day.
|
||||
|
||||
Returns one dict per hour with bytes written, coverage, and flags.
|
||||
"""
|
||||
cursor = conn.execute(
|
||||
"SELECT hour, bytes_written_delta, coverage, sample_count, "
|
||||
"active_seconds, idle_seconds, powered_off_seconds, unknown_seconds "
|
||||
"FROM hour_observations "
|
||||
"WHERE hour LIKE ? ORDER BY hour",
|
||||
(day + "T%",),
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
result: List[Dict[str, Any]] = []
|
||||
for row in rows:
|
||||
hour = row[0]
|
||||
bw = row[1] or 0
|
||||
coverage = row[2] or 0.0
|
||||
sample_count = row[3] or 0
|
||||
active = row[4] or 0
|
||||
idle = row[5] or 0
|
||||
powered_off = row[6] or 0
|
||||
unknown = row[7] or 0
|
||||
|
||||
is_zero = bw == 0
|
||||
local_label = hour[11:13] if len(hour) >= 13 else hour
|
||||
|
||||
result.append({
|
||||
"hour": hour,
|
||||
"local_label": local_label,
|
||||
"bytes_written": bw,
|
||||
"coverage": coverage,
|
||||
"sample_count": sample_count,
|
||||
"active_seconds": active,
|
||||
"idle_seconds": idle,
|
||||
"powered_off_seconds": powered_off,
|
||||
"unknown_seconds": unknown,
|
||||
"is_zero": is_zero,
|
||||
})
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data queries for TUI regions
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -229,6 +828,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 +886,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 +935,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 DailyBarGraph(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 +963,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,56 +1012,78 @@ 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("#usage-history").set_data([])
|
||||
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("#usage-history").set_data([])
|
||||
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:
|
||||
"""Render all four regions from live store data."""
|
||||
# --- Headline band (§7.2) ---
|
||||
# --- Sample count for single-sample state (issue #73 AC3) ---
|
||||
try:
|
||||
proj = compute_projection(conn, self._clock_now)
|
||||
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
|
||||
)
|
||||
cursor = conn.execute("SELECT COUNT(*) FROM samples")
|
||||
sample_count = cursor.fetchone()[0]
|
||||
cursor = conn.execute("SELECT COUNT(*) FROM day_aggregates")
|
||||
day_count = cursor.fetchone()[0]
|
||||
except Exception:
|
||||
self.query_one("#headline-band").update(
|
||||
"[bold]No projection available[/bold]"
|
||||
)
|
||||
sample_count = 0
|
||||
day_count = 0
|
||||
|
||||
# --- Usage-history pane (§7.2 left) ---
|
||||
history = _query_usage_history(conn)
|
||||
history_text = "[bold]Usage history[/bold] · %d days · %.1f–%.1f GB/day\n %s\n %s" % (
|
||||
history["num_days"],
|
||||
history["min_gb"],
|
||||
history["max_gb"],
|
||||
history["sparkline"],
|
||||
history["habit_bar"],
|
||||
)
|
||||
self.query_one("#usage-history").update(history_text)
|
||||
# --- Headline band (§7.2) ---
|
||||
if sample_count <= 1 and day_count == 0:
|
||||
# Single sample: awaiting another sample
|
||||
self._render_headline(
|
||||
"[bold]Awaiting another sample[/bold]\n\n"
|
||||
"Collecting usage data — the first projection requires at least two samples."
|
||||
)
|
||||
else:
|
||||
try:
|
||||
proj = compute_projection(conn, self._clock_now)
|
||||
headline = self._format_headline(proj)
|
||||
confidence = self._format_confidence(proj)
|
||||
scenario = self._format_scenario(proj)
|
||||
self._render_headline(headline + "\n" + confidence + "\n" + scenario)
|
||||
except Exception:
|
||||
self._render_headline("[bold]No projection available[/bold]")
|
||||
|
||||
# --- Usage-history pane (§7.2 left): interactive bar graph ---
|
||||
graph = self.query_one("#usage-history")
|
||||
day_data = _query_daily_graph_data(conn)
|
||||
|
||||
# Preserve drill-down state across refresh if still valid
|
||||
if graph.view_mode == "hourly" and graph.drill_day:
|
||||
saved_drill_day = graph.drill_day
|
||||
hour_data = _query_hourly_graph_data(conn, saved_drill_day)
|
||||
graph.set_data(day_data, on_drill=self._on_graph_drill)
|
||||
graph.view_mode = "hourly"
|
||||
graph.drill_day = saved_drill_day
|
||||
graph.set_hour_data(hour_data)
|
||||
else:
|
||||
graph.set_data(day_data, on_drill=self._on_graph_drill)
|
||||
|
||||
# --- Drive-health pane (§7.2 right) ---
|
||||
health = _query_drive_health(conn)
|
||||
@@ -448,17 +1116,61 @@ 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 _on_graph_drill(self, day: str) -> None:
|
||||
"""Load hourly data when the graph enters drill-down mode."""
|
||||
graph = self.query_one("#usage-history")
|
||||
try:
|
||||
conn = self._open_store()
|
||||
if conn is not None:
|
||||
try:
|
||||
hour_data = _query_hourly_graph_data(conn, day)
|
||||
graph.set_hour_data(hour_data)
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception:
|
||||
graph.set_hour_data([])
|
||||
|
||||
def _format_headline(self, proj: ProjectionResult) -> str:
|
||||
"""Format the lifespan headline (spec §6.11)."""
|
||||
if proj.headline_remaining_seconds is None:
|
||||
@@ -493,13 +1205,17 @@ class FenrisTuiApp(App):
|
||||
)
|
||||
|
||||
def _format_scenario(self, proj: ProjectionResult) -> str:
|
||||
"""Format scenario range (spec §6.5)."""
|
||||
if not proj.scenario_range or not proj.scenario_range.rates:
|
||||
"""Format scenario range with horizon reasons (spec §6.5)."""
|
||||
if not proj.scenario_range:
|
||||
return ""
|
||||
parts = []
|
||||
for horizon in sorted(proj.scenario_range.rates.keys()):
|
||||
rate_gb_day = proj.scenario_range.rates[horizon] * 86400 / 1e9
|
||||
parts.append("%dd: %.2f GB/day" % (horizon, rate_gb_day))
|
||||
for horizon, reason in sorted(proj.scenario_range.horizon_reasons.items()):
|
||||
parts.append("%dd: %s" % (horizon, reason))
|
||||
if not parts:
|
||||
return ""
|
||||
return "[bold]Scenario range[/bold] · %s" % " · ".join(parts)
|
||||
|
||||
# --- Actions ---
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"},
|
||||
}
|
||||
@@ -0,0 +1,712 @@
|
||||
"""Collector history tracer tests (issue #73).
|
||||
|
||||
Tests the end-to-end history pipeline: sample acquisition → interval
|
||||
derivation → hour observation → day aggregate, with concurrent-read
|
||||
safety, cross-hour handling, and display states.
|
||||
|
||||
Seams:
|
||||
- write side: run_collection() → observation store
|
||||
- read side: get_status(), compute_projection() → observation store
|
||||
"""
|
||||
import os
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
import pytest
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
|
||||
from fenris.collector import run_collection, normalize_identity
|
||||
from fenris.store import init_store, get_store_path, SCHEMA_VERSION
|
||||
from fenris.monitoring_periods import ensure_period_open, close_period, get_open_period
|
||||
from fenris.day_aggregate import derive_day, derive_all_days
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def smartctl_fixture() -> Dict[str, Any]:
|
||||
"""Minimal smartctl -a -j output with required fields."""
|
||||
return {
|
||||
"json_format_version": [1, 0],
|
||||
"smartctl": {"version": [7, 3], "svn_revision": "5155", "build_info": "(local build)"},
|
||||
"nvme_smart_health_information_log": {
|
||||
"critical_warning": 0,
|
||||
"temperature": 35,
|
||||
"available_spare": 100,
|
||||
"available_spare_threshold": 10,
|
||||
"percentage_used": 5,
|
||||
"data_units_written": 12345678,
|
||||
"data_units_read": 9876543,
|
||||
"power_on_hours": 8765,
|
||||
"power_cycles": 1234,
|
||||
"unsafe_shutdowns": 5,
|
||||
"media_errors": 0,
|
||||
"num_err_log_entries": 0,
|
||||
},
|
||||
"user_capacity": {"bytes": 1024000000000, "units": "bytes"},
|
||||
"model_name": "Samsung SSD 970 EVO Plus 1TB",
|
||||
"serial_number": "S4EWNX0N123456",
|
||||
"firmware_version": "2B2QEXM7",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sysfs_fixture_tree(tmp_path: Path) -> Path:
|
||||
"""Create a minimal sysfs fixture tree with controller identity."""
|
||||
ctrl_dir = tmp_path / "sys" / "class" / "nvme" / "nvme0"
|
||||
ctrl_dir.mkdir(parents=True)
|
||||
(ctrl_dir / "subsysnqn").write_text("nqn.2014-08.org.nvmexpress:uuid:12345678-1234-1234-1234-123456789abc\n")
|
||||
(ctrl_dir / "model").write_text("Samsung SSD 970 EVO Plus 1TB\n")
|
||||
(ctrl_dir / "serial").write_text("S4EWNX0N123456\n")
|
||||
(ctrl_dir / "firmware_rev").write_text("2B2QEXM7\n")
|
||||
transport_dir = ctrl_dir / "transport"
|
||||
transport_dir.mkdir()
|
||||
(transport_dir / "address").write_text("0000:03:00.0")
|
||||
(transport_dir / "trstring").write_text("pcie")
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config_fixture(tmp_path: Path) -> Dict[str, Any]:
|
||||
"""Configuration fixture naming the device."""
|
||||
return {
|
||||
"device": "/dev/nvme0",
|
||||
"store_path": str(tmp_path / "observations.db"),
|
||||
}
|
||||
|
||||
|
||||
class FakeClock:
|
||||
"""Injected clock returning controlled time."""
|
||||
def __init__(self, initial: datetime):
|
||||
self.now = initial
|
||||
def utcnow(self):
|
||||
return self.now
|
||||
def advance(self, **kwargs):
|
||||
self.now = self.now + timedelta(**kwargs)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schema migration: existing data readable at real precision
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSchemaMigration:
|
||||
"""Schema migration 1→2 preserves existing data (issue #73 AC1)."""
|
||||
|
||||
def test_migration_bumps_version(self, tmp_path):
|
||||
"""Migration from v1 to v2 succeeds."""
|
||||
from fenris.store import migrate_to_latest, SCHEMA_VERSION
|
||||
|
||||
# Create a v1 store directly (simulating pre-migration state)
|
||||
db = tmp_path / "test.db"
|
||||
conn = sqlite3.connect(str(db))
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
# Create v1 schema manually
|
||||
conn.execute("""
|
||||
CREATE TABLE samples (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts TEXT NOT NULL,
|
||||
device TEXT NOT NULL,
|
||||
subnqn TEXT, sn TEXT, mn TEXT, fr TEXT,
|
||||
capacity_bytes INTEGER, percentage_used INTEGER,
|
||||
available_spare INTEGER, media_errors INTEGER,
|
||||
power_on_hours INTEGER, power_cycles INTEGER,
|
||||
unsafe_shutdowns INTEGER, temperature_c INTEGER,
|
||||
data_units_written INTEGER, data_units_read INTEGER,
|
||||
bytes_written INTEGER, bytes_read INTEGER,
|
||||
critical_warning INTEGER
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE hour_observations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
hour TEXT NOT NULL UNIQUE,
|
||||
active_seconds INTEGER DEFAULT 0, idle_seconds INTEGER DEFAULT 0,
|
||||
powered_off_seconds INTEGER DEFAULT 0, unknown_seconds INTEGER DEFAULT 0,
|
||||
bytes_written_delta INTEGER DEFAULT 0, bytes_read_delta INTEGER DEFAULT 0,
|
||||
temperature_min INTEGER, temperature_avg REAL, temperature_max INTEGER,
|
||||
sample_count INTEGER DEFAULT 0, coverage REAL DEFAULT 0.0
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE day_aggregates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
day TEXT NOT NULL UNIQUE,
|
||||
active_seconds INTEGER DEFAULT 0, idle_seconds INTEGER DEFAULT 0,
|
||||
powered_off_seconds INTEGER DEFAULT 0, unknown_seconds INTEGER DEFAULT 0,
|
||||
bytes_written_delta INTEGER DEFAULT 0, bytes_read_delta INTEGER DEFAULT 0,
|
||||
sample_count INTEGER DEFAULT 0, coverage REAL DEFAULT 0.0
|
||||
)
|
||||
""")
|
||||
conn.execute("CREATE TABLE monitoring_periods (id INTEGER PRIMARY KEY AUTOINCREMENT, started_at TEXT NOT NULL, ended_at TEXT, end_cause TEXT)")
|
||||
conn.execute("CREATE TABLE controller_segments (id INTEGER PRIMARY KEY AUTOINCREMENT, opened_at TEXT NOT NULL, identity_key TEXT, identity_degraded BOOLEAN DEFAULT 0, subnqn TEXT, sn TEXT, mn TEXT, fr TEXT, vid TEXT, ssvid TEXT, transport TEXT)")
|
||||
conn.execute("CREATE TABLE endurance_baseline (id INTEGER PRIMARY KEY AUTOINCREMENT, tbw_terabytes REAL NOT NULL, source_url TEXT, document_revision TEXT, entry_date TEXT, model_string TEXT, nominal_capacity_bytes INTEGER, validated_by TEXT, verified BOOLEAN DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)")
|
||||
conn.execute("CREATE TABLE store_metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL)")
|
||||
conn.execute("PRAGMA user_version=1")
|
||||
conn.execute("INSERT INTO samples (ts, device, mn, sn, fr, capacity_bytes, percentage_used, available_spare, media_errors, power_on_hours, power_cycles, unsafe_shutdowns, temperature_c, data_units_written, data_units_read, bytes_written, bytes_read, critical_warning) VALUES ('2026-09-01T12:00:00+00:00', '/dev/nvme0', 'Test', 'SN', 'FR', 1000000000000, 5, 100, 0, 1000, 100, 0, 35, 1000000, 500000, 512000000000, 256000000000, 0)")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# Migrate
|
||||
steps = migrate_to_latest(db)
|
||||
assert steps == 1
|
||||
|
||||
# Verify data preserved
|
||||
conn = sqlite3.connect(str(db))
|
||||
row = conn.execute("SELECT ts, mn FROM samples").fetchone()
|
||||
version = conn.execute("PRAGMA user_version").fetchone()[0]
|
||||
conn.close()
|
||||
assert row[0] == "2026-09-01T12:00:00+00:00"
|
||||
assert row[1] == "Test"
|
||||
assert version == SCHEMA_VERSION
|
||||
|
||||
def test_newer_schema_refused(self, tmp_path):
|
||||
"""Store with user_version > SCHEMA_VERSION is refused."""
|
||||
db = tmp_path / "test.db"
|
||||
conn = sqlite3.connect(str(db))
|
||||
conn.execute("PRAGMA user_version=%d" % (SCHEMA_VERSION + 1))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
with pytest.raises(ValueError, match="newer Fenris"):
|
||||
init_store(db)
|
||||
|
||||
def test_existing_data_preserved_after_migration(self, config_fixture,
|
||||
smartctl_fixture,
|
||||
sysfs_fixture_tree):
|
||||
"""Existing sample data is not lost or modified by migration."""
|
||||
clock = FakeClock(datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc))
|
||||
|
||||
# Write first sample
|
||||
run_collection(smartctl_fixture, sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||
config_fixture, clock)
|
||||
|
||||
conn = sqlite3.connect(config_fixture["store_path"])
|
||||
row = conn.execute("SELECT ts, device, bytes_written FROM samples").fetchone()
|
||||
conn.close()
|
||||
|
||||
assert row[0] == "2026-09-01T12:00:00+00:00"
|
||||
assert row[1] == "/dev/nvme0"
|
||||
assert row[2] == 12345678 * 512000
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Collector publishes samples, intervals, hour observations, day aggregates
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCollectorDerivation:
|
||||
"""Collector derives hour observations and day aggregates (issue #73 AC2)."""
|
||||
|
||||
def _make_sample(self, duw_units: int, ts: str) -> Dict[str, Any]:
|
||||
"""Build a smartctl fixture with specific DUW."""
|
||||
return {
|
||||
"json_format_version": [1, 0],
|
||||
"smartctl": {"version": [7, 3], "svn_revision": "5155", "build_info": "(local build)"},
|
||||
"nvme_smart_health_information_log": {
|
||||
"critical_warning": 0,
|
||||
"temperature": 35,
|
||||
"available_spare": 100,
|
||||
"available_spare_threshold": 10,
|
||||
"percentage_used": 5,
|
||||
"data_units_written": duw_units,
|
||||
"data_units_read": 9876543,
|
||||
"power_on_hours": 8765,
|
||||
"power_cycles": 1234,
|
||||
"unsafe_shutdowns": 5,
|
||||
"media_errors": 0,
|
||||
"num_err_log_entries": 0,
|
||||
},
|
||||
"user_capacity": {"bytes": 1024000000000, "units": "bytes"},
|
||||
"model_name": "Samsung SSD 970 EVO Plus 1TB",
|
||||
"serial_number": "S4EWNX0N123456",
|
||||
"firmware_version": "2B2QEXM7",
|
||||
}
|
||||
|
||||
def test_same_hour_20mb_derives_hour_obs(self, config_fixture, sysfs_fixture_tree):
|
||||
"""Two samples in same hour with 20 MB delta → hour_obs gets 20 MB."""
|
||||
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
|
||||
|
||||
# DUW units: 12345678 * 512000 = ~6.3 TB; 20 MB = 20*1024*1024 / 512000 ≈ 40 units
|
||||
duw1 = 12345678
|
||||
duw2 = duw1 + 40 # ~20 MB more
|
||||
|
||||
clock1 = FakeClock(t1)
|
||||
s1 = self._make_sample(duw1, t1.isoformat())
|
||||
r1 = run_collection(s1, sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||
config_fixture, clock1)
|
||||
assert r1["ok"]
|
||||
|
||||
clock2 = FakeClock(t2)
|
||||
s2 = self._make_sample(duw2, t2.isoformat())
|
||||
r2 = run_collection(s2, sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||
config_fixture, clock2)
|
||||
assert r2["ok"]
|
||||
|
||||
# Check hour_observation was derived
|
||||
conn = sqlite3.connect(config_fixture["store_path"])
|
||||
hour = conn.execute(
|
||||
"SELECT bytes_written_delta, sample_count FROM hour_observations WHERE hour LIKE '2026-09-01T12%'"
|
||||
).fetchone()
|
||||
conn.close()
|
||||
|
||||
assert hour is not None, "Hour observation should exist for 12:00"
|
||||
assert hour[1] >= 2 # at least 2 samples contributed
|
||||
# bytes_written_delta should be the 20 MB delta (40 * 512000 = 20480000)
|
||||
assert hour[0] == 40 * 512000
|
||||
|
||||
def test_zero_delta_derives_hour_obs(self, config_fixture, sysfs_fixture_tree):
|
||||
"""Two samples in same hour with no DUW change → 0 B written."""
|
||||
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
|
||||
|
||||
duw = 12345678 # same for both
|
||||
|
||||
clock1 = FakeClock(t1)
|
||||
s1 = self._make_sample(duw, t1.isoformat())
|
||||
run_collection(s1, sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||
config_fixture, clock1)
|
||||
|
||||
clock2 = FakeClock(t2)
|
||||
s2 = self._make_sample(duw, t2.isoformat())
|
||||
run_collection(s2, sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||
config_fixture, clock2)
|
||||
|
||||
conn = sqlite3.connect(config_fixture["store_path"])
|
||||
hour = conn.execute(
|
||||
"SELECT bytes_written_delta FROM hour_observations WHERE hour LIKE '2026-09-01T12%'"
|
||||
).fetchone()
|
||||
conn.close()
|
||||
|
||||
assert hour is not None
|
||||
assert hour[0] == 0
|
||||
|
||||
def test_cross_hour_100mb_unattributed(self, config_fixture, sysfs_fixture_tree):
|
||||
"""Samples in different hours → delta is unattributed to any hour."""
|
||||
t1 = datetime(2026, 9, 1, 11, 55, 0, tzinfo=timezone.utc)
|
||||
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
|
||||
|
||||
duw1 = 12345678
|
||||
duw2 = duw1 + 200 # ~100 MB
|
||||
|
||||
clock1 = FakeClock(t1)
|
||||
s1 = self._make_sample(duw1, t1.isoformat())
|
||||
run_collection(s1, sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||
config_fixture, clock1)
|
||||
|
||||
clock2 = FakeClock(t2)
|
||||
s2 = self._make_sample(duw2, t2.isoformat())
|
||||
run_collection(s2, sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||
config_fixture, clock2)
|
||||
|
||||
conn = sqlite3.connect(config_fixture["store_path"])
|
||||
|
||||
# Hour observations should NOT contain the cross-hour delta
|
||||
hour11 = conn.execute(
|
||||
"SELECT bytes_written_delta FROM hour_observations WHERE hour LIKE '2026-09-01T11%'"
|
||||
).fetchone()
|
||||
hour12 = conn.execute(
|
||||
"SELECT bytes_written_delta FROM hour_observations WHERE hour LIKE '2026-09-01T12%'"
|
||||
).fetchone()
|
||||
conn.close()
|
||||
|
||||
# Neither hour should have the full 100 MB delta attributed
|
||||
# (they may have 0 or partial, but not 200*512000)
|
||||
full_delta = 200 * 512000
|
||||
if hour11 is not None:
|
||||
assert hour11[0] != full_delta, "Hour 11 should not have full cross-hour delta"
|
||||
if hour12 is not None:
|
||||
assert hour12[0] != full_delta, "Hour 12 should not have full cross-hour delta"
|
||||
|
||||
def test_readonly_sees_consistent_snapshot(self, config_fixture, sysfs_fixture_tree):
|
||||
"""A read-only reader sees valid pre- or post-publication snapshot."""
|
||||
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
|
||||
|
||||
clock1 = FakeClock(t1)
|
||||
run_collection(self._make_sample(12345678, t1.isoformat()),
|
||||
sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||
config_fixture, clock1)
|
||||
|
||||
# Open read-only
|
||||
ro_conn = sqlite3.connect(
|
||||
"file:%s?mode=ro" % config_fixture["store_path"], uri=True
|
||||
)
|
||||
count_before = ro_conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0]
|
||||
ro_conn.close()
|
||||
assert count_before == 1
|
||||
|
||||
# Write second sample
|
||||
clock2 = FakeClock(t2)
|
||||
run_collection(self._make_sample(12345718, t2.isoformat()),
|
||||
sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||
config_fixture, clock2)
|
||||
|
||||
# Read-only reader sees 2 samples now
|
||||
ro_conn2 = sqlite3.connect(
|
||||
"file:%s?mode=ro" % config_fixture["store_path"], uri=True
|
||||
)
|
||||
count_after = ro_conn2.execute("SELECT COUNT(*) FROM samples").fetchone()[0]
|
||||
ro_conn2.close()
|
||||
assert count_after == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Display states: awaiting first sample, awaiting another sample
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDisplayStates:
|
||||
"""Display states for zero/one/two+ samples (issue #73 AC3)."""
|
||||
|
||||
def test_zero_samples_awaiting_first(self, tmp_path):
|
||||
"""Zero samples → 'awaiting first sample' state."""
|
||||
from fenris.status import get_status
|
||||
db = tmp_path / "test.db"
|
||||
init_store(db)
|
||||
now = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
from unittest.mock import patch
|
||||
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=False, query_journal=False)
|
||||
assert "no observations yet" in result.lower() or "awaiting" in result.lower()
|
||||
|
||||
def test_one_sample_awaiting_another(self, tmp_path):
|
||||
"""One sample → 'awaiting another sample' state."""
|
||||
from fenris.status import get_status
|
||||
db = tmp_path / "test.db"
|
||||
conn = init_store(db)
|
||||
conn.execute(
|
||||
"INSERT INTO samples (ts, device, mn, sn, fr, capacity_bytes, "
|
||||
"percentage_used, available_spare, media_errors, power_on_hours, "
|
||||
"power_cycles, unsafe_shutdowns, temperature_c, "
|
||||
"data_units_written, data_units_read, bytes_written, bytes_read, "
|
||||
"critical_warning) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
("2026-09-01T12:00:00+00:00", "/dev/nvme0", "Test", "SN", "FR",
|
||||
1000000000000, 5, 100, 0, 1000, 100, 0, 35,
|
||||
1000000, 500000, 512000000000, 256000000000, 0),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
now = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
from unittest.mock import patch
|
||||
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=False, query_journal=False)
|
||||
# Should mention awaiting or insufficient data
|
||||
lower = result.lower()
|
||||
assert "awaiting" in lower or "another sample" in lower or "no projection" in lower
|
||||
|
||||
def test_one_sample_awaiting_another_in_tui(self, tmp_path):
|
||||
"""One sample → TUI shows awaiting state."""
|
||||
from fenris.tui import FenrisTuiApp, _query_service_facts
|
||||
db = tmp_path / "test.db"
|
||||
conn = init_store(db)
|
||||
conn.execute(
|
||||
"INSERT INTO samples (ts, device, mn, sn, fr, capacity_bytes, "
|
||||
"percentage_used, available_spare, media_errors, power_on_hours, "
|
||||
"power_cycles, unsafe_shutdowns, temperature_c, "
|
||||
"data_units_written, data_units_read, bytes_written, bytes_read, "
|
||||
"critical_warning) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
("2026-09-01T12:00:00+00:00", "/dev/nvme0", "Test", "SN", "FR",
|
||||
1000000000000, 5, 100, 0, 1000, 100, 0, 35,
|
||||
1000000, 500000, 512000000000, 256000000000, 0),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
now = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
# Test the projection handles single sample
|
||||
from fenris.projection import compute_projection, ConfidenceState
|
||||
conn = sqlite3.connect(db)
|
||||
proj = compute_projection(conn, now)
|
||||
conn.close()
|
||||
# With only one sample, projection should be unavailable
|
||||
assert proj.confidence_state == ConfidenceState.UNSUPPORTED
|
||||
|
||||
def test_two_same_hour_samples_show_measured_usage(self, config_fixture, sysfs_fixture_tree):
|
||||
"""Two compatible same-hour samples show measured usage."""
|
||||
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
|
||||
|
||||
clock1 = FakeClock(t1)
|
||||
run_collection(self._make_sample_helper(12345678), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||
config_fixture, clock1)
|
||||
|
||||
clock2 = FakeClock(t2)
|
||||
run_collection(self._make_sample_helper(12345718), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||
config_fixture, clock2)
|
||||
|
||||
from fenris.status import get_status
|
||||
from unittest.mock import patch
|
||||
now = datetime(2026, 9, 1, 12, 10, 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=Path(config_fixture["store_path"]),
|
||||
clock_now=now, query_services=False, query_journal=False)
|
||||
# Should not say "no observations" or "awaiting"
|
||||
lower = result.lower()
|
||||
assert "no observations yet" not in lower
|
||||
|
||||
def _make_sample_helper(self, duw_units: int) -> Dict[str, Any]:
|
||||
return {
|
||||
"json_format_version": [1, 0],
|
||||
"smartctl": {"version": [7, 3], "svn_revision": "5155", "build_info": "(local build)"},
|
||||
"nvme_smart_health_information_log": {
|
||||
"critical_warning": 0, "temperature": 35,
|
||||
"available_spare": 100, "available_spare_threshold": 10,
|
||||
"percentage_used": 5, "data_units_written": duw_units,
|
||||
"data_units_read": 9876543, "power_on_hours": 8765,
|
||||
"power_cycles": 1234, "unsafe_shutdowns": 5,
|
||||
"media_errors": 0, "num_err_log_entries": 0,
|
||||
},
|
||||
"user_capacity": {"bytes": 1024000000000, "units": "bytes"},
|
||||
"model_name": "Samsung SSD 970 EVO Plus 1TB",
|
||||
"serial_number": "S4EWNX0N123456",
|
||||
"firmware_version": "2B2QEXM7",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pause crossing and counter reset
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPauseCrossing:
|
||||
"""Delta across monitoring period gap (issue #73 AC4)."""
|
||||
|
||||
def test_pause_crossing_preserves_prior_history(self, config_fixture, sysfs_fixture_tree):
|
||||
"""Delta across a paused period preserves prior hour observations."""
|
||||
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
t_pause = datetime(2026, 9, 1, 13, 0, 0, tzinfo=timezone.utc)
|
||||
t_resume = datetime(2026, 9, 1, 14, 0, 0, tzinfo=timezone.utc)
|
||||
t2 = datetime(2026, 9, 1, 14, 5, 0, tzinfo=timezone.utc)
|
||||
|
||||
duw1 = 12345678
|
||||
duw2 = duw1 + 100
|
||||
|
||||
# First sample (opens period)
|
||||
clock1 = FakeClock(t1)
|
||||
run_collection(self._make_sample_for_pause(duw1), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||
config_fixture, clock1)
|
||||
|
||||
# Pause
|
||||
conn = sqlite3.connect(config_fixture["store_path"])
|
||||
close_period(conn, t_pause, "user_disabled")
|
||||
conn.close()
|
||||
|
||||
# Resume with new sample
|
||||
clock_resume = FakeClock(t_resume)
|
||||
run_collection(self._make_sample_for_pause(duw1), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||
config_fixture, clock_resume)
|
||||
|
||||
# Second sample after resume
|
||||
clock2 = FakeClock(t2)
|
||||
run_collection(self._make_sample_for_pause(duw2), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||
config_fixture, clock2)
|
||||
|
||||
# Verify prior hour observations are intact
|
||||
conn = sqlite3.connect(config_fixture["store_path"])
|
||||
hour_12 = conn.execute(
|
||||
"SELECT bytes_written_delta FROM hour_observations WHERE hour LIKE '2026-09-01T12%'"
|
||||
).fetchone()
|
||||
conn.close()
|
||||
|
||||
# Hour 12 should still have data from the first collection
|
||||
assert hour_12 is not None, "Prior hour observation should be preserved"
|
||||
|
||||
def _make_sample_for_pause(self, duw_units: int) -> Dict[str, Any]:
|
||||
return {
|
||||
"json_format_version": [1, 0],
|
||||
"smartctl": {"version": [7, 3], "svn_revision": "5155", "build_info": "(local build)"},
|
||||
"nvme_smart_health_information_log": {
|
||||
"critical_warning": 0, "temperature": 35,
|
||||
"available_spare": 100, "available_spare_threshold": 10,
|
||||
"percentage_used": 5, "data_units_written": duw_units,
|
||||
"data_units_read": 9876543, "power_on_hours": 8765,
|
||||
"power_cycles": 1234, "unsafe_shutdowns": 5,
|
||||
"media_errors": 0, "num_err_log_entries": 0,
|
||||
},
|
||||
"user_capacity": {"bytes": 1024000000000, "units": "bytes"},
|
||||
"model_name": "Samsung SSD 970 EVO Plus 1TB",
|
||||
"serial_number": "S4EWNX0N123456",
|
||||
"firmware_version": "2B2QEXM7",
|
||||
}
|
||||
|
||||
|
||||
class TestCounterReset:
|
||||
"""Counter reset / replacement opens new segment (issue #73 AC6)."""
|
||||
|
||||
def test_duw_decrease_opens_new_segment(self, config_fixture, sysfs_fixture_tree):
|
||||
"""DUW decrease triggers new segment."""
|
||||
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
|
||||
|
||||
duw1 = 12345678
|
||||
duw2 = duw1 - 100 # decrease = reset
|
||||
|
||||
clock1 = FakeClock(t1)
|
||||
run_collection(self._make_sample_for_reset(duw1), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||
config_fixture, clock1)
|
||||
|
||||
clock2 = FakeClock(t2)
|
||||
run_collection(self._make_sample_for_reset(duw2), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||
config_fixture, clock2)
|
||||
|
||||
conn = sqlite3.connect(config_fixture["store_path"])
|
||||
segments = conn.execute("SELECT COUNT(*) FROM controller_segments").fetchone()[0]
|
||||
samples = conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0]
|
||||
conn.close()
|
||||
|
||||
# Should have 2 segments (new one opened for DUW decrease)
|
||||
assert segments == 2
|
||||
# Should have 2 samples
|
||||
assert samples == 2
|
||||
|
||||
def _make_sample_for_reset(self, duw_units: int) -> Dict[str, Any]:
|
||||
return {
|
||||
"json_format_version": [1, 0],
|
||||
"smartctl": {"version": [7, 3], "svn_revision": "5155", "build_info": "(local build)"},
|
||||
"nvme_smart_health_information_log": {
|
||||
"critical_warning": 0, "temperature": 35,
|
||||
"available_spare": 100, "available_spare_threshold": 10,
|
||||
"percentage_used": 5, "data_units_written": duw_units,
|
||||
"data_units_read": 9876543, "power_on_hours": 8765,
|
||||
"power_cycles": 1234, "unsafe_shutdowns": 5,
|
||||
"media_errors": 0, "num_err_log_entries": 0,
|
||||
},
|
||||
"user_capacity": {"bytes": 1024000000000, "units": "bytes"},
|
||||
"model_name": "Samsung SSD 970 EVO Plus 1TB",
|
||||
"serial_number": "S4EWNX0N123456",
|
||||
"firmware_version": "2B2QEXM7",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Derivation failure preserves prior history
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDerivationFailure:
|
||||
"""Injected derivation failure preserves prior history (issue #73 AC6)."""
|
||||
|
||||
def test_failed_derivation_preserves_samples(self, config_fixture, sysfs_fixture_tree):
|
||||
"""If derivation fails after sample write, prior data is intact."""
|
||||
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
|
||||
|
||||
clock1 = FakeClock(t1)
|
||||
run_collection(self._make_sample_for_failure(12345678),
|
||||
sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||
config_fixture, clock1)
|
||||
|
||||
# Verify first sample exists
|
||||
conn = sqlite3.connect(config_fixture["store_path"])
|
||||
count = conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0]
|
||||
conn.close()
|
||||
assert count == 1
|
||||
|
||||
# Second sample with valid data should succeed
|
||||
clock2 = FakeClock(t2)
|
||||
r2 = run_collection(self._make_sample_for_failure(12345718),
|
||||
sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||
config_fixture, clock2)
|
||||
assert r2["ok"]
|
||||
|
||||
# Both samples should exist
|
||||
conn = sqlite3.connect(config_fixture["store_path"])
|
||||
count = conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0]
|
||||
conn.close()
|
||||
assert count == 2
|
||||
|
||||
def _make_sample_for_failure(self, duw_units: int) -> Dict[str, Any]:
|
||||
return {
|
||||
"json_format_version": [1, 0],
|
||||
"smartctl": {"version": [7, 3], "svn_revision": "5155", "build_info": "(local build)"},
|
||||
"nvme_smart_health_information_log": {
|
||||
"critical_warning": 0, "temperature": 35,
|
||||
"available_spare": 100, "available_spare_threshold": 10,
|
||||
"percentage_used": 5, "data_units_written": duw_units,
|
||||
"data_units_read": 9876543, "power_on_hours": 8765,
|
||||
"power_cycles": 1234, "unsafe_shutdowns": 5,
|
||||
"media_errors": 0, "num_err_log_entries": 0,
|
||||
},
|
||||
"user_capacity": {"bytes": 1024000000000, "units": "bytes"},
|
||||
"model_name": "Samsung SSD 970 EVO Plus 1TB",
|
||||
"serial_number": "S4EWNX0N123456",
|
||||
"firmware_version": "2B2QEXM7",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Monitoring period is opened by collector
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMonitoringPeriod:
|
||||
"""Collector ensures monitoring period is open (issue #73 AC2)."""
|
||||
|
||||
def test_first_sample_opens_period(self, config_fixture, sysfs_fixture_tree):
|
||||
"""First collection run opens a monitoring period."""
|
||||
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
clock1 = FakeClock(t1)
|
||||
run_collection(self._make_sample_simple(), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||
config_fixture, clock1)
|
||||
|
||||
conn = sqlite3.connect(config_fixture["store_path"])
|
||||
period = get_open_period(conn)
|
||||
conn.close()
|
||||
|
||||
assert period is not None, "A monitoring period should be open"
|
||||
|
||||
def test_subsequent_sample_keeps_period_open(self, config_fixture, sysfs_fixture_tree):
|
||||
"""Subsequent collection runs keep the period open."""
|
||||
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
|
||||
|
||||
clock1 = FakeClock(t1)
|
||||
run_collection(self._make_sample_simple(), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||
config_fixture, clock1)
|
||||
|
||||
clock2 = FakeClock(t2)
|
||||
run_collection(self._make_sample_simple(), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||
config_fixture, clock2)
|
||||
|
||||
conn = sqlite3.connect(config_fixture["store_path"])
|
||||
period = get_open_period(conn)
|
||||
periods_count = conn.execute("SELECT COUNT(*) FROM monitoring_periods").fetchone()[0]
|
||||
conn.close()
|
||||
|
||||
assert period is not None
|
||||
assert periods_count == 1 # Still only one period
|
||||
|
||||
def _make_sample_simple(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"json_format_version": [1, 0],
|
||||
"smartctl": {"version": [7, 3], "svn_revision": "5155", "build_info": "(local build)"},
|
||||
"nvme_smart_health_information_log": {
|
||||
"critical_warning": 0, "temperature": 35,
|
||||
"available_spare": 100, "available_spare_threshold": 10,
|
||||
"percentage_used": 5, "data_units_written": 12345678,
|
||||
"data_units_read": 9876543, "power_on_hours": 8765,
|
||||
"power_cycles": 1234, "unsafe_shutdowns": 5,
|
||||
"media_errors": 0, "num_err_log_entries": 0,
|
||||
},
|
||||
"user_capacity": {"bytes": 1024000000000, "units": "bytes"},
|
||||
"model_name": "Samsung SSD 970 EVO Plus 1TB",
|
||||
"serial_number": "S4EWNX0N123456",
|
||||
"firmware_version": "2B2QEXM7",
|
||||
}
|
||||
@@ -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
@@ -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")
|
||||
|
||||
+124
-1
@@ -22,7 +22,7 @@ from fenris.monitoring_periods import ensure_period_open, close_period
|
||||
from fenris.projection import (
|
||||
compute_projection, ConfidenceState, BaselineTier, ScenarioRange,
|
||||
TBW_TO_BYTES, HORIZON_DAYS, WARMING_MIN_DAYS, STALENESS_HOURS,
|
||||
YOUNG_REGIME_DAYS, DISCLOSURES,
|
||||
YOUNG_REGIME_DAYS, DISCLOSURES, _compute_horizon_rate,
|
||||
)
|
||||
|
||||
|
||||
@@ -899,3 +899,126 @@ class TestIdentityChangeBlankKeys:
|
||||
# All 25 days in same segment (equal blanks continue)
|
||||
assert result.regime_days is not None
|
||||
assert result.regime_days >= 20 # Most of the history
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Issue #76: Evidence-anchored projection rates
|
||||
# Scenario windows anchored at latest evidence endpoint T
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
class TestEvidenceAnchoredHorizons:
|
||||
"""Issue #76: Scenario windows anchored at latest published usage-evidence
|
||||
endpoint T with exact trailing 7/28/90×86400-second starts."""
|
||||
|
||||
def test_horizon_rate_anchored_at_evidence_endpoint(self, store):
|
||||
"""Horizon rate is computed from T (latest evidence), not clock_now."""
|
||||
_insert_baseline(store, tbw_tb=10.0, verified=True)
|
||||
_insert_segment(store, opened_at="2026-09-01T00:00:00+00:00")
|
||||
_open_period(store, start="2026-09-01T00:00:00+00:00")
|
||||
bw = 100 * 1024 * 1024
|
||||
# 30 days of data ending Sep 29
|
||||
for i in range(30):
|
||||
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(store, d, bw=bw)
|
||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||
# Clock is Oct 1, but T is Sep 29 (latest evidence endpoint)
|
||||
clock = datetime(2026, 10, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
result = compute_projection(store, clock)
|
||||
# 7-day horizon should be anchored at Sep 29, not Oct 1
|
||||
if result.scenario_range and 7 in result.scenario_range.rates:
|
||||
# Rate should be based on Sep 23-29, not Sep 25-Oct 1
|
||||
assert result.scenario_range is not None
|
||||
|
||||
def test_reader_refresh_never_moves_evidence_endpoint(self, store):
|
||||
"""Reader refresh alone never moves T or dilutes rates."""
|
||||
_insert_baseline(store, tbw_tb=10.0, verified=True)
|
||||
_insert_segment(store, opened_at="2026-09-01T00:00:00+00:00")
|
||||
_open_period(store, start="2026-09-01T00:00:00+00:00")
|
||||
bw = 100 * 1024 * 1024
|
||||
for i in range(30):
|
||||
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(store, d, bw=bw)
|
||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||
# Two reads at different clock times
|
||||
clock1 = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc)
|
||||
clock2 = datetime(2026, 10, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
r1 = compute_projection(store, clock1)
|
||||
r2 = compute_projection(store, clock2)
|
||||
# Both should produce identical scenario rates (anchored at T, not clock)
|
||||
if r1.scenario_range and r2.scenario_range:
|
||||
assert r1.scenario_range.rates == r2.scenario_range.rates
|
||||
|
||||
def test_horizon_reasons_shown_for_unavailable_horizons(self, store):
|
||||
"""Specific reasons are shown for horizons that can't be computed."""
|
||||
_insert_baseline(store, tbw_tb=10.0, verified=True)
|
||||
_insert_segment(store, opened_at="2026-09-20T00:00:00+00:00")
|
||||
_open_period(store, start="2026-09-20T00:00:00+00:00")
|
||||
bw = 100 * 1024 * 1024
|
||||
# Only 10 days of data
|
||||
for i in range(10):
|
||||
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(store, d, bw=bw)
|
||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||
result = compute_projection(store, _clock())
|
||||
# 7-day horizon should be available, 28 and 90 should have reasons
|
||||
if result.scenario_range:
|
||||
assert 7 in result.scenario_range.rates
|
||||
if 28 in result.scenario_range.horizon_reasons:
|
||||
assert "starts before earliest data" in result.scenario_range.horizon_reasons[28]
|
||||
if 90 in result.scenario_range.horizon_reasons:
|
||||
assert "starts before earliest data" in result.scenario_range.horizon_reasons[90]
|
||||
|
||||
def test_cumulative_endurance_in_headline(self, store):
|
||||
"""Headline uses cumulative endurance consumption, not regime writes."""
|
||||
_insert_baseline(store, tbw_tb=10.0, verified=True)
|
||||
_insert_segment(store, opened_at="2026-09-01T00:00:00+00:00")
|
||||
_open_period(store, start="2026-09-01T00:00:00+00:00")
|
||||
bw = 100 * 1024 * 1024
|
||||
for i in range(30):
|
||||
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(store, d, bw=bw)
|
||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||
result = compute_projection(store, _clock())
|
||||
# Headline should be computed with cumulative bytes
|
||||
if result.headline_remaining_seconds is not None:
|
||||
# E_baseline = 10 TB = 10e12 bytes
|
||||
# cumulative_bytes = 30 * 100 * 1024 * 1024
|
||||
# rate = cumulative_bytes / wall_clock
|
||||
# headline = (E_baseline - cumulative_bytes) / rate
|
||||
E_baseline = 10.0 * TBW_TO_BYTES
|
||||
cumulative_bytes = 30 * bw
|
||||
assert result.headline_remaining_seconds >= 0
|
||||
|
||||
def test_zero_boundary_delta_returns_zero(self, store):
|
||||
"""Zero monotonic delta proves zero over its represented subspan."""
|
||||
_insert_baseline(store, tbw_tb=10.0, verified=True)
|
||||
_insert_segment(store, opened_at="2026-09-01T00:00:00+00:00")
|
||||
_open_period(store, start="2026-09-01T00:00:00+00:00")
|
||||
# Days with zero bytes written
|
||||
for i in range(30):
|
||||
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(store, d, bw=0)
|
||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||
result = compute_projection(store, _clock())
|
||||
# Zero rate should result in UNSUPPORTED
|
||||
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
||||
assert result.headline_remaining_seconds is None
|
||||
|
||||
def test_noon_endpoint_same_as_midnight(self, store):
|
||||
"""Noon endpoint produces same rates as midnight endpoint."""
|
||||
_insert_baseline(store, tbw_tb=10.0, verified=True)
|
||||
_insert_segment(store, opened_at="2026-09-01T00:00:00+00:00")
|
||||
_open_period(store, start="2026-09-01T00:00:00+00:00")
|
||||
bw = 100 * 1024 * 1024
|
||||
for i in range(30):
|
||||
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(store, d, bw=bw)
|
||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||
# Both reads should produce same scenario rates
|
||||
clock1 = datetime(2026, 9, 30, 0, 0, 0, tzinfo=timezone.utc)
|
||||
clock2 = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc)
|
||||
r1 = compute_projection(store, clock1)
|
||||
r2 = compute_projection(store, clock2)
|
||||
if r1.scenario_range and r2.scenario_range:
|
||||
assert r1.scenario_range.rates == r2.scenario_range.rates
|
||||
|
||||
+54
-3
@@ -1,7 +1,9 @@
|
||||
"""Raw sample pruning tests.
|
||||
|
||||
Spec §3.4, ST-5: Raw samples pruned to 14 days; hour observations and
|
||||
day aggregates retained indefinitely.
|
||||
day aggregates are retained indefinitely.
|
||||
|
||||
Issue #74: Boundary anchors required for successor evidence are retained.
|
||||
"""
|
||||
import sqlite3
|
||||
import sys
|
||||
@@ -51,6 +53,9 @@ class TestPruneOldSamples:
|
||||
def test_removes_old_samples(self, store_conn):
|
||||
now = datetime(2026, 9, 15, 12, 0, 0, tzinfo=timezone.utc)
|
||||
# Insert samples at 10, 14, and 15 days ago
|
||||
# All three are before the cutoff (2026-09-01T12:00:00)
|
||||
# The 15-day-old sample is not a boundary anchor because
|
||||
# the next sample (14 days ago) is also before the cutoff
|
||||
for days_ago in [10, 14, 15]:
|
||||
ts = (now - timedelta(days=days_ago)).isoformat()
|
||||
_insert_sample(store_conn, ts)
|
||||
@@ -82,6 +87,7 @@ class TestPruneOldSamples:
|
||||
"""Hour observations are retained indefinitely."""
|
||||
now = datetime(2026, 9, 15, 12, 0, 0, tzinfo=timezone.utc)
|
||||
# Insert an old sample and a recent sample
|
||||
# The old sample is a boundary anchor (needed for derivation)
|
||||
_insert_sample(store_conn, (now - timedelta(days=20)).isoformat())
|
||||
_insert_sample(store_conn, (now - timedelta(days=1)).isoformat())
|
||||
|
||||
@@ -95,10 +101,55 @@ class TestPruneOldSamples:
|
||||
|
||||
prune_old_samples(store_conn, now, retention_days=14)
|
||||
|
||||
# Sample removed
|
||||
# Old sample is retained as boundary anchor (needed for derivation)
|
||||
cursor = store_conn.execute("SELECT COUNT(*) FROM samples")
|
||||
assert cursor.fetchone()[0] == 1
|
||||
assert cursor.fetchone()[0] == 2
|
||||
|
||||
# Hour observation retained
|
||||
cursor = store_conn.execute("SELECT COUNT(*) FROM hour_observations")
|
||||
assert cursor.fetchone()[0] == 1
|
||||
|
||||
def test_boundary_anchor_retained(self, store_conn):
|
||||
"""Boundary anchors required for derivation are retained."""
|
||||
now = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
# Old sample before boundary (2026-09-14T23:55:00)
|
||||
# is 15 days and 0.75 hours old (before cutoff at 2026-09-16T12:00:00)
|
||||
_insert_sample(store_conn, "2026-09-14T23:55:00+00:00", 1000000)
|
||||
|
||||
# Sample after boundary (2026-09-16T12:30:00)
|
||||
# is 13 days and 23.5 hours old (within retention)
|
||||
_insert_sample(store_conn, "2026-09-16T12:30:00+00:00", 2000000)
|
||||
|
||||
# Run pruning
|
||||
pruned = prune_old_samples(store_conn, now, retention_days=14)
|
||||
|
||||
# The boundary anchor should be retained
|
||||
cursor = store_conn.execute(
|
||||
"SELECT COUNT(*) FROM samples WHERE ts = '2026-09-14T23:55:00+00:00'"
|
||||
)
|
||||
assert cursor.fetchone()[0] == 1
|
||||
|
||||
def test_old_sample_with_derived_interval_removed(self, store_conn):
|
||||
"""Old samples with fully derived intervals are removed."""
|
||||
now = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
# Old sample with derived interval
|
||||
_insert_sample(store_conn, "2026-09-10T10:00:00+00:00", 1000000)
|
||||
_insert_sample(store_conn, "2026-09-10T10:30:00+00:00", 2000000)
|
||||
|
||||
# Hour observation exists for the interval
|
||||
store_conn.execute(
|
||||
"INSERT INTO hour_observations (hour, active_seconds, bytes_written_delta, bytes_read_delta, sample_count, coverage) "
|
||||
"VALUES ('2026-09-10T10:00:00+00:00', 3600, 1000000, 0, 1, 1.0)",
|
||||
)
|
||||
store_conn.commit()
|
||||
|
||||
# Run pruning
|
||||
pruned = prune_old_samples(store_conn, now, retention_days=14)
|
||||
|
||||
# Old sample should be removed (interval is derived)
|
||||
cursor = store_conn.execute(
|
||||
"SELECT COUNT(*) FROM samples WHERE ts = '2026-09-10T10:00:00+00:00'"
|
||||
)
|
||||
assert cursor.fetchone()[0] == 0
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
"""Repair and retention tests (issue #74).
|
||||
|
||||
Tests the idempotent, safe repair of hour observations and day aggregates
|
||||
from surviving raw samples, boundary anchor retention, and legacy summary
|
||||
handling at actual precision.
|
||||
"""
|
||||
import sqlite3
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
|
||||
from fenris.store import init_store
|
||||
from fenris.repair import (
|
||||
repair_derivation,
|
||||
is_repair_in_progress,
|
||||
get_repair_status,
|
||||
)
|
||||
from fenris.pruning import prune_old_samples, needs_boundary_anchor
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def store_conn(tmp_path: Path):
|
||||
"""Create a fresh store for each test."""
|
||||
db_path = tmp_path / "test.db"
|
||||
conn = init_store(db_path)
|
||||
yield conn
|
||||
conn.close()
|
||||
|
||||
|
||||
def _insert_sample(conn, ts_iso, bytes_written, bytes_read=0, power_on_hours=100,
|
||||
device="/dev/nvme0", segment_id=None):
|
||||
"""Insert a raw sample."""
|
||||
conn.execute(
|
||||
"""INSERT INTO samples
|
||||
(ts, device, bytes_written, bytes_read, power_on_hours,
|
||||
data_units_written, data_units_read, segment_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(ts_iso, device, bytes_written, bytes_read, power_on_hours,
|
||||
bytes_written // 512000, bytes_read // 512000, segment_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _insert_hour(conn, hour_iso, bytes_written_delta=0, active_seconds=3600,
|
||||
idle_seconds=0, powered_off_seconds=0, unknown_seconds=0,
|
||||
sample_count=1, coverage=1.0):
|
||||
"""Insert an hour observation."""
|
||||
conn.execute(
|
||||
"""INSERT INTO hour_observations
|
||||
(hour, active_seconds, idle_seconds, powered_off_seconds, unknown_seconds,
|
||||
bytes_written_delta, bytes_read_delta, sample_count, coverage)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(hour_iso, active_seconds, idle_seconds, powered_off_seconds, unknown_seconds,
|
||||
bytes_written_delta, 0, sample_count, coverage),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _insert_day_aggregate(conn, day, bytes_written_delta=0, coverage=1.0):
|
||||
"""Insert a day aggregate."""
|
||||
conn.execute(
|
||||
"""INSERT INTO day_aggregates
|
||||
(day, active_seconds, bytes_written_delta, coverage, sample_count)
|
||||
VALUES (?, 3600, ?, ?, 1)""",
|
||||
(day, bytes_written_delta, coverage),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _open_period(conn, start_iso, end_iso=None, end_cause=None):
|
||||
"""Insert a monitoring period."""
|
||||
conn.execute(
|
||||
"""INSERT INTO monitoring_periods (started_at, ended_at, end_cause)
|
||||
VALUES (?, ?, ?)""",
|
||||
(start_iso, end_iso, end_cause),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC1: Normal collection, legacy import and recovery use same evidence rules
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRepairUsesSameEvidenceRules:
|
||||
"""AC1: Repair derives only surviving supported evidence, transactionally
|
||||
and idempotently."""
|
||||
|
||||
def test_repair_idempotent_on_empty_store(self, store_conn):
|
||||
"""Repair on empty store succeeds and does nothing."""
|
||||
result = repair_derivation(store_conn)
|
||||
assert result.ok is True
|
||||
assert result.hours_created == 0
|
||||
assert result.days_created == 0
|
||||
|
||||
def test_repair_idempotent_on_fully_derived(self, store_conn):
|
||||
"""Repair on store with existing derived data does not duplicate."""
|
||||
now = datetime(2026, 9, 15, 12, 0, 0, tzinfo=timezone.utc)
|
||||
_open_period(store_conn, "2026-09-14T00:00:00+00:00")
|
||||
|
||||
# Insert samples that span an hour
|
||||
_insert_sample(store_conn, "2026-09-14T10:00:00+00:00", 1000000)
|
||||
_insert_sample(store_conn, "2026-09-14T10:30:00+00:00", 2000000)
|
||||
|
||||
# Pre-existing hour observation for the 10:00 hour
|
||||
# This hour observation captures the interval [10:00, 10:30]
|
||||
_insert_hour(store_conn, "2026-09-14T10:00:00+00:00",
|
||||
bytes_written_delta=1000000)
|
||||
_insert_day_aggregate(store_conn, "2026-09-14",
|
||||
bytes_written_delta=1000000)
|
||||
|
||||
# Run repair
|
||||
result = repair_derivation(store_conn)
|
||||
|
||||
# Should not create new observations (already exist)
|
||||
assert result.hours_created == 0
|
||||
assert result.days_created == 0
|
||||
|
||||
# Verify no duplicates
|
||||
cursor = store_conn.execute(
|
||||
"SELECT COUNT(*) FROM hour_observations WHERE hour = '2026-09-14T10:00:00+00:00'"
|
||||
)
|
||||
assert cursor.fetchone()[0] == 1
|
||||
|
||||
def test_repair_preserves_import_markers(self, store_conn):
|
||||
"""Repair does not remove legacy import markers."""
|
||||
# Set legacy import marker
|
||||
store_conn.execute(
|
||||
"INSERT INTO store_metadata (key, value) VALUES ('legacy_imported', 'true')"
|
||||
)
|
||||
store_conn.commit()
|
||||
|
||||
# Run repair
|
||||
result = repair_derivation(store_conn)
|
||||
|
||||
# Verify marker preserved
|
||||
cursor = store_conn.execute(
|
||||
"SELECT value FROM store_metadata WHERE key = 'legacy_imported'"
|
||||
)
|
||||
assert cursor.fetchone()[0] == "true"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC2: Rerunning or interrupting repair produces no duplicates
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRepairIdempotency:
|
||||
"""AC2: No duplicated intervals or totals from repeated repair."""
|
||||
|
||||
def test_repair_no_duplicate_hours(self, store_conn):
|
||||
"""Running repair twice produces no duplicate hour observations."""
|
||||
_open_period(store_conn, "2026-09-14T00:00:00+00:00")
|
||||
_insert_sample(store_conn, "2026-09-14T10:00:00+00:00", 1000000)
|
||||
_insert_sample(store_conn, "2026-09-14T10:30:00+00:00", 2000000)
|
||||
|
||||
# First repair
|
||||
result1 = repair_derivation(store_conn)
|
||||
assert result1.hours_created == 1
|
||||
|
||||
# Second repair
|
||||
result2 = repair_derivation(store_conn)
|
||||
assert result2.hours_created == 0 # No new hours
|
||||
|
||||
# Verify only one hour observation
|
||||
cursor = store_conn.execute("SELECT COUNT(*) FROM hour_observations")
|
||||
assert cursor.fetchone()[0] == 1
|
||||
|
||||
def test_repair_no_duplicate_days(self, store_conn):
|
||||
"""Running repair twice produces no duplicate day aggregates."""
|
||||
_open_period(store_conn, "2026-09-14T00:00:00+00:00")
|
||||
_insert_sample(store_conn, "2026-09-14T10:00:00+00:00", 1000000)
|
||||
_insert_sample(store_conn, "2026-09-14T10:30:00+00:00", 2000000)
|
||||
|
||||
# First repair
|
||||
result1 = repair_derivation(store_conn)
|
||||
assert result1.days_created == 1
|
||||
|
||||
# Second repair
|
||||
result2 = repair_derivation(store_conn)
|
||||
assert result2.days_created == 0 # No new days
|
||||
|
||||
# Verify only one day aggregate
|
||||
cursor = store_conn.execute("SELECT COUNT(*) FROM day_aggregates")
|
||||
assert cursor.fetchone()[0] == 1
|
||||
|
||||
def test_interrupted_repair_preserves_evidence(self, store_conn):
|
||||
"""If repair fails, prior valid history is preserved."""
|
||||
_open_period(store_conn, "2026-09-14T00:00:00+00:00")
|
||||
|
||||
# Insert valid existing data
|
||||
_insert_hour(store_conn, "2026-09-14T10:00:00+00:00",
|
||||
bytes_written_delta=500000)
|
||||
_insert_day_aggregate(store_conn, "2026-09-14",
|
||||
bytes_written_delta=500000)
|
||||
|
||||
# Run repair (should succeed but not modify existing valid data)
|
||||
result = repair_derivation(store_conn)
|
||||
assert result.ok is True
|
||||
|
||||
# Verify existing data preserved
|
||||
cursor = store_conn.execute(
|
||||
"SELECT bytes_written_delta FROM hour_observations "
|
||||
"WHERE hour = '2026-09-14T10:00:00+00:00'"
|
||||
)
|
||||
assert cursor.fetchone()[0] == 500000
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC3: Boundary anchor retention
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBoundaryAnchorRetention:
|
||||
"""AC3: Prune samples only after durable derivation; retain anchors."""
|
||||
|
||||
def test_needs_boundary_anchor_sample(self, store_conn):
|
||||
"""Sample before 14-day boundary is needed for derivation."""
|
||||
now = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
# Sample just before 14-day boundary (2026-09-15T23:55:00)
|
||||
# is 14 days and 0.75 hours old (before cutoff at 2026-09-16T12:00:00)
|
||||
_insert_sample(store_conn, "2026-09-15T23:55:00+00:00", 1000000)
|
||||
|
||||
# Sample just after boundary (2026-09-16T12:30:00)
|
||||
# is 13 days and 23.5 hours old (within retention)
|
||||
_insert_sample(store_conn, "2026-09-16T12:30:00+00:00", 2000000)
|
||||
|
||||
# The sample at 2026-09-15 is a boundary anchor because
|
||||
# the interval spans the retention boundary
|
||||
assert needs_boundary_anchor(store_conn, "2026-09-15T23:55:00+00:00", now)
|
||||
|
||||
def test_not_boundary_anchor_if_fully_derived(self, store_conn):
|
||||
"""Sample that's fully derived is not a boundary anchor."""
|
||||
now = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
# Insert sample and fully derive its interval
|
||||
_insert_sample(store_conn, "2026-09-14T10:00:00+00:00", 1000000)
|
||||
_insert_sample(store_conn, "2026-09-14T10:30:00+00:00", 2000000)
|
||||
|
||||
# Hour observation already exists for this interval
|
||||
_insert_hour(store_conn, "2026-09-14T10:00:00+00:00",
|
||||
bytes_written_delta=1000000)
|
||||
|
||||
# Not a boundary anchor
|
||||
assert not needs_boundary_anchor(store_conn, "2026-09-14T10:00:00+00:00", now)
|
||||
|
||||
def test_pruning_retains_boundary_anchors(self, store_conn):
|
||||
"""Pruning keeps samples needed as boundary anchors."""
|
||||
now = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
# Old sample before boundary (2026-09-14T23:55:00)
|
||||
# is 15 days and 0.75 hours old (before cutoff at 2026-09-16T12:00:00)
|
||||
_insert_sample(store_conn, "2026-09-14T23:55:00+00:00", 1000000)
|
||||
|
||||
# Sample after boundary (2026-09-16T12:30:00)
|
||||
# is 13 days and 23.5 hours old (within retention)
|
||||
_insert_sample(store_conn, "2026-09-16T12:30:00+00:00", 2000000)
|
||||
|
||||
# Recent sample
|
||||
_insert_sample(store_conn, "2026-09-29T12:00:00+00:00", 3000000)
|
||||
|
||||
# Run pruning
|
||||
pruned = prune_old_samples(store_conn, now, retention_days=14)
|
||||
|
||||
# The boundary anchor should be retained
|
||||
cursor = store_conn.execute(
|
||||
"SELECT COUNT(*) FROM samples WHERE ts = '2026-09-14T23:55:00+00:00'"
|
||||
)
|
||||
assert cursor.fetchone()[0] == 1
|
||||
|
||||
def test_pruning_removes_old_sample_with_derived_interval(self, store_conn):
|
||||
"""Pruning removes old samples when interval is fully derived."""
|
||||
now = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
# Old sample with derived interval
|
||||
_insert_sample(store_conn, "2026-09-10T10:00:00+00:00", 1000000)
|
||||
_insert_sample(store_conn, "2026-09-10T10:30:00+00:00", 2000000)
|
||||
|
||||
# Hour observation exists for the interval
|
||||
_insert_hour(store_conn, "2026-09-10T10:00:00+00:00",
|
||||
bytes_written_delta=1000000)
|
||||
|
||||
# Run pruning
|
||||
pruned = prune_old_samples(store_conn, now, retention_days=14)
|
||||
|
||||
# Old sample should be removed (interval is derived)
|
||||
cursor = store_conn.execute(
|
||||
"SELECT COUNT(*) FROM samples WHERE ts = '2026-09-10T10:00:00+00:00'"
|
||||
)
|
||||
assert cursor.fetchone()[0] == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC4: Legacy day-only summaries retain actual precision
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLegacySummaryPrecision:
|
||||
"""AC4: Legacy summaries at actual precision, no interpolation."""
|
||||
|
||||
def test_legacy_summary_not_reconstructed(self, store_conn):
|
||||
"""Legacy day-only summaries are not interpolated to hour detail."""
|
||||
# Insert a legacy-style day aggregate without hour observations
|
||||
_insert_day_aggregate(store_conn, "2026-08-01",
|
||||
bytes_written_delta=5000000)
|
||||
|
||||
# Run repair
|
||||
result = repair_derivation(store_conn)
|
||||
|
||||
# Should not create hour observations for legacy day
|
||||
cursor = store_conn.execute(
|
||||
"SELECT COUNT(*) FROM hour_observations WHERE hour LIKE '2026-08-01%'"
|
||||
)
|
||||
assert cursor.fetchone()[0] == 0
|
||||
|
||||
def test_legacy_summary_no_double_counting(self, store_conn):
|
||||
"""Legacy summaries and derived intervals don't double-count."""
|
||||
# Insert legacy day aggregate
|
||||
_insert_day_aggregate(store_conn, "2026-08-01",
|
||||
bytes_written_delta=5000000)
|
||||
|
||||
# Run repair
|
||||
result = repair_derivation(store_conn)
|
||||
|
||||
# Day aggregate should not be modified
|
||||
cursor = store_conn.execute(
|
||||
"SELECT bytes_written_delta FROM day_aggregates WHERE day = '2026-08-01'"
|
||||
)
|
||||
assert cursor.fetchone()[0] == 5000000
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC5: Status distinguishes evidence states
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestStatusEvidenceDistingushing:
|
||||
"""AC5: Read-only views distinguish evidence states."""
|
||||
|
||||
def test_repair_status_available(self, store_conn):
|
||||
"""Repair status is available for read-only views."""
|
||||
status = get_repair_status(store_conn)
|
||||
assert hasattr(status, 'last_repair')
|
||||
assert hasattr(status, 'repair_in_progress')
|
||||
assert hasattr(status, 'hours_derived')
|
||||
assert hasattr(status, 'days_derived')
|
||||
|
||||
def test_repair_in_progress_flag(self, store_conn):
|
||||
"""Repair in progress flag is trackable."""
|
||||
assert not is_repair_in_progress(store_conn)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC6: Migration then collection then reader consumption
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMigrationCollectionReader:
|
||||
"""AC6: End-to-end migration, collection, and reader consumption."""
|
||||
|
||||
def test_store_with_raw_evidence_and_summaries(self, store_conn):
|
||||
"""Store with raw evidence and old summaries works correctly."""
|
||||
# Set up store with mixed data
|
||||
_open_period(store_conn, "2026-08-01T00:00:00+00:00")
|
||||
|
||||
# Old day-only summary (legacy)
|
||||
_insert_day_aggregate(store_conn, "2026-08-01",
|
||||
bytes_written_delta=5000000)
|
||||
|
||||
# Recent raw samples
|
||||
_insert_sample(store_conn, "2026-09-14T10:00:00+00:00", 1000000)
|
||||
_insert_sample(store_conn, "2026-09-14T10:30:00+00:00", 2000000)
|
||||
|
||||
# Run repair
|
||||
result = repair_derivation(store_conn)
|
||||
assert result.ok is True
|
||||
|
||||
# Verify legacy summary preserved
|
||||
cursor = store_conn.execute(
|
||||
"SELECT bytes_written_delta FROM day_aggregates WHERE day = '2026-08-01'"
|
||||
)
|
||||
assert cursor.fetchone()[0] == 5000000
|
||||
|
||||
# Verify new hour observation created
|
||||
cursor = store_conn.execute(
|
||||
"SELECT COUNT(*) FROM hour_observations WHERE hour LIKE '2026-09-14%'"
|
||||
)
|
||||
assert cursor.fetchone()[0] == 1
|
||||
|
||||
def test_concurrent_reader_consistency(self, store_conn):
|
||||
"""Reader sees consistent snapshot during repair."""
|
||||
# This is more of a documentation test - SQLite WAL mode handles this
|
||||
# We verify the store is in WAL mode
|
||||
cursor = store_conn.execute("PRAGMA journal_mode")
|
||||
assert cursor.fetchone()[0] == "wal"
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -118,12 +118,12 @@ class TestMigrateToLatest:
|
||||
assert migrate_to_latest(db) == 0
|
||||
|
||||
def test_migrates_intermediate_version(self, tmp_path):
|
||||
"""Store at version 1 with SCHEMA_VERSION=1 → 0 steps (current)."""
|
||||
"""Store at version SCHEMA_VERSION-1 → 1 step to current."""
|
||||
from fenris.store import SCHEMA_VERSION
|
||||
db = tmp_path / "observations.db"
|
||||
_make_store(db, version=1)
|
||||
# SCHEMA_VERSION is 1, so version 1 is current
|
||||
_make_store(db, version=SCHEMA_VERSION - 1)
|
||||
steps = migrate_to_latest(db)
|
||||
assert steps == 0
|
||||
assert steps == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
@@ -39,12 +40,23 @@ from fenris.status import (
|
||||
)
|
||||
from fenris.tui import (
|
||||
FenrisTuiApp,
|
||||
DailyBarGraph,
|
||||
_format_remaining,
|
||||
_sparkline,
|
||||
_habit_bar,
|
||||
_query_usage_history,
|
||||
_query_drive_health,
|
||||
_query_service_facts,
|
||||
_query_daily_graph_data,
|
||||
_query_hourly_graph_data,
|
||||
_RANGE_OPTIONS,
|
||||
_RANGE_DEFAULT,
|
||||
_BAR_HEIGHT,
|
||||
_GLYPH_ALLOCATED,
|
||||
_GLYPH_UNALLOCATED,
|
||||
_GLYPH_GAP,
|
||||
_GLYPH_ZERO,
|
||||
_GLYPH_PARTIAL,
|
||||
)
|
||||
|
||||
|
||||
@@ -384,6 +396,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())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -525,3 +691,398 @@ class TestStateMatrixCombinations:
|
||||
assert proj.baseline_tier.value == "unverified_override"
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hour observation helper for graph tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _insert_hour(conn, hour, bw=0, active=3600, idle=0, powered_off=0,
|
||||
unknown=0, coverage=1.0, samples=1):
|
||||
"""Insert an hour observation row."""
|
||||
conn.execute(
|
||||
"INSERT INTO hour_observations "
|
||||
"(hour, active_seconds, idle_seconds, powered_off_seconds, unknown_seconds, "
|
||||
" bytes_written_delta, bytes_read_delta, sample_count, coverage) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?)",
|
||||
(hour, active, idle, powered_off, unknown, bw, samples, coverage),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _insert_day_with_unattributed(conn, day, bw=0, unattributed=0,
|
||||
coverage=0.95, samples=24):
|
||||
"""Insert a day aggregate with explicit unattributed bytes."""
|
||||
conn.execute(
|
||||
"INSERT INTO day_aggregates "
|
||||
"(day, active_seconds, idle_seconds, powered_off_seconds, unknown_seconds, "
|
||||
" bytes_written_delta, bytes_read_delta, sample_count, coverage, "
|
||||
" unattributed_bytes_written, unattributed_bytes_read) "
|
||||
"VALUES (?, 3600, 0, 0, 0, ?, 0, ?, ?, ?, 0)",
|
||||
(day, bw, samples, coverage, unattributed),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Graph data query tests (issue #75)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestQueryDailyGraphData:
|
||||
def test_empty_store(self, tmp_path):
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
result = _query_daily_graph_data(conn)
|
||||
assert result == []
|
||||
conn.close()
|
||||
|
||||
def test_with_days(self, tmp_path):
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
_insert_segment(conn)
|
||||
_open_period(conn)
|
||||
for i in range(14):
|
||||
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(conn, d, bw=1024*1024*100)
|
||||
result = _query_daily_graph_data(conn)
|
||||
assert len(result) == 14
|
||||
assert result[0]["total_bytes"] == 1024*1024*100
|
||||
assert result[0]["allocated_bytes"] == 1024*1024*100
|
||||
assert result[0]["unallocated_bytes"] == 0
|
||||
assert result[0]["is_zero"] is False
|
||||
conn.close()
|
||||
|
||||
def test_zero_day(self, tmp_path):
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
_insert_segment(conn)
|
||||
_open_period(conn)
|
||||
_insert_day(conn, "2026-09-20", bw=0, coverage=0.95, samples=24)
|
||||
result = _query_daily_graph_data(conn)
|
||||
assert len(result) == 1
|
||||
assert result[0]["is_zero"] is True
|
||||
assert result[0]["total_bytes"] == 0
|
||||
conn.close()
|
||||
|
||||
def test_unattributed_bytes(self, tmp_path):
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
_insert_segment(conn)
|
||||
_open_period(conn)
|
||||
_insert_day_with_unattributed(
|
||||
conn, "2026-09-20", bw=500, unattributed=300
|
||||
)
|
||||
result = _query_daily_graph_data(conn)
|
||||
assert len(result) == 1
|
||||
assert result[0]["allocated_bytes"] == 500
|
||||
assert result[0]["unallocated_bytes"] == 300
|
||||
assert result[0]["total_bytes"] == 800
|
||||
conn.close()
|
||||
|
||||
def test_gap_day(self, tmp_path):
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
_insert_segment(conn)
|
||||
_open_period(conn)
|
||||
# Day with no hours but unknown seconds (gap in monitoring period)
|
||||
conn.execute(
|
||||
"INSERT INTO day_aggregates "
|
||||
"(day, active_seconds, idle_seconds, powered_off_seconds, unknown_seconds, "
|
||||
" bytes_written_delta, sample_count, coverage) "
|
||||
"VALUES (?, 0, 0, 0, 86400, 0, 0, 0.0)",
|
||||
("2026-09-20",),
|
||||
)
|
||||
conn.commit()
|
||||
result = _query_daily_graph_data(conn)
|
||||
assert len(result) == 1
|
||||
assert result[0]["is_gap"] is True
|
||||
conn.close()
|
||||
|
||||
|
||||
class TestQueryHourlyGraphData:
|
||||
def test_empty_day(self, tmp_path):
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
result = _query_hourly_graph_data(conn, "2026-09-20")
|
||||
assert result == []
|
||||
conn.close()
|
||||
|
||||
def test_with_hours(self, tmp_path):
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
for h in range(24):
|
||||
hour = "2026-09-20T%02d:00:00+00:00" % h
|
||||
bw = 1024*1024*100 if h in (10, 14) else 0
|
||||
_insert_hour(conn, hour, bw=bw)
|
||||
result = _query_hourly_graph_data(conn, "2026-09-20")
|
||||
assert len(result) == 24
|
||||
assert result[10]["bytes_written"] == 1024*1024*100
|
||||
assert result[10]["is_zero"] is False
|
||||
assert result[0]["is_zero"] is True
|
||||
conn.close()
|
||||
|
||||
def test_hour_labels(self, tmp_path):
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
_insert_hour(conn, "2026-09-20T12:00:00+00:00", bw=1000)
|
||||
result = _query_hourly_graph_data(conn, "2026-09-20")
|
||||
assert len(result) == 1
|
||||
assert result[0]["local_label"] == "12"
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DailyBarGraph widget unit tests (issue #75)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDailyBarGraph:
|
||||
def test_empty_data(self, tmp_path):
|
||||
"""Empty data shows awaiting message."""
|
||||
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||
# We test the widget directly via the app's compose
|
||||
graph = DailyBarGraph()
|
||||
# Simulate setting empty data
|
||||
from textual.app import App as TextualApp
|
||||
class _TestApp(TextualApp):
|
||||
def compose(self):
|
||||
yield graph
|
||||
# We can't easily test widget lifecycle outside an app, so test the data
|
||||
assert graph._all_day_data == []
|
||||
assert graph.range_days == _RANGE_DEFAULT
|
||||
|
||||
def test_range_default(self):
|
||||
"""Default range is 14 days."""
|
||||
graph = DailyBarGraph()
|
||||
assert graph.range_days == _RANGE_DEFAULT
|
||||
assert graph.selected_index == -1
|
||||
assert graph.view_mode == "daily"
|
||||
|
||||
def test_trim_to_range(self):
|
||||
"""Data is trimmed to the selected range."""
|
||||
graph = DailyBarGraph()
|
||||
data = [{"day": "2026-09-%02d" % d, "total_bytes": d * 100}
|
||||
for d in range(1, 31)]
|
||||
graph._all_day_data = data
|
||||
graph.range_days = 7
|
||||
graph._trim_to_range()
|
||||
assert len(graph._day_data) == 7
|
||||
assert graph._day_data[0]["day"] == "2026-09-24"
|
||||
|
||||
def test_range_all_fits(self):
|
||||
"""When data fits within range, all days are shown."""
|
||||
graph = DailyBarGraph()
|
||||
data = [{"day": "2026-09-%02d" % d, "total_bytes": d * 100}
|
||||
for d in range(1, 8)]
|
||||
graph._all_day_data = data
|
||||
graph.range_days = 14
|
||||
graph._trim_to_range()
|
||||
assert len(graph._day_data) == 7
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bar graph headless TUI tests (issue #75)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBarGraphTUI:
|
||||
"""Headless tests for the interactive bar graph."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_graph_renders_with_data(self, tmp_path):
|
||||
"""Graph widget renders with day data."""
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
_insert_segment(conn)
|
||||
_open_period(conn)
|
||||
for i in range(14):
|
||||
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(conn, d, bw=1024*1024*100)
|
||||
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
|
||||
conn.close()
|
||||
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||
async with app.run_test(size=(80, 24)) as pilot:
|
||||
graph = app.query_one("#usage-history")
|
||||
assert isinstance(graph, DailyBarGraph)
|
||||
assert len(graph._day_data) > 0
|
||||
# Legend should be visible
|
||||
legend = str(app.query_one("#bar-legend").render())
|
||||
assert "alloc" in legend
|
||||
assert "zero" in legend
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_arrow_selection(self, tmp_path):
|
||||
"""Left/right arrows move the selection."""
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
_insert_segment(conn)
|
||||
_open_period(conn)
|
||||
for i in range(14):
|
||||
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(conn, d, bw=1024*1024*100)
|
||||
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
|
||||
conn.close()
|
||||
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||
async with app.run_test(size=(80, 24)) as pilot:
|
||||
graph = app.query_one("#usage-history")
|
||||
app.set_focus(graph)
|
||||
await pilot.pause()
|
||||
await pilot.pause()
|
||||
|
||||
# Right arrow selects first bar
|
||||
await pilot.press("right")
|
||||
await pilot.pause()
|
||||
assert graph.selected_index == 0
|
||||
|
||||
# Right again moves to second
|
||||
await pilot.press("right")
|
||||
await pilot.pause()
|
||||
assert graph.selected_index == 1
|
||||
|
||||
# Left moves back
|
||||
await pilot.press("left")
|
||||
await pilot.pause()
|
||||
assert graph.selected_index == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_range_switching(self, tmp_path):
|
||||
"""1-4 keys switch the visible range."""
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
_insert_segment(conn)
|
||||
_open_period(conn)
|
||||
for i in range(30):
|
||||
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(conn, d, bw=1024*1024*100)
|
||||
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
|
||||
conn.close()
|
||||
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||
async with app.run_test(size=(80, 24)) as pilot:
|
||||
graph = app.query_one("#usage-history")
|
||||
app.set_focus(graph)
|
||||
await pilot.pause()
|
||||
await pilot.pause()
|
||||
|
||||
# Default is 14
|
||||
assert graph.range_days == 14
|
||||
assert len(graph._day_data) == 14
|
||||
|
||||
# Press 1 for 7-day range
|
||||
await pilot.press("1")
|
||||
await pilot.pause()
|
||||
assert graph.range_days == 7
|
||||
assert len(graph._day_data) == 7
|
||||
|
||||
# Press 3 for 28-day range
|
||||
await pilot.press("3")
|
||||
await pilot.pause()
|
||||
assert graph.range_days == 28
|
||||
assert len(graph._day_data) == 28
|
||||
|
||||
# Press 4 for 90-day range (only 30 days available)
|
||||
await pilot.press("4")
|
||||
await pilot.pause()
|
||||
assert graph.range_days == 90
|
||||
assert len(graph._day_data) == 30
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readout_updates_on_selection(self, tmp_path):
|
||||
"""Readout shows selected day info."""
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
_insert_segment(conn)
|
||||
_open_period(conn)
|
||||
for i in range(14):
|
||||
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(conn, d, bw=1024*1024*100)
|
||||
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
|
||||
conn.close()
|
||||
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||
async with app.run_test(size=(80, 24)) as pilot:
|
||||
graph = app.query_one("#usage-history")
|
||||
app.set_focus(graph)
|
||||
await pilot.pause()
|
||||
await pilot.pause()
|
||||
|
||||
# No selection initially
|
||||
readout = str(app.query_one("#bar-readout").render())
|
||||
assert "select" in readout.lower()
|
||||
|
||||
# Select first bar
|
||||
await pilot.press("right")
|
||||
await pilot.pause()
|
||||
readout = str(app.query_one("#bar-readout").render())
|
||||
assert "2026-09" in readout
|
||||
assert "GB" in readout
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hourly_drill_down_and_back(self, tmp_path):
|
||||
"""Enter drills into hourly view, Esc returns to daily."""
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
_insert_segment(conn)
|
||||
_open_period(conn)
|
||||
for i in range(14):
|
||||
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(conn, d, bw=1024*1024*100)
|
||||
# Insert hours for each day
|
||||
for h in range(24):
|
||||
hour = "%sT%02d:00:00+00:00" % (d, h)
|
||||
bw_h = 1024*1024*10 if h == 12 else 0
|
||||
_insert_hour(conn, hour, bw=bw_h)
|
||||
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
|
||||
conn.close()
|
||||
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||
async with app.run_test(size=(80, 24)) as pilot:
|
||||
graph = app.query_one("#usage-history")
|
||||
app.set_focus(graph)
|
||||
await pilot.pause()
|
||||
await pilot.pause()
|
||||
|
||||
# Select a day
|
||||
await pilot.press("right")
|
||||
await pilot.pause()
|
||||
assert graph.selected_index == 0
|
||||
assert graph.view_mode == "daily"
|
||||
|
||||
# Enter drill-down
|
||||
await pilot.press("enter")
|
||||
await pilot.pause()
|
||||
assert graph.view_mode == "hourly"
|
||||
assert graph.drill_day is not None
|
||||
assert len(graph._hour_data) == 24
|
||||
|
||||
# Esc returns to daily
|
||||
await pilot.press("escape")
|
||||
await pilot.pause()
|
||||
assert graph.view_mode == "daily"
|
||||
assert graph.drill_day is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_store_graph(self, tmp_path):
|
||||
"""Empty store shows awaiting message in graph."""
|
||||
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||
async with app.run_test(size=(80, 24)) as pilot:
|
||||
graph = app.query_one("#usage-history")
|
||||
assert isinstance(graph, DailyBarGraph)
|
||||
render = str(app.query_one("#bar-render").render())
|
||||
assert "awaiting" in render.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_graph_border_title(self, tmp_path):
|
||||
"""Graph pane has a border title."""
|
||||
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||
async with app.run_test(size=(80, 24)) as pilot:
|
||||
graph = app.query_one("#usage-history")
|
||||
assert graph.border_title == "usage history"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_glyphs_in_legend(self, tmp_path):
|
||||
"""Legend shows all required glyphs."""
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
_insert_segment(conn)
|
||||
_open_period(conn)
|
||||
for i in range(7):
|
||||
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(conn, d, bw=1024*1024*100)
|
||||
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
|
||||
conn.close()
|
||||
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||
async with app.run_test(size=(80, 24)) as pilot:
|
||||
await pilot.pause()
|
||||
legend = str(app.query_one("#bar-legend").render())
|
||||
assert _GLYPH_ALLOCATED in legend
|
||||
assert _GLYPH_UNALLOCATED in legend
|
||||
assert _GLYPH_GAP in legend
|
||||
assert _GLYPH_ZERO in legend
|
||||
assert _GLYPH_PARTIAL in legend
|
||||
|
||||
@@ -7,3 +7,4 @@ After=local-fs.target
|
||||
Type=oneshot
|
||||
ExecStart=/usr/libexec/fenris/fenris-collect
|
||||
TimeoutStartSec=90
|
||||
UMask=002
|
||||
|
||||
Reference in New Issue
Block a user