Compare commits
29
Commits
967ba6964f
...
v0.4.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bac26836a | ||
|
|
88672e12bf | ||
|
|
cfd71a053b | ||
|
|
b76067104b | ||
|
|
95cca2e115 | ||
|
|
5df8a12339 | ||
|
|
e12f4a574c | ||
|
|
4f884b4b73 | ||
|
|
366c2f55b7 | ||
|
|
54cd56e4ec | ||
|
|
69e08d9d3a | ||
|
|
714e69be52 | ||
|
|
ba16413363 | ||
|
|
dfe6a6a2d0 | ||
|
|
44c57b70dd | ||
|
|
f06424f3b8 | ||
|
|
fae72bb07b | ||
|
|
9d22525403 | ||
|
|
a3e6cc3b3c | ||
|
|
34bfc70bb8 | ||
|
|
8b6d4b2447 | ||
|
|
72dacab03b | ||
|
|
cca6804964 | ||
|
|
dea2186d6b | ||
|
|
95c75badd5 | ||
|
|
70dbae65fb | ||
|
|
d119a09b1f | ||
|
|
fca0724fb4 | ||
|
|
1c3037c2f8 |
@@ -64,6 +64,20 @@ jobs:
|
||||
-o /tmp/nfpm.tar.gz
|
||||
sudo tar -xzf /tmp/nfpm.tar.gz -C /usr/local/bin nfpm
|
||||
nfpm --version
|
||||
# Ubuntu does not package the XBPS build tools. Use Void's static
|
||||
# toolchain, pinned and checksum-verified before it reaches PATH.
|
||||
XBPS_STATIC_VERSION=0.60.4_1
|
||||
XBPS_STATIC_ARCHIVE="xbps-static-static-${XBPS_STATIC_VERSION}.x86_64-musl.tar.xz"
|
||||
XBPS_STATIC_SHA256=603b3c55e9cabd5af79b461b929b14e1556a443c97b5714d188681c2172d9e28
|
||||
curl --fail --silent --show-error --location \
|
||||
"https://repo-default.voidlinux.org/static/${XBPS_STATIC_ARCHIVE}" \
|
||||
-o "/tmp/${XBPS_STATIC_ARCHIVE}"
|
||||
echo "${XBPS_STATIC_SHA256} /tmp/${XBPS_STATIC_ARCHIVE}" | sha256sum --check --strict
|
||||
mkdir -p /tmp/xbps-static
|
||||
tar -xJf "/tmp/${XBPS_STATIC_ARCHIVE}" -C /tmp/xbps-static
|
||||
export PATH="/tmp/xbps-static/usr/bin:${PATH}"
|
||||
echo "/tmp/xbps-static/usr/bin" >> "$GITHUB_PATH"
|
||||
xbps-create --version
|
||||
|
||||
- name: Build packages
|
||||
run: make package
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
## Agent skills
|
||||
|
||||
## Commit messages
|
||||
|
||||
Do not add `Co-authored-by: CommandCodeBot <noreply@commandcode.ai>` or other
|
||||
CommandCodeBot attribution trailers to commits.
|
||||
|
||||
### Issue tracker
|
||||
|
||||
Issues are tracked in Gitea using the authenticated `tea` CLI. See `docs/agents/issue-tracker.md`.
|
||||
|
||||
@@ -9,6 +9,44 @@ backfill releases from before this changelog.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.4.0] - 2026-09-18
|
||||
|
||||
### Added
|
||||
|
||||
- Show local-day read and write totals with their recorded timezone, labelled as totals so far for the current day.
|
||||
- Open the dashboard on a live three-hour written-volume graph with a read/write toggle and selected-point inspection.
|
||||
- Browse history by day from the keyboard with `[`, `]`, `g` date entry, and `t` for today and the live view.
|
||||
|
||||
### Changed
|
||||
|
||||
- Collect every three minutes on systemd and runit, plot live points at their actual timestamps, and retain three-minute detail for 14 days before durable summaries.
|
||||
- Withhold the endurance outlook until one full local observation day has usable evidence, then show it with categorical confidence.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Keep midnight-spanning activity once as shared boundary evidence instead of adding it to both days.
|
||||
- Publish derived hour and day history consistently with each collection run.
|
||||
|
||||
## [0.3.7] - 2026-09-16
|
||||
|
||||
### Changed
|
||||
|
||||
- Make usage-history axes, units, UTC boundaries, active 7/14/30/90-day window, gaps, partial periods, stacked write attribution, and hourly drill-down explicit; keep live graph data refreshed on the current three-minute cadence.
|
||||
|
||||
## [0.3.6] - 2026-09-16
|
||||
|
||||
### Changed
|
||||
|
||||
- Use sentence case throughout the dashboard and add persistent keyboard and sudo guidance.
|
||||
- Share read-only status acquisition between the CLI and TUI, preserving unknown monitoring state and store-fault recovery guidance.
|
||||
- Use one authenticated action path for the CLI and TUI; let valid collection runs finish without the former 30-second dashboard cutoff.
|
||||
- Remove the unused sparkline and habit-bar rendering path while retaining the interactive history graph.
|
||||
- Align all package formats on the MIT license and Python 3.10 minimum; include the license text in native packages.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Correct observation-store directory permissions in native packages, including repair of older runit installations during upgrade.
|
||||
|
||||
## [0.3.5] - 2026-09-14
|
||||
|
||||
### Added
|
||||
|
||||
@@ -40,6 +40,14 @@ _Avoid_: Hourly record, hourly.jsonl entry
|
||||
One row per UTC day derived from hour observations; the grain at which usage-habit evidence is judged.
|
||||
_Avoid_: Daily summary, daily stats
|
||||
|
||||
**Usage-history window**:
|
||||
An exact consecutive span of UTC calendar days ending today, shown from day aggregates; a day without trustworthy evidence remains an explicit gap rather than disappearing or being estimated.
|
||||
_Avoid_: Available records, dataset range
|
||||
|
||||
**Unallocated write evidence**:
|
||||
Writes known to belong to a UTC day but which cannot be assigned honestly to a particular hour; they contribute to that day's total but are never distributed across hourly bars.
|
||||
_Avoid_: Missing writes, estimated hourly writes
|
||||
|
||||
**Controller segment**:
|
||||
A span of observation history within which the drive's controller identity is unchanged and counters are monotonic; write deltas are never computed across a segment boundary.
|
||||
_Avoid_: Counter reset handling, drive swap detection
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Fenris contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -74,7 +74,7 @@ install: check-python check-smartctl dist/fenris-*.whl
|
||||
|
||||
@echo "=== Creating data directory (root-written, group-read) ==="
|
||||
@sudo groupadd -f fenris
|
||||
@sudo install -d -o root -g fenris -m 2750 $(DATA_DIR)
|
||||
@sudo install -d -o root -g fenris -m 2770 $(DATA_DIR)
|
||||
|
||||
@echo "=== Installing version-neutral runtime packages ==="
|
||||
@sudo rm -rf $(VENV_DIR)
|
||||
@@ -95,6 +95,7 @@ install: check-python check-smartctl dist/fenris-*.whl
|
||||
|
||||
@echo "=== Installing runit service files (dormant — not enabled) ==="
|
||||
@sudo install -d -m 0755 /etc/sv/fenris-collect/log
|
||||
@sudo install -d -o root -g fenris -m 2770 /var/log/fenris-collect
|
||||
@sudo install -m 0755 units/runit/fenris-collect/run /etc/sv/fenris-collect/run
|
||||
@sudo install -m 0755 units/runit/fenris-collect/log/run /etc/sv/fenris-collect/log/run
|
||||
@sudo touch /etc/sv/fenris-collect/down
|
||||
@@ -111,10 +112,14 @@ install: check-python check-smartctl dist/fenris-*.whl
|
||||
@echo "$(LIBEXEC_DIR)/fenris-collect" | sudo tee -a $(MANIFEST) > /dev/null
|
||||
@echo "$(UNIT_DIR)/fenris-collect.timer" | sudo tee -a $(MANIFEST) > /dev/null
|
||||
@echo "$(UNIT_DIR)/fenris-collect.service" | sudo tee -a $(MANIFEST) > /dev/null
|
||||
@echo "/etc/sv/fenris-collect/run" | sudo tee -a $(MANIFEST) > /dev/null
|
||||
@echo "/etc/sv/fenris-collect/log/run" | sudo tee -a $(MANIFEST) > /dev/null
|
||||
@echo "/etc/sv/fenris-collect/down" | sudo tee -a $(MANIFEST) > /dev/null
|
||||
@echo "$(POLKIT_DIR)/com.bongbetic.fenris.monitor.policy" | sudo tee -a $(MANIFEST) > /dev/null
|
||||
@echo "$(VENV_DIR)" | sudo tee -a $(MANIFEST) > /dev/null
|
||||
@echo "$(DATA_DIR)" | sudo tee -a $(MANIFEST) > /dev/null
|
||||
@echo "$(CONF_DIR)" | sudo tee -a $(MANIFEST) > /dev/null
|
||||
@echo "/var/log/fenris-collect" | sudo tee -a $(MANIFEST) > /dev/null
|
||||
@echo "$(MANIFEST)" | sudo tee -a $(MANIFEST) > /dev/null
|
||||
|
||||
@echo "=== Install complete ==="
|
||||
@@ -150,6 +155,8 @@ upgrade: dist/fenris-*.whl
|
||||
@sudo install -m 0644 units/fenris-collect.timer $(UNIT_DIR)/
|
||||
@sudo install -m 0644 units/fenris-collect.service $(UNIT_DIR)/
|
||||
@sudo install -d -m 0755 /etc/sv/fenris-collect/log
|
||||
@sudo groupadd -f fenris
|
||||
@sudo install -d -o root -g fenris -m 2770 /var/log/fenris-collect
|
||||
@sudo install -m 0755 units/runit/fenris-collect/run /etc/sv/fenris-collect/run
|
||||
@sudo install -m 0755 units/runit/fenris-collect/log/run /etc/sv/fenris-collect/log/run
|
||||
@sudo install -m 0644 polkit/com.bongbetic.fenris.monitor.policy $(POLKIT_DIR)/
|
||||
@@ -167,10 +174,14 @@ upgrade: dist/fenris-*.whl
|
||||
@echo "$(LIBEXEC_DIR)/fenris-collect" | sudo tee -a $(MANIFEST) > /dev/null
|
||||
@echo "$(UNIT_DIR)/fenris-collect.timer" | sudo tee -a $(MANIFEST) > /dev/null
|
||||
@echo "$(UNIT_DIR)/fenris-collect.service" | sudo tee -a $(MANIFEST) > /dev/null
|
||||
@echo "/etc/sv/fenris-collect/run" | sudo tee -a $(MANIFEST) > /dev/null
|
||||
@echo "/etc/sv/fenris-collect/log/run" | sudo tee -a $(MANIFEST) > /dev/null
|
||||
@echo "/etc/sv/fenris-collect/down" | sudo tee -a $(MANIFEST) > /dev/null
|
||||
@echo "$(POLKIT_DIR)/com.bongbetic.fenris.monitor.policy" | sudo tee -a $(MANIFEST) > /dev/null
|
||||
@echo "$(VENV_DIR)" | sudo tee -a $(MANIFEST) > /dev/null
|
||||
@echo "$(DATA_DIR)" | sudo tee -a $(MANIFEST) > /dev/null
|
||||
@echo "$(CONF_DIR)" | sudo tee -a $(MANIFEST) > /dev/null
|
||||
@echo "/var/log/fenris-collect" | sudo tee -a $(MANIFEST) > /dev/null
|
||||
@echo "$(MANIFEST)" | sudo tee -a $(MANIFEST) > /dev/null
|
||||
|
||||
@echo "=== Restarting timer only if contents changed and active (IN-5) ==="
|
||||
@@ -277,28 +288,30 @@ package: package-deb package-rpm
|
||||
|
||||
# XBPS signing key (separate from SSH authentication key)
|
||||
XBPS_SIGNING_KEY ?= $(HOME)/.ssh/id_xbps
|
||||
XBPS_REVISION ?= 1
|
||||
|
||||
package-xbps: stage
|
||||
@echo "=== Building XBPS package ==="
|
||||
cp packaging/xbps/install.sh build/stage/INSTALL
|
||||
cp packaging/xbps/remove.sh build/stage/REMOVE
|
||||
install -D -m 0644 packaging/fenris.conf build/stage/etc/fenris/fenris.conf
|
||||
chmod 755 build/stage/INSTALL build/stage/REMOVE
|
||||
xbps-create -A x86_64 \
|
||||
-n fenris-$(FENRIS_VERSION)_1 \
|
||||
-n fenris-$(FENRIS_VERSION)_$(XBPS_REVISION) \
|
||||
-s "Fenris NVMe wear monitor" \
|
||||
-S "NVMe wear monitor with persistent TUI" \
|
||||
-m "Fenris Packaging <packaging@bongbetic.com>" \
|
||||
-H "https://git.bongbetic.com/xavierk/Fenris" \
|
||||
-l "MIT" \
|
||||
-D "python3>=3.10 smartmontools" \
|
||||
-D "python3>=3.10 smartmontools>=0" \
|
||||
-F "/etc/fenris/fenris.conf" \
|
||||
build/stage
|
||||
@echo "=== XBPS package built: fenris-$(FENRIS_VERSION)_1.x86_64.xbps ==="
|
||||
@echo "=== XBPS package built: fenris-$(FENRIS_VERSION)_$(XBPS_REVISION).x86_64.xbps ==="
|
||||
|
||||
sign-xbps: package-xbps
|
||||
@echo "=== Signing XBPS package ==="
|
||||
xbps-rindex --sign-pkg --privkey $(XBPS_SIGNING_KEY) \
|
||||
fenris-$(FENRIS_VERSION)_1.x86_64.xbps
|
||||
fenris-$(FENRIS_VERSION)_$(XBPS_REVISION).x86_64.xbps
|
||||
@echo "=== XBPS package signed ==="
|
||||
|
||||
xbps-publish:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
*Observes an NVMe drive's real-world use and translates that history into an understandable endurance outlook.*
|
||||
|
||||
Fenris is a persistent TUI monitor backed by a short-lived privileged collector on a systemd timer. It reads SMART data every few minutes, stores compact observation history in SQLite, and recomputes a usage-adjusted theoretical lifespan on every screen render — no fairy dust, just your actual bytes.
|
||||
Fenris is a persistent TUI monitor backed by a short-lived privileged collector on the host's native scheduler. It reads SMART data every few minutes, stores compact observation history in SQLite, and recomputes a usage-adjusted theoretical lifespan on every screen render — no fairy dust, just your actual bytes.
|
||||
|
||||
---
|
||||
|
||||
@@ -10,7 +10,7 @@ Fenris is a persistent TUI monitor backed by a short-lived privileged collector
|
||||
|
||||
- **Python ≥ 3.10** (verified at install time)
|
||||
- **smartmontools** (`smartctl` — verified at install time)
|
||||
- **systemd** with a polkit agent (the collector runs as root oneshot; elevation is exclusively polkit)
|
||||
- **systemd** or **runit**, with a polkit agent (the collector runs as root; elevation is exclusively polkit)
|
||||
|
||||
No other OS packages or Python dependencies beyond [Textual](https://textual.textualize.io/) (pinned in the lockfile).
|
||||
|
||||
@@ -66,6 +66,46 @@ 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).
|
||||
|
||||
### Void Linux (XBPS)
|
||||
|
||||
Void x86_64 with glibc and runit is the native target. Its signed XBPS channel
|
||||
is available from the permanent repository below. Add it, refresh its
|
||||
metadata, and install the released package:
|
||||
|
||||
```bash
|
||||
sudo install -d -m 0755 /etc/xbps.d
|
||||
echo 'repository=https://git.bongbetic.com/xavierk/Fenris-xbps/raw/branch/stable/x86_64' \
|
||||
| sudo tee /etc/xbps.d/fenris.conf
|
||||
sudo xbps-install -M -S fenris
|
||||
```
|
||||
|
||||
XBPS requires remote repositories to be signed. On the first refresh it
|
||||
displays the repository signing key embedded in the signed metadata; accept it
|
||||
only when its RSA SHA256 fingerprint is
|
||||
`SHA256:AvPMRlKMikPg75u0iKr8AUkxlfU/Ad4k/S4o2M9W4/w`. The public key is also
|
||||
available at
|
||||
`https://git.bongbetic.com/xavierk/Fenris-xbps/raw/branch/stable/keys/fenris-xbps-signing.pub`.
|
||||
For later updates, always refresh first so XBPS fetches the current index:
|
||||
|
||||
```bash
|
||||
sudo xbps-install -M -Syu
|
||||
```
|
||||
|
||||
The `-M` flag bypasses XBPS's on-disk repodata cache. It is required when
|
||||
checking for a newly published package through Gitea's cached raw-file URL.
|
||||
|
||||
The runit service remains dormant after installation. `fenris monitor resume`
|
||||
creates `/var/service/fenris-collect`; pause removes that link and records a
|
||||
deliberate disable in the observation history.
|
||||
|
||||
Fenris keeps the observation store root-written and readable by the `fenris`
|
||||
group. Add each TUI user to that group, then start a new login session before
|
||||
running Fenris:
|
||||
|
||||
```bash
|
||||
sudo usermod -aG fenris "$USER"
|
||||
```
|
||||
|
||||
### Package signature verification
|
||||
|
||||
The RPM payload is signed with the Fenris packaging key (RSA 3072).
|
||||
@@ -84,12 +124,12 @@ The packaging public key is published in-repo — no keyservers. See
|
||||
|
||||
### Dormant install
|
||||
|
||||
A fresh package install is fully dormant. Units are present but disabled;
|
||||
nothing runs. The only opt-in is the sanctioned toggle:
|
||||
A fresh package install is fully dormant. Its native scheduler is present but
|
||||
disabled; nothing runs. The only opt-in is the sanctioned toggle:
|
||||
|
||||
```bash
|
||||
fenris monitor resume # enable timer + open first monitoring period
|
||||
fenris monitor pause # close the period, disable timer
|
||||
fenris monitor resume # enable scheduling + open first monitoring period
|
||||
fenris monitor pause # close the period, disable scheduling
|
||||
```
|
||||
|
||||
## Development install (make install)
|
||||
@@ -117,6 +157,7 @@ make purge # also removes /etc/fenris and /var/lib/fenris
|
||||
```bash
|
||||
sudo apt update && sudo apt upgrade fenris # Debian/Ubuntu
|
||||
sudo dnf upgrade fenris # Fedora
|
||||
sudo xbps-install -Syu # Void Linux
|
||||
```
|
||||
|
||||
### Development upgrade
|
||||
@@ -133,6 +174,12 @@ What it does:
|
||||
5. Applies forward-only schema migrations (the store directory is never rebuilt; automatic downgrade does not exist).
|
||||
|
||||
Rollback: reinstall the previous version and restore `observations.db.bak`.
|
||||
On Void, pause monitoring first, copy the compatible snapshot back to
|
||||
`/var/lib/fenris/observations.db`, then force-install the matching older
|
||||
package version. If that version is no longer indexed, add its retained XBPS
|
||||
archive to a local repository with `xbps-rindex -a` and use
|
||||
`xbps-install -R <local-repository> -f fenris-<version>`. Installing an older
|
||||
package over a newer observation store is unsupported.
|
||||
|
||||
## Migration from make install
|
||||
|
||||
@@ -150,6 +197,7 @@ continuity. Over-installing the package over a `make install` is
|
||||
sudo apt remove fenris # preserves config and store
|
||||
sudo apt purge fenris # also removes config and store
|
||||
sudo dnf remove fenris # preserves config and store
|
||||
sudo xbps-remove fenris # preserves config and store
|
||||
```
|
||||
|
||||
### Development removal
|
||||
@@ -163,7 +211,7 @@ Uninstall performs the sanctioned disable first (`fenris-monitor disable --now`)
|
||||
|
||||
## Cadence drop-ins
|
||||
|
||||
The default collection cadence is **5 minutes** (`OnUnitInactiveSec=5min` in the timer unit). To change it, place a systemd drop-in:
|
||||
The default collection cadence is **3 minutes** (`OnUnitInactiveSec=3min` in the timer unit). To change it, place a systemd drop-in:
|
||||
|
||||
```bash
|
||||
sudo systemctl edit fenris-collect.timer
|
||||
@@ -174,6 +222,19 @@ sudo systemctl edit fenris-collect.timer
|
||||
|
||||
No interval key exists in `/etc/fenris/fenris.conf`. Cadence is a systemd concern, not a Fenris configuration key.
|
||||
|
||||
On Void, Fenris uses its native runit service instead: its initial collection
|
||||
is delayed by two minutes and later collections run three minutes after the
|
||||
previous run finishes. Inspect its state and diagnostics with:
|
||||
|
||||
```bash
|
||||
sv status fenris-collect
|
||||
sudo tail -n 50 /var/log/fenris-collect/current
|
||||
```
|
||||
|
||||
`fenris status` also reports the separate boot-enabled, runtime-active,
|
||||
collection outcome, and observation-store freshness facts. A failed collection
|
||||
is retried at the next interval; it never fabricates missing observations.
|
||||
|
||||
## CLI reference
|
||||
|
||||
| Command | Behavior |
|
||||
@@ -181,8 +242,8 @@ No interval key exists in `/etc/fenris/fenris.conf`. Cadence is a systemd concer
|
||||
| `fenris` | Opens the TUI (no arguments). |
|
||||
| `fenris status` | Projection facts, enabled/active state, last collect outcome, journal hint on failure or staleness. Never auto-samples. |
|
||||
| `fenris sample` | On-demand collection via the privileged helper. Blocks until the run completes. |
|
||||
| `fenris monitor pause` | Sanctioned disable — asks for confirmation, then disables the timer and closes the monitoring period. |
|
||||
| `fenris monitor resume` | Sanctioned enable — enables the timer and opens a monitoring period. No confirmation. |
|
||||
| `fenris monitor pause` | Sanctioned disable — asks for confirmation, then disables native scheduling and closes the monitoring period. |
|
||||
| `fenris monitor resume` | Sanctioned enable — enables native scheduling and opens a monitoring period. No confirmation. |
|
||||
| `fenris baseline set <json>` | CLI-side validation, then polkit-guarded persistence. |
|
||||
| `fenris baseline clear` | Remove the endurance baseline. |
|
||||
| `fenris import <path>` | Idempotent single-transaction legacy import. |
|
||||
@@ -191,11 +252,33 @@ No interval key exists in `/etc/fenris/fenris.conf`. Cadence is a systemd concer
|
||||
|
||||
## Reading the dashboard
|
||||
|
||||
`fenris` opens the TUI dashboard.
|
||||
Run `fenris` as your normal user to open the TUI dashboard. The dashboard does
|
||||
not need `sudo`. Use `sudo` for package installation and system configuration;
|
||||
pause, resume, and collect-now actions normally authenticate through polkit.
|
||||
|
||||
If the observation store is inaccessible, add your login user to the `fenris`
|
||||
group with `sudo usermod -aG fenris "$USER"`, then log out and back in.
|
||||
|
||||
If polkit authentication is unavailable, quit the dashboard and run only the
|
||||
required administrative action in your terminal:
|
||||
|
||||
```bash
|
||||
sudo fenris monitor resume # Enable monitoring now and across reboots
|
||||
sudo fenris monitor pause # Confirm a deliberate monitoring pause
|
||||
sudo fenris sample # Request one collection run
|
||||
```
|
||||
|
||||
Reopen the dashboard with `fenris` afterward. Press `?` for these instructions
|
||||
and keyboard controls at any time; use the arrow keys to scroll and `Esc` to close.
|
||||
|
||||
The CLI and TUI share the same authenticated action path. Authentication and
|
||||
waiting for collection have no separate dashboard deadline; the native collector
|
||||
enforces its 90-second runtime limit. An interrupted or failed action is not
|
||||
automatically retried—check `fenris status` before retrying.
|
||||
|
||||
- **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.
|
||||
- **Auth banner** — the launch notice explains normal-user startup and polkit authentication, then clears on the first refresh. The `?` help screen remains available.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ Fenris's current single process combines daemonization, a PID file, an HTTP dash
|
||||
## Decision
|
||||
|
||||
1. **Units.** Two system units only: `fenris-collect.timer` (`WantedBy=timers.target`) and `fenris-collect.service` (`Type=oneshot`, root, `ExecStart=/usr/libexec/fenris/fenris-collect`; no listener, no UI code). The TUI and CLI are ordinary unprivileged processes and never units. There is no `/run/fenris` coordination surface: systemd serializes runs, the observation store holds state, and failures go to the journal per [ADR 0001](0001-observation-store-sqlite.md).
|
||||
2. **Cadence.** Default five minutes: `OnBootSec=2min`, `OnUnitInactiveSec=5min` (measured from run completion; drift accepted because hours are the evidence grain), `AccuracySec=30s`, `Persistent=no`, no suspend catch-up (absent hours classify through power-on-hours evidence), `TimeoutStartSec=90s` so a hung interrogation fails visibly. Cadence changes are documented drop-ins on the timer unit (`systemctl edit` + daemon-reload); no interval key exists in configuration.
|
||||
2. **Cadence.** Default three minutes: `OnBootSec=2min`, `OnUnitInactiveSec=3min` (measured from run completion; drift accepted because hours are the evidence grain), `AccuracySec=30s`, `Persistent=no`, no suspend catch-up (absent hours classify through power-on-hours evidence), `TimeoutStartSec=90s` so a hung interrogation fails visibly. Cadence changes are documented drop-ins on the timer unit (`systemctl edit` + daemon-reload); no interval key exists in configuration.
|
||||
3. **Configuration.** `/etc/fenris/fenris.conf` holds exactly one key: the device selector, a stable `/dev/disk/by-id/…` path (raw nodes accepted with an instability warning), validated at collection time. The oneshot re-reads it every run, so there is no reload path to design. An invalid selector is a bounded failed run — journal plus failed unit result, retried next interval; `status` and the TUI also read the world-readable file directly and surface a `configuration error: <reason>` fact.
|
||||
4. **Entry points.** Two privileged binaries: `/usr/libexec/fenris/fenris-collect` (device interrogation and store writes; the unit's `ExecStart`) and `/usr/libexec/fenris/fenris-monitor` (fixed operations `enable` and `disable` with optional `--now`, plus the collect trigger, monitoring-period bookkeeping, and `baseline set`/`baseline clear` persistence for the CLI-validated endurance baseline; the only binary the polkit policy authorizes). One unprivileged `fenris` for humans: no arguments opens the TUI; subcommands (`status`, `sample`, `monitor pause`, `monitor resume`) are the CLI.
|
||||
5. **Sanctioned toggle.** Pause = `disable --now`; Resume = `enable --now`; both executed by `fenris-monitor`, which performs the systemctl operation and the monitoring-period bookkeeping in one step, under polkit action `com.bongbetic.fenris.monitor` (`auth_admin`, covering the collect trigger too). Root invokes the helpers directly; where no polkit agent exists the operation fails cleanly and prints the root equivalent. This amends the research's direct-systemctl toggle: a period boundary cannot be recorded by systemctl, so the toggle must be Fenris's own fixed operation.
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
# 8. Native Void Linux support and XBPS delivery
|
||||
|
||||
Status: Accepted scope and hosting direction; implementation and acceptance proof pending.
|
||||
Status: Accepted — implementation and host acceptance completed; evidence is recorded in [issue #87](https://git.bongbetic.com/xavierk/Fenris/issues/87).
|
||||
|
||||
Fenris will support Void Linux natively with runit and full application feature parity, while retaining its existing Debian/RPM and systemd support. This extends the platform boundary in [ADR 0003](0003-service-lifecycle-and-sanctioned-toggle.md) and the delivery scope in [ADR 0007](0007-package-delivery-amends-0004.md): requiring Void users to replace their init system would not meet the native-support goal.
|
||||
|
||||
Delivery will include a Fenris-maintained, signed XBPS repository that users configure once for subsequent installation and updates through XBPS, plus versioned release artifacts and notes on Gitea. All downloads must be served directly by Gitea itself; a separate static HTTP repository, even alongside Gitea, does not satisfy this requirement.
|
||||
|
||||
Use a dedicated public Gitea repository, provisionally `xavierk/Fenris-xbps`, with a permanent `stable` branch. Its raw-file URL serves the XBPS index, versioned packages, and package signatures as ordinary Git blobs without LFS. Keeping binaries in a separate repository avoids increasing application source-clone size. This accepts growth in distribution-repository Git history in exchange for publishing index and artifacts together through one branch update, without the generic registry's delete-and-upload index replacement gap. The proposed repository has not yet been created.
|
||||
Use the dedicated public Gitea repository `xavierk/Fenris-xbps` with its permanent `stable` branch. Its raw-file URL serves the XBPS index, versioned packages, and package signatures as ordinary Git blobs without LFS. Keeping binaries in a separate repository avoids increasing application source-clone size. This accepts growth in distribution-repository Git history in exchange for publishing index and artifacts together through one branch update, without the generic registry's delete-and-upload index replacement gap.
|
||||
|
||||
Serialize publication, commit the signed index and its new artifacts together, and retain older versioned artifacts in the current tree so clients with cached older indexes can still download them. Native XBPS installation and update tests against the actual endpoint are required before release validation. Verify binary delivery limits and index freshness: the inspected Gitea raw endpoint advertised six-hour HTTP caching, so immediate update visibility has not been established.
|
||||
Serialize publication, commit the signed index and its new artifacts together, and retain older versioned artifacts in the current tree so clients with cached older indexes can still download them. Native XBPS installation and update tests against the actual endpoint are required before release validation. The first release acceptance recorded in issue #87 verified direct artifact delivery, signed metadata, retained packages, and immediate discovery after an explicit memory-synchronized refresh. The Gitea raw endpoint advertises six-hour HTTP caching; users should use `xbps-install -M -S` when looking for updates so XBPS bypasses its on-disk repodata cache.
|
||||
|
||||
Immediate availability is required: after successful XBPS publication, an explicit repository refresh against the permanent URL must discover the newly published version without a cache-expiry wait or a URL change. This does not promise automatic installation on client machines. Acceptance must exercise a client that fetched the previous index before publication and verify that refresh retrieves the new index and its signed package afterward. Resolve and document actual client and intermediary cache behavior; if the selected Gitea route cannot meet this requirement, hold XBPS publication and revisit its delivery mechanics rather than silently accepting delayed availability.
|
||||
|
||||
@@ -18,4 +18,4 @@ The first supported Void target is x86_64 with glibc, matching the inspected dev
|
||||
|
||||
Package formats have independent publication gates: publish each validated format, and hold only formats that have not passed release validation. A failure or pending validation in XBPS must not prevent a validated Debian or RPM package from shipping, and vice versa. Release notes must identify available formats and those still withheld; publication must not imply validation of a missing format.
|
||||
|
||||
After successful native acceptance testing, leave the released XBPS package installed on this machine and monitoring the selected NVMe drive. Preserve the observation history collected during testing. Coordinate the reboot test with the user so it can occur at a suitable interruption point.
|
||||
After successful native acceptance testing, leave the released XBPS package installed on this machine and monitoring the selected NVMe drive. Preserve the observation history collected during testing. Coordinate the reboot test with the user so it can occur at a suitable interruption point. Issue #87 records that this final state, including reboot persistence, was achieved for the first supported release.
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# One MIT license across source and packages
|
||||
|
||||
The native package metadata previously disagreed: deb/rpm declared Proprietary,
|
||||
while XBPS declared MIT and the repository carried no license text. On
|
||||
2026-09-16 the maintainer chose MIT for Fenris. The repository now includes the
|
||||
standard MIT license, and Python, deb, rpm, and XBPS distributions must preserve
|
||||
that same licensing decision; bundled third-party dependencies retain their own
|
||||
license notices.
|
||||
@@ -49,7 +49,7 @@ Status: Accepted — resolves [Define cross-cutting acceptance criteria](https:/
|
||||
## Collector lifecycle and privilege boundaries (ADR 0003)
|
||||
|
||||
- **LC-1** (P) Exactly two system units exist — `fenris-collect.timer` (`timers.target`) and `fenris-collect.service` (`Type=oneshot`, root, `ExecStart=/usr/libexec/fenris/fenris-collect`); the TUI and CLI are ordinary unprivileged processes and never units.
|
||||
- **LC-2** (P) Timer defaults ship as `OnBootSec=2min`, `OnUnitInactiveSec=5min`, `AccuracySec=30s`, `Persistent=no`, `TimeoutStartSec=90s`; cadence changes are documented drop-ins and no interval key exists in configuration.
|
||||
- **LC-2** (P) Timer defaults ship as `OnBootSec=2min`, `OnUnitInactiveSec=3min`, `AccuracySec=30s`, `Persistent=no`, `TimeoutStartSec=90s`; cadence changes are documented drop-ins and no interval key exists in configuration.
|
||||
- **LC-3** (P) A hung device interrogation fails visibly within `TimeoutStartSec=90s` as a bounded failed run retried next interval.
|
||||
- **LC-4** (A/P) `/etc/fenris/fenris.conf` holds exactly the device selector (stable `/dev/disk/by-id/…` path; raw nodes warned), re-read every run; an invalid selector is a bounded failed run surfaced as `configuration error: <reason>` in `status` and the TUI.
|
||||
- **LC-5** (P) Two privileged binaries ship at `/usr/libexec/fenris/fenris-collect` and `/usr/libexec/fenris/fenris-monitor`; the unprivileged `fenris` wrapper opens the TUI with no arguments.
|
||||
|
||||
@@ -16,7 +16,7 @@ Every constant is defined once, in the section named below; other sections cite,
|
||||
|
||||
| Constant | Value | Defined in |
|
||||
|---|---|---|
|
||||
| Collection cadence (default) | 5 min (`OnUnitInactiveSec`) | §8.2 |
|
||||
| Collection cadence (default) | 3 min (`OnUnitInactiveSec`) | §8.2 |
|
||||
| First-boot delay | 2 min (`OnBootSec`) | §8.2 |
|
||||
| Timer accuracy window | 30 s (`AccuracySec`) | §8.2 |
|
||||
| Collection-run timeout | 90 s (`TimeoutStartSec`) | §8.2 |
|
||||
@@ -479,7 +479,7 @@ Two privileged binaries — `/usr/libexec/fenris/fenris-collect` (device interro
|
||||
|
||||
Constants defined once, consumed by TUI and CLI alike; the grade derives from the **newest sample timestamp**, never a stored flag:
|
||||
|
||||
- **fresh** — newest sample within 2 × cadence + `AccuracySec` + 60 s (11.5 min at default cadence);
|
||||
- **fresh** — newest sample within 2 × cadence + `AccuracySec` + 60 s (7.5 min at default cadence);
|
||||
- **missed** — between that and 48 h (a contributing fact);
|
||||
- **stale** — ≥ 48 h, matching the §6.7 evidence gate;
|
||||
- **empty store** — *"no observations yet"* with an enable hint.
|
||||
|
||||
@@ -63,4 +63,4 @@ Musl, other architectures, additional init systems, official Void repository inc
|
||||
|
||||
## Further Notes
|
||||
|
||||
The design decisions are recorded in ADR 0008. Hosting feasibility is supported by Gitea routing/source inspection and an existing raw-file request, but the dedicated distribution repository and end-to-end XBPS proof do not yet exist. Current host inspection found Void x86_64/glibc with runit, polkit support and an NVMe controller; Fenris and smartmontools were absent. Verify these facts again before host changes.
|
||||
The design decisions are recorded in ADR 0008. The dedicated distribution repository and end-to-end XBPS proof are complete; the host acceptance record is [issue #87](https://git.bongbetic.com/xavierk/Fenris/issues/87). The accepted target is Void x86_64/glibc with runit, with the released package installed and monitoring the selected NVMe drive after the coordinated reboot and lifecycle checks.
|
||||
|
||||
@@ -68,8 +68,8 @@ Cache behavior:
|
||||
- Conditional requests with old ETag return 200 (full content), not 304
|
||||
|
||||
xbps-install behavior:
|
||||
- Always fetches fresh repodata with `-S` flag
|
||||
- Respects ETag changes for immediate discovery
|
||||
- `-M -S` fetches fresh repodata while bypassing the on-disk cache
|
||||
- The ordinary `-S` path can reuse a cached repodata archive
|
||||
- No 6-hour delay observed in practice
|
||||
|
||||
Binary size limits:
|
||||
@@ -77,8 +77,9 @@ Binary size limits:
|
||||
- Real packages expected to be <10MB (vendored pure-Python)
|
||||
- Gitea serves any file size without LFS
|
||||
|
||||
**Caveat**: Clients using `xbps-install -Su` without `-S` may use cached repodata.
|
||||
The `-S` flag forces a fresh fetch. Users should always use `-Syu` for updates.
|
||||
**Caveat**: Clients using `xbps-install -Su` or `-Syu` without `-M` may use
|
||||
cached repodata. Users should use `-M -Syu` for updates when immediate
|
||||
publication visibility matters.
|
||||
|
||||
### 5. Publish index/artifacts together, preserve older artifacts, safe failure recovery
|
||||
|
||||
@@ -117,7 +118,6 @@ xavierk/Fenris-xbps (stable branch)
|
||||
fenris-<version>_1.x86_64.xbps # Package archives
|
||||
fenris-<version>_1.x86_64.xbps.sig2 # Package signatures
|
||||
x86_64-repodata # Repository index (zstd-compressed tar)
|
||||
x86_64-repodata.sig2 # Repository metadata signature
|
||||
keys/
|
||||
fenris-xbps-signing.pub # Public signing key
|
||||
README.md
|
||||
@@ -130,9 +130,9 @@ Users add the repository to `/etc/xbps.d/xbps.conf`:
|
||||
repository=https://git.bongbetic.com/xavierk/Fenris-xbps/raw/branch/stable/x86_64
|
||||
```
|
||||
|
||||
Or install directly:
|
||||
Or make a one-off request without writing the configuration file:
|
||||
```sh
|
||||
sudo xbps-install -S https://git.bongbetic.com/xavierk/Fenris-xbps/raw/branch/stable/x86_64
|
||||
sudo xbps-install -R https://git.bongbetic.com/xavierk/Fenris-xbps/raw/branch/stable/x86_64 -M -S fenris
|
||||
```
|
||||
|
||||
## Publication Mechanism
|
||||
|
||||
+2
-2
@@ -7,7 +7,7 @@ description: >
|
||||
NVMe wear monitor with persistent TUI — observes real-world drive use and
|
||||
translates it into an understandable endurance outlook.
|
||||
homepage: https://git.bongbetic.com/xavierk/Fenris
|
||||
license: Proprietary
|
||||
license: MIT
|
||||
|
||||
depends:
|
||||
- python3 (>= 3.10)
|
||||
@@ -38,7 +38,7 @@ contents:
|
||||
- dst: /var/lib/fenris
|
||||
type: dir
|
||||
file_info:
|
||||
mode: 2750
|
||||
mode: 02770
|
||||
group: fenris
|
||||
|
||||
scripts:
|
||||
|
||||
@@ -63,14 +63,14 @@ print(f'Fenris migration: {n} step(s) applied') if n else None
|
||||
else
|
||||
# runit: create group, set directory permissions
|
||||
groupadd -f fenris
|
||||
install -d -o root -g fenris -m 2750 "${STORE_DIR}" 2>/dev/null || true
|
||||
install -d -o root -g fenris -m 2770 "${STORE_DIR}"
|
||||
chmod 02770 "${STORE_DIR}"
|
||||
# Mark runit service as dormant (down) for fresh install
|
||||
if [ -d /etc/sv/fenris-collect ] && [ ! -e /var/service/fenris-collect ]; then
|
||||
touch /etc/sv/fenris-collect/down
|
||||
fi
|
||||
# Ensure log directory exists
|
||||
mkdir -p /var/log/fenris-collect
|
||||
chown fenris:fenris /var/log/fenris-collect 2>/dev/null || true
|
||||
install -d -o root -g fenris -m 2770 /var/log/fenris-collect 2>/dev/null || true
|
||||
fi
|
||||
;;
|
||||
abort-upgrade|abort-install|disappear)
|
||||
|
||||
@@ -12,7 +12,10 @@ sudo xbps-install fenris # Void Linux
|
||||
For Void Linux, configure the XBPS repository first:
|
||||
|
||||
```bash
|
||||
sudo xbps-install -S https://git.bongbetic.com/xavierk/Fenris-xbps/raw/branch/stable/x86_64
|
||||
sudo install -d -m 0755 /etc/xbps.d
|
||||
echo 'repository=https://git.bongbetic.com/xavierk/Fenris-xbps/raw/branch/stable/x86_64' \
|
||||
| sudo tee /etc/xbps.d/fenris.conf
|
||||
sudo xbps-install -M -S fenris
|
||||
```
|
||||
|
||||
## Verify downloads
|
||||
|
||||
@@ -24,14 +24,14 @@ if [ "$1" -eq 1 ]; then
|
||||
else
|
||||
# runit: create group, set directory permissions
|
||||
groupadd -f fenris
|
||||
install -d -o root -g fenris -m 2750 "${STORE_DIR}" 2>/dev/null || true
|
||||
install -d -o root -g fenris -m 2770 "${STORE_DIR}"
|
||||
chmod 02770 "${STORE_DIR}"
|
||||
# Mark runit service as dormant (down) for fresh install
|
||||
if [ -d /etc/sv/fenris-collect ] && [ ! -e /var/service/fenris-collect ]; then
|
||||
touch /etc/sv/fenris-collect/down
|
||||
fi
|
||||
# Ensure log directory exists
|
||||
mkdir -p /var/log/fenris-collect
|
||||
chown fenris:fenris /var/log/fenris-collect 2>/dev/null || true
|
||||
install -d -o root -g fenris -m 2770 /var/log/fenris-collect 2>/dev/null || true
|
||||
fi
|
||||
elif [ "$1" -ge 2 ]; then
|
||||
# Upgrade — snapshot, migration, init-system-aware reload
|
||||
@@ -67,5 +67,11 @@ print(f'Fenris migration: {n} step(s) applied') if n else None
|
||||
done
|
||||
# Re-apply placement modes (store dir group access, issue #54)
|
||||
systemd-tmpfiles --create || true
|
||||
else
|
||||
# runit: repair log access when upgrading from an older package
|
||||
groupadd -f fenris
|
||||
install -d -o root -g fenris -m 2770 "${STORE_DIR}"
|
||||
chmod 02770 "${STORE_DIR}"
|
||||
install -d -o root -g fenris -m 2770 /var/log/fenris-collect 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -58,6 +58,9 @@ python3 -m pip install --disable-pip-version-check --no-compile \
|
||||
# observation store.
|
||||
chmod -R a+rX "${VENDOR_DIR}"
|
||||
|
||||
# Ship the application license in every native package.
|
||||
install -D -m 0644 "${REPO_ROOT}/LICENSE" "${STAGE_DIR}/usr/share/licenses/fenris/LICENSE"
|
||||
|
||||
# --- Inject version into wrapper from pyproject.toml ---
|
||||
# The wrapper has a hardcoded version string; patch it for packaging.
|
||||
WRAPPER_SRC="${REPO_ROOT}/scripts/fenris"
|
||||
|
||||
@@ -34,6 +34,10 @@ case "${ACTION}" in
|
||||
fi
|
||||
;;
|
||||
post)
|
||||
# Keep observation-store access consistent across fresh installs and upgrades.
|
||||
groupadd -f fenris
|
||||
install -d -o root -g fenris -m 2770 "${STORE_DIR}"
|
||||
chmod 02770 "${STORE_DIR}"
|
||||
if [ "${UPDATE}" = "yes" ]; then
|
||||
# Upgrade — snapshot, migration, runit-aware reload
|
||||
if [ -f "${STORE_DB}" ]; then
|
||||
@@ -48,19 +52,17 @@ print(f'Fenris migration: {n} step(s) applied') if n else None
|
||||
" 2>&1 || echo "Fenris: migration skipped (store not yet initialized)"
|
||||
fi
|
||||
# Ensure log directory exists on upgrade
|
||||
mkdir -p /var/log/fenris-collect
|
||||
chown fenris:fenris /var/log/fenris-collect 2>/dev/null || true
|
||||
groupadd -f fenris
|
||||
install -d -o root -g fenris -m 2770 /var/log/fenris-collect 2>/dev/null || true
|
||||
else
|
||||
# Fresh install — runit service setup
|
||||
groupadd -f fenris
|
||||
install -d -o root -g fenris -m 2770 "${STORE_DIR}" 2>/dev/null || true
|
||||
# Mark runit service as dormant (down) for fresh install
|
||||
if [ -d /etc/sv/fenris-collect ] && [ ! -e /var/service/fenris-collect ]; then
|
||||
touch /etc/sv/fenris-collect/down
|
||||
fi
|
||||
# Ensure log directory exists
|
||||
mkdir -p /var/log/fenris-collect
|
||||
chown fenris:fenris /var/log/fenris-collect 2>/dev/null || true
|
||||
install -d -o root -g fenris -m 2770 /var/log/fenris-collect 2>/dev/null || true
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
+3
-2
@@ -1,8 +1,9 @@
|
||||
[project]
|
||||
name = "fenris"
|
||||
version = "0.3.5"
|
||||
version = "0.4.0"
|
||||
description = "NVMe wear monitor with persistent TUI"
|
||||
requires-python = ">=3.9"
|
||||
requires-python = ">=3.10"
|
||||
license = {file = "LICENSE"}
|
||||
dependencies = [
|
||||
"textual>=0.40.0",
|
||||
]
|
||||
|
||||
@@ -9,3 +9,4 @@ mdurl==0.1.2
|
||||
platformdirs==4.11.7
|
||||
Pygments==2.21.0
|
||||
linkify-it-py==2.2.0
|
||||
typing-extensions==4.16.0
|
||||
|
||||
+9
-30
@@ -7,8 +7,6 @@ Subcommands route through fenris-monitor for privileged operations.
|
||||
Spec: §1.2, §8.4
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -35,35 +33,16 @@ def add_runtime_packages() -> None:
|
||||
add_runtime_packages()
|
||||
|
||||
|
||||
def is_root() -> bool:
|
||||
"""Check if running as root."""
|
||||
return os.geteuid() == 0
|
||||
|
||||
|
||||
def run_monitor(*args: str) -> None:
|
||||
"""Run fenris-monitor with the given arguments.
|
||||
"""Render the shared privileged-action outcome for the CLI."""
|
||||
from fenris.control import MonitorError, run_monitor as invoke_monitor
|
||||
|
||||
If not root, re-exec under pkexec.
|
||||
"""
|
||||
monitor_cmd = "/usr/libexec/fenris/fenris-monitor"
|
||||
|
||||
if is_root():
|
||||
result = subprocess.run([monitor_cmd] + list(args))
|
||||
sys.exit(result.returncode)
|
||||
else:
|
||||
# Use pkexec to elevate
|
||||
pkexec = subprocess.run(
|
||||
["which", "pkexec"], capture_output=True
|
||||
)
|
||||
if pkexec.returncode != 0:
|
||||
print(
|
||||
"Error: No polkit agent available. "
|
||||
"Run as root: sudo fenris-monitor ...",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
result = subprocess.run(["pkexec", monitor_cmd] + list(args))
|
||||
sys.exit(result.returncode)
|
||||
try:
|
||||
invoke_monitor(*args)
|
||||
except MonitorError as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
sys.exit(exc.exit_code)
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def cmd_tui(args: argparse.Namespace) -> None:
|
||||
@@ -143,7 +122,7 @@ def main() -> None:
|
||||
description="Fenris NVMe endurance monitor",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--version", action="version", version="%(prog)s 0.3.0"
|
||||
"--version", action="version", version="%(prog)s 0.3.6"
|
||||
)
|
||||
|
||||
subparsers = parser.add_subparsers(dest="command")
|
||||
|
||||
+54
-16
@@ -42,7 +42,7 @@ for arg in "$@"; do
|
||||
echo " --publish Execute the full publication flow"
|
||||
echo ""
|
||||
echo "Environment:"
|
||||
echo " XBPS_SIGNING_KEY Path to SSH RSA private key (default: ~/.ssh/id_rsa)"
|
||||
echo " XBPS_SIGNING_KEY Path to SSH RSA private key (default: ~/.ssh/id_xbps)"
|
||||
echo " SIGNED_BY Signature identity (default: Fenris Packaging <packaging@bongbetic.com>)"
|
||||
exit 0
|
||||
;;
|
||||
@@ -84,15 +84,24 @@ for tool in xbps-create xbps-rindex git; do
|
||||
if ! command -v "$tool" &>/dev/null; then
|
||||
echo "ERROR: Required tool not found: $tool" >&2
|
||||
exit 1
|
||||
done
|
||||
fi
|
||||
done
|
||||
|
||||
# ── Main ─────────────────────────────────────────────────────────────────
|
||||
|
||||
VERSION=$(_version)
|
||||
REVISION=1
|
||||
REVISION="${XBPS_REVISION:-1}"
|
||||
PKGVER="fenris-${VERSION}_${REVISION}"
|
||||
XBPS_FILE="${PKGVER}.${ARCH}.xbps"
|
||||
SOURCE_DIR=$(pwd)
|
||||
XBPS_PATH="${SOURCE_DIR}/${XBPS_FILE}"
|
||||
GIT_USER_NAME=$(git config user.name || true)
|
||||
GIT_USER_EMAIL=$(git config user.email || true)
|
||||
|
||||
if [[ -z "${GIT_USER_NAME}" || -z "${GIT_USER_EMAIL}" ]]; then
|
||||
echo "ERROR: Configure git user.name and user.email in the source repository before publishing" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== Fenris XBPS Publication v${VERSION} ==="
|
||||
echo ""
|
||||
@@ -105,14 +114,14 @@ fi
|
||||
# ── Step 1: Build XBPS package ───────────────────────────────────────────
|
||||
|
||||
echo "--- Build XBPS package ---"
|
||||
_run "make stage"
|
||||
_run "xbps-create -A ${ARCH} -n ${PKGVER} -s 'Fenris NVMe wear monitor' -S 'NVMe wear monitor with persistent TUI' -m 'Fenris Packaging <packaging@bongbetic.com>' -H 'https://git.bongbetic.com/xavierk/Fenris' -l 'MIT' build/stage"
|
||||
_run "make XBPS_REVISION=${REVISION} package-xbps"
|
||||
echo ""
|
||||
|
||||
# ── Step 2: Sign package ─────────────────────────────────────────────────
|
||||
|
||||
echo "--- Sign XBPS package ---"
|
||||
_run "xbps-rindex --sign-pkg --privkey ${XBPS_SIGNING_KEY} ${XBPS_FILE}"
|
||||
_run "rm -f ${XBPS_PATH}.sig2"
|
||||
_run "xbps-rindex --sign-pkg --privkey ${XBPS_SIGNING_KEY} ${XBPS_PATH}"
|
||||
echo ""
|
||||
|
||||
# ── Step 3: Clone/update distribution repository ─────────────────────────
|
||||
@@ -120,31 +129,59 @@ echo ""
|
||||
echo "--- Prepare distribution repository ---"
|
||||
WORK_DIR=$(mktemp -d)
|
||||
_run "git clone ${GITEA_URL}/${GITEA_OWNER}/${GITEA_REPO}.git ${WORK_DIR}"
|
||||
_run "cd ${WORK_DIR} && git checkout ${GITEA_BRANCH}"
|
||||
_run "git -C ${WORK_DIR} config user.name '${GIT_USER_NAME}'"
|
||||
_run "git -C ${WORK_DIR} config user.email '${GIT_USER_EMAIL}'"
|
||||
_run "git -C ${WORK_DIR} checkout ${GITEA_BRANCH}"
|
||||
_run "mkdir -p ${WORK_DIR}/${ARCH}"
|
||||
echo ""
|
||||
|
||||
# ── Step 4: Copy artifacts and update index ──────────────────────────────
|
||||
|
||||
echo "--- Update repository index ---"
|
||||
_run "cp ${XBPS_FILE} ${XBPS_FILE}.sig2 ${WORK_DIR}/${ARCH}/"
|
||||
_run "cd ${WORK_DIR} && xbps-rindex --add ${ARCH}/${XBPS_FILE}"
|
||||
_run "cd ${WORK_DIR} && xbps-rindex --sign --privkey ${XBPS_SIGNING_KEY} --signedby '${SIGNED_BY}' ${ARCH}"
|
||||
_run "cp ${XBPS_PATH} ${XBPS_PATH}.sig2 ${WORK_DIR}/${ARCH}/"
|
||||
_run "(cd ${WORK_DIR} && xbps-rindex --add ${ARCH}/${XBPS_FILE})"
|
||||
_run "(cd ${WORK_DIR} && xbps-rindex --sign --privkey ${XBPS_SIGNING_KEY} --signedby '${SIGNED_BY}' ${ARCH})"
|
||||
echo ""
|
||||
|
||||
# ── Step 5: Commit and push ──────────────────────────────────────────────
|
||||
|
||||
echo "--- Commit and push ---"
|
||||
_run "cd ${WORK_DIR} && git add -A"
|
||||
_run "cd ${WORK_DIR} && git commit -m 'Release fenris ${VERSION}'"
|
||||
_run "cd ${WORK_DIR} && git push origin ${GITEA_BRANCH}"
|
||||
_run "git -C ${WORK_DIR} add -A"
|
||||
_run "git -C ${WORK_DIR} commit -m 'Release fenris ${VERSION}'"
|
||||
_run "git -C ${WORK_DIR} push origin ${GITEA_BRANCH}"
|
||||
echo ""
|
||||
|
||||
# ── Step 6: Verify publication ───────────────────────────────────────────
|
||||
|
||||
echo "--- Verify publication ---"
|
||||
RAW_URL="${GITEA_URL}/${GITEA_OWNER}/${GITEA_REPO}/raw/branch/${GITEA_BRANCH}/${ARCH}/x86_64-repodata"
|
||||
_run "curl -sI '${RAW_URL}' | head -5"
|
||||
RAW_BASE="${GITEA_URL}/${GITEA_OWNER}/${GITEA_REPO}/raw/branch/${GITEA_BRANCH}/${ARCH}"
|
||||
if $PUBLISH; then
|
||||
# Compare every served byte with the artifact that was indexed and pushed.
|
||||
# A successful HEAD request alone can still hide a stale or incomplete
|
||||
# publication behind the raw endpoint's cache.
|
||||
# Repository signatures are embedded in x86_64-repodata by xbps-rindex;
|
||||
# only package signatures are separate .sig2 files.
|
||||
for artifact in "${XBPS_FILE}" "${XBPS_FILE}.sig2" "x86_64-repodata"; do
|
||||
case "${artifact}" in
|
||||
"${XBPS_FILE}") local_path="${XBPS_PATH}" ;;
|
||||
"${XBPS_FILE}.sig2") local_path="${XBPS_PATH}.sig2" ;;
|
||||
*) local_path="${WORK_DIR}/${ARCH}/${artifact}" ;;
|
||||
esac
|
||||
downloaded="${WORK_DIR}/.${artifact}.download"
|
||||
curl --fail --silent --show-error --location \
|
||||
--output "${downloaded}" "${RAW_BASE}/${artifact}"
|
||||
cmp -- "${local_path}" "${downloaded}"
|
||||
rm -f "${downloaded}"
|
||||
done
|
||||
else
|
||||
_run "curl --fail --silent --show-error --location --output ${WORK_DIR}/.${XBPS_FILE}.download ${RAW_BASE}/${XBPS_FILE}"
|
||||
_run "cmp -- ${XBPS_PATH} ${WORK_DIR}/.${XBPS_FILE}.download"
|
||||
_run "curl --fail --silent --show-error --location --output ${WORK_DIR}/.${XBPS_FILE}.sig2.download ${RAW_BASE}/${XBPS_FILE}.sig2"
|
||||
_run "cmp -- ${XBPS_PATH}.sig2 ${WORK_DIR}/.${XBPS_FILE}.sig2.download"
|
||||
_run "curl --fail --silent --show-error --location --output ${WORK_DIR}/.x86_64-repodata.download ${RAW_BASE}/x86_64-repodata"
|
||||
_run "cmp -- ${WORK_DIR}/${ARCH}/x86_64-repodata ${WORK_DIR}/.x86_64-repodata.download"
|
||||
_run "rm -f ${WORK_DIR}/.${XBPS_FILE}.download ${WORK_DIR}/.${XBPS_FILE}.sig2.download ${WORK_DIR}/.x86_64-repodata.download"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ── Cleanup ──────────────────────────────────────────────────────────────
|
||||
@@ -164,7 +201,8 @@ echo " Branch: ${GITEA_BRANCH}"
|
||||
echo " Repository URL: https://git.bongbetic.com/${GITEA_OWNER}/${GITEA_REPO}/raw/branch/${GITEA_BRANCH}/${ARCH}"
|
||||
echo ""
|
||||
echo "Client installation:"
|
||||
echo " sudo xbps-install -S https://git.bongbetic.com/${GITEA_OWNER}/${GITEA_REPO}/raw/branch/${GITEA_BRANCH}/${ARCH}"
|
||||
echo " echo 'repository=https://git.bongbetic.com/${GITEA_OWNER}/${GITEA_REPO}/raw/branch/${GITEA_BRANCH}/${ARCH}' | sudo tee /etc/xbps.d/fenris.conf"
|
||||
echo " sudo xbps-install -M -S fenris"
|
||||
echo ""
|
||||
echo "Key ceremony: delete the private key after publication."
|
||||
echo " See docs/install/signing-key-ceremony.md"
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
"""Fenris: NVMe wear monitor with persistent TUI."""
|
||||
__version__ = "0.3.1"
|
||||
__version__ = "0.4.0"
|
||||
|
||||
@@ -340,6 +340,26 @@ def run_collection(
|
||||
"data_units_read": sample["data_units_read"],
|
||||
}
|
||||
derive_hours_from_interval(conn, prev, current)
|
||||
|
||||
# Rebuild day aggregates from hour observations
|
||||
from .day_aggregate import derive_all_days, persist_day_aggregate
|
||||
for agg in derive_all_days(conn):
|
||||
persist_day_aggregate(conn, agg)
|
||||
|
||||
# Derive local-day summary using system timezone (issue #90)
|
||||
try:
|
||||
from .tz_util import detect_system_tz
|
||||
from .local_day import derive_local_day_summary, persist_local_day
|
||||
tz_name = detect_system_tz()
|
||||
clock_now = clock.utcnow()
|
||||
local_summary = derive_local_day_summary(conn, tz_name, clock_now)
|
||||
if local_summary is not None:
|
||||
persist_local_day(conn, local_summary)
|
||||
except Exception:
|
||||
# Local-day derivation failure must not prevent publication
|
||||
pass
|
||||
|
||||
conn.commit()
|
||||
except Exception:
|
||||
# Derivation failure must not prevent sample persistence (issue #73 AC6)
|
||||
pass
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Terminal-attached invocation of Fenris's fixed privileged operations.
|
||||
|
||||
Both human entry points use this module. Authentication has no frontend
|
||||
deadline; collection runtime is bounded by the native scheduler (ADR 0003).
|
||||
"""
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
|
||||
|
||||
MONITOR_HELPER = "/usr/libexec/fenris/fenris-monitor"
|
||||
|
||||
|
||||
class MonitorError(Exception):
|
||||
"""An action failed, with a message and exit status for either renderer."""
|
||||
|
||||
def __init__(self, message: str, exit_code: int = 1):
|
||||
super().__init__(message)
|
||||
self.exit_code = exit_code
|
||||
|
||||
|
||||
def run_monitor(*args: str, helper_path: str = MONITOR_HELPER) -> None:
|
||||
"""Run one helper operation, inheriting the terminal for authentication.
|
||||
|
||||
Never invoke a shell, retry an action, or fall back to sudo automatically.
|
||||
The helper owns the operation allow-list and privileged state changes.
|
||||
"""
|
||||
helper_command = [helper_path, *args]
|
||||
needs_auth = os.geteuid() != 0
|
||||
command = ["pkexec", *helper_command] if needs_auth else helper_command
|
||||
root_hint = (
|
||||
" If authentication is unavailable, run in your terminal: "
|
||||
+ shlex.join(["sudo", *helper_command])
|
||||
) if needs_auth else ""
|
||||
|
||||
try:
|
||||
# The collector owns its 90-second runtime limit. A frontend timeout
|
||||
# would also count time spent authenticating or waiting for a run.
|
||||
result = subprocess.run(command)
|
||||
except FileNotFoundError as exc:
|
||||
raise MonitorError(
|
||||
"Command not found: %s.%s" % (exc.filename or command[0], root_hint), 127,
|
||||
) from exc
|
||||
except OSError as exc:
|
||||
raise MonitorError("Cannot run monitoring action: %s.%s" % (exc, root_hint)) from exc
|
||||
except KeyboardInterrupt as exc:
|
||||
raise MonitorError(
|
||||
"Action interrupted. Check fenris status before retrying.", 130,
|
||||
) from exc
|
||||
|
||||
if result.returncode:
|
||||
exit_code = result.returncode if result.returncode > 0 else 128 - result.returncode
|
||||
raise MonitorError(
|
||||
"Action failed (exit %d). Check fenris status before retrying.%s"
|
||||
% (exit_code, root_hint), exit_code,
|
||||
)
|
||||
@@ -163,3 +163,41 @@ def derive_all_days(conn: sqlite3.Connection) -> list[DayAggregate]:
|
||||
if agg is not None:
|
||||
results.append(agg)
|
||||
return results
|
||||
|
||||
|
||||
def persist_day_aggregate(conn: sqlite3.Connection, agg: DayAggregate) -> None:
|
||||
"""Upsert a derived day aggregate into the day_aggregates table.
|
||||
|
||||
Merges attributed bytes from hour observations with any existing
|
||||
unattributed cross-hour evidence already stored for this day.
|
||||
Caller must manage transactions and commits.
|
||||
"""
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM day_aggregates WHERE day = ?",
|
||||
(agg.day,),
|
||||
).fetchone()
|
||||
|
||||
if existing is None:
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(agg.day, agg.seconds_active, agg.seconds_idle,
|
||||
agg.seconds_powered_off, agg.seconds_unknown,
|
||||
agg.bytes_written_delta, agg.bytes_read_delta,
|
||||
agg.sample_count, agg.coverage),
|
||||
)
|
||||
else:
|
||||
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 id = ?",
|
||||
(agg.seconds_active, agg.seconds_idle,
|
||||
agg.seconds_powered_off, agg.seconds_unknown,
|
||||
agg.bytes_written_delta, agg.bytes_read_delta,
|
||||
agg.sample_count, agg.coverage,
|
||||
existing[0]),
|
||||
)
|
||||
|
||||
+31
-24
@@ -175,7 +175,8 @@ def _upsert_hour_observation(
|
||||
"""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 = ?",
|
||||
"SELECT id, bytes_written_delta, bytes_read_delta, sample_count "
|
||||
"FROM hour_observations WHERE hour = ?",
|
||||
(hour_key,),
|
||||
).fetchone()
|
||||
|
||||
@@ -198,10 +199,13 @@ def _upsert_hour_observation(
|
||||
else:
|
||||
# Merge: accumulate bytes and sample count
|
||||
new_bw = existing[1] + bw_delta
|
||||
new_samples = existing[2] + sample_count
|
||||
new_br = existing[2] + br_delta
|
||||
new_samples = existing[3] + sample_count
|
||||
conn.execute(
|
||||
"UPDATE hour_observations SET bytes_written_delta = ?, sample_count = ? WHERE id = ?",
|
||||
(new_bw, new_samples, existing[0]),
|
||||
"UPDATE hour_observations "
|
||||
"SET bytes_written_delta = ?, bytes_read_delta = ?, sample_count = ? "
|
||||
"WHERE id = ?",
|
||||
(new_bw, new_br, new_samples, existing[0]),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
@@ -213,25 +217,28 @@ def _add_unattributed_bytes(
|
||||
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")
|
||||
"""Store unattributed byte deltas once as shared boundary evidence.
|
||||
|
||||
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),
|
||||
)
|
||||
A cross-midnight interval's delta is preserved on the day where it
|
||||
STARTS (the earlier day). It is not duplicated into both days;
|
||||
the spec requires preserving the measured volume once as shared
|
||||
unallocated boundary evidence (issue #88).
|
||||
"""
|
||||
day = prev_ts.strftime("%Y-%m-%d")
|
||||
|
||||
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()
|
||||
|
||||
@@ -10,13 +10,13 @@ responsibility shared by the privileged control path and read-only status
|
||||
composition.
|
||||
|
||||
Runit service layout:
|
||||
/etc/sv/fenris-collect/run — scheduler (sleep 120; loop { collect; sleep 300 })
|
||||
/etc/sv/fenris-collect/run — scheduler (sleep 120; loop { collect; sleep 180 })
|
||||
/etc/sv/fenris-collect/log/run — logger to /var/log/fenris-collect/
|
||||
/var/service/fenris-collect — symlink to enable
|
||||
/etc/sv/fenris-collect/down — marker for dormant install
|
||||
|
||||
Runit guarantees:
|
||||
- Completion-relative 5-minute cadence (sleep 300 after each collect)
|
||||
- Completion-relative 3-minute cadence (sleep 180 after each collect)
|
||||
- Initial 2-minute boot delay (sleep 120 before first collect)
|
||||
- Bounded execution (90s timeout via timeout(1))
|
||||
- No catch-up (service sleeps fixed interval, no Persistent= flag)
|
||||
@@ -166,11 +166,11 @@ def _systemd_query_state() -> Dict[str, Any]:
|
||||
"ActiveState", "ExecMainStatus", "ExecMainExitTimestamp",
|
||||
)
|
||||
|
||||
boot_enabled_str = timer_props.get("UnitFileState", "")
|
||||
boot_enabled = boot_enabled_str == "enabled"
|
||||
boot_enabled_str = timer_props.get("UnitFileState")
|
||||
boot_enabled = boot_enabled_str == "enabled" if boot_enabled_str else None
|
||||
|
||||
active_state = timer_props.get("ActiveState", "inactive")
|
||||
timer_active = active_state == "active"
|
||||
active_state = timer_props.get("ActiveState")
|
||||
timer_active = active_state == "active" if active_state else None
|
||||
|
||||
last_collect_ok = None
|
||||
last_collect_age_s = None
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
"""Local-day activity derivation from UTC hour observations.
|
||||
|
||||
Computes durable local-day read/write summaries using the actual local
|
||||
midnight boundaries, retaining UTC hour/day aggregates for endurance
|
||||
projections. The collector owns this derivation, preserving the
|
||||
existing read-only TUI boundary (ADR 0010).
|
||||
|
||||
Key contracts:
|
||||
- An interval wholly attributable to a local day contributes its volume once
|
||||
- Midnight-spanning intervals are retained once as shared/unallocated evidence
|
||||
- UTC hour/day aggregates are never modified or deleted
|
||||
- Migration cannot manufacture local precision from historical UTC data
|
||||
|
||||
Retention policy (issue #93):
|
||||
- Raw three-minute samples are pruned after 14 days (spec §3.4, ST-5).
|
||||
- Hour observations and day aggregates are retained indefinitely.
|
||||
- Local-day summaries are persisted at collection time and retained
|
||||
indefinitely. They survive raw-sample pruning because they depend on
|
||||
hour observations, not on raw samples.
|
||||
- After detail expires, aged local summaries remain queryable with their
|
||||
recorded timezone and UTC boundaries. The evidence-availability flag
|
||||
on each history entry indicates whether the underlying raw detail is
|
||||
still present or has been pruned.
|
||||
- A later timezone change does not rewrite historical day boundaries.
|
||||
Each summary retains the timezone and offsets recorded at collection.
|
||||
- No fabricated evidence replaces missing precision. Incomplete or
|
||||
unavailable dates are labelled as such in the history readout.
|
||||
"""
|
||||
import sqlite3
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LocalDaySummary:
|
||||
"""One local day's aggregated read/write volumes."""
|
||||
local_date: str # e.g. "2026-09-01"
|
||||
tz_name: str # e.g. "Asia/Kolkata"
|
||||
tz_offset: str # e.g. "+05:30"
|
||||
utc_start: str # ISO 8601 UTC: the local midnight that starts this day
|
||||
utc_end: str # ISO 8601 UTC: the local midnight that ends this day
|
||||
bytes_written: int
|
||||
bytes_read: int
|
||||
coverage: float # known / (known + unknown) in UTC hours
|
||||
sample_count: int
|
||||
complete: bool # day's UTC range fully covered by hour observations
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LocalDayHistoryEntry:
|
||||
"""A local-day summary with evidence-limit metadata for history readout.
|
||||
|
||||
Includes the summary data plus flags that indicate whether the
|
||||
underlying raw three-minute detail is still available or has been
|
||||
pruned, and whether the summary was derived from surviving evidence
|
||||
or is a legacy UTC-only aggregate.
|
||||
"""
|
||||
local_date: str
|
||||
tz_name: str
|
||||
tz_offset: str
|
||||
utc_start: str
|
||||
utc_end: str
|
||||
bytes_written: int
|
||||
bytes_read: int
|
||||
coverage: float
|
||||
sample_count: int
|
||||
complete: bool
|
||||
detail_available: bool # True if raw samples for this day are within 14-day retention
|
||||
derived_from_surviving: bool # True if derived from hour observations, not raw samples
|
||||
|
||||
@classmethod
|
||||
def from_summary(
|
||||
cls,
|
||||
summary: dict,
|
||||
detail_available: bool,
|
||||
derived_from_surviving: bool = True,
|
||||
) -> "LocalDayHistoryEntry":
|
||||
"""Create a history entry from a stored summary dict."""
|
||||
return cls(
|
||||
local_date=summary["local_date"],
|
||||
tz_name=summary["tz_name"],
|
||||
tz_offset=summary["tz_offset"],
|
||||
utc_start=summary["utc_start"],
|
||||
utc_end=summary["utc_end"],
|
||||
bytes_written=summary["bytes_written"],
|
||||
bytes_read=summary["bytes_read"],
|
||||
coverage=summary["coverage"],
|
||||
sample_count=summary["sample_count"],
|
||||
complete=summary["complete"],
|
||||
detail_available=detail_available,
|
||||
derived_from_surviving=derived_from_surviving,
|
||||
)
|
||||
|
||||
|
||||
def _local_midnight_utc(dt: datetime, tz_name: str) -> datetime:
|
||||
"""Compute the UTC time of the local midnight that contains *dt*.
|
||||
|
||||
Returns the most recent local midnight in UTC. For example, if *dt*
|
||||
is 2026-09-01T17:35:00+05:30 (i.e. 12:05 UTC) this returns
|
||||
2026-08-31T18:30:00+00:00 (2026-09-01 00:00 in +05:30).
|
||||
"""
|
||||
from zoneinfo import ZoneInfo
|
||||
local_tz = ZoneInfo(tz_name)
|
||||
local_dt = dt.astimezone(local_tz)
|
||||
local_midnight = local_dt.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
return local_midnight.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def derive_local_day_summary(
|
||||
conn: sqlite3.Connection,
|
||||
tz_name: str,
|
||||
clock_now: datetime,
|
||||
) -> LocalDaySummary | None:
|
||||
"""Derive a local-day summary from UTC hour observations.
|
||||
|
||||
Computes the UTC boundaries of the current local day, queries the
|
||||
UTC hours overlapping that range, and aggregates read/write volumes.
|
||||
Midnight-spanning hours are retained once as shared evidence.
|
||||
|
||||
Returns None if no hours exist for the local day.
|
||||
"""
|
||||
from zoneinfo import ZoneInfo
|
||||
from .tz_util import get_tz_offset_str
|
||||
|
||||
local_tz = ZoneInfo(tz_name)
|
||||
local_dt = clock_now.astimezone(local_tz)
|
||||
local_date = local_dt.strftime("%Y-%m-%d")
|
||||
tz_offset_str = get_tz_offset_str(clock_now, tz_name)
|
||||
|
||||
# UTC boundaries of this local day
|
||||
utc_start = _local_midnight_utc(clock_now, tz_name)
|
||||
next_local = local_dt + timedelta(days=1)
|
||||
utc_end = next_local.replace(hour=0, minute=0, second=0, microsecond=0).astimezone(timezone.utc)
|
||||
|
||||
utc_start_iso = utc_start.isoformat()
|
||||
utc_end_iso = utc_end.isoformat()
|
||||
|
||||
# Query UTC hours overlapping the local day
|
||||
cursor = conn.execute(
|
||||
"SELECT hour, bytes_written_delta, bytes_read_delta, sample_count, "
|
||||
" active_seconds, idle_seconds, powered_off_seconds, unknown_seconds "
|
||||
"FROM hour_observations "
|
||||
"WHERE hour >= ? AND hour < ? "
|
||||
"ORDER BY hour",
|
||||
(utc_start_iso, utc_end_iso),
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
# Check for a midnight-spanning UTC hour before utc_start.
|
||||
# When the local midnight falls inside a UTC hour (e.g. UTC+5:30
|
||||
# where local midnight is 18:30 UTC), the hour 18:00 straddles the
|
||||
# boundary. The main query (hour >= utc_start) excludes it because
|
||||
# 18:00 < 18:30, so we must include it separately. This does NOT
|
||||
# double-count: the hour falls outside the query range by
|
||||
# construction (issue #93).
|
||||
midnight_hour = utc_start.replace(minute=0, second=0, microsecond=0)
|
||||
midnight_hour_iso = midnight_hour.strftime("%Y-%m-%dT%H:00:00+00:00")
|
||||
prev_row = conn.execute(
|
||||
"SELECT hour, bytes_written_delta, bytes_read_delta, sample_count "
|
||||
"FROM hour_observations WHERE hour = ?",
|
||||
(midnight_hour_iso,),
|
||||
).fetchone()
|
||||
|
||||
total_bw = 0
|
||||
total_br = 0
|
||||
total_samples = 0
|
||||
known_seconds = 0
|
||||
unknown_seconds = 0
|
||||
hour_count = 0
|
||||
|
||||
for row in rows:
|
||||
total_bw += row[1] or 0
|
||||
total_br += row[2] or 0
|
||||
total_samples += row[3] or 0
|
||||
known_seconds += (row[4] or 0) + (row[5] or 0) + (row[6] or 0)
|
||||
unknown_seconds += row[7] or 0
|
||||
hour_count += 1
|
||||
|
||||
# Include midnight-spanning hour if it exists and is not already
|
||||
# in the main query results (it won't be, since hour < utc_start).
|
||||
if prev_row is not None:
|
||||
# Verify this hour is NOT already counted in the main query
|
||||
prev_hour_iso = prev_row[0]
|
||||
already_counted = any(r[0] == prev_hour_iso for r in rows)
|
||||
if not already_counted:
|
||||
total_bw += prev_row[1] or 0
|
||||
total_br += prev_row[2] or 0
|
||||
total_samples += prev_row[3] or 0
|
||||
hour_count += 1
|
||||
|
||||
total_evidenced = known_seconds + unknown_seconds
|
||||
# Coverage is known seconds as a share of the full local day,
|
||||
# not just the hours present — gaps reduce coverage
|
||||
day_seconds = int((utc_end - utc_start).total_seconds())
|
||||
coverage = known_seconds / day_seconds if day_seconds > 0 else 0.0
|
||||
|
||||
# Day is complete only if known (usable) seconds cover the full local day.
|
||||
# Unknown seconds represent gaps without usable observation evidence.
|
||||
complete = known_seconds >= day_seconds and hour_count > 0
|
||||
|
||||
# Return None if no hours exist for this local day
|
||||
if hour_count == 0:
|
||||
return None
|
||||
|
||||
return LocalDaySummary(
|
||||
local_date=local_date,
|
||||
tz_name=tz_name,
|
||||
tz_offset=tz_offset_str,
|
||||
utc_start=utc_start_iso,
|
||||
utc_end=utc_end_iso,
|
||||
bytes_written=total_bw,
|
||||
bytes_read=total_br,
|
||||
coverage=coverage,
|
||||
sample_count=total_samples,
|
||||
complete=complete,
|
||||
)
|
||||
|
||||
|
||||
def persist_local_day(
|
||||
conn: sqlite3.Connection,
|
||||
summary: LocalDaySummary,
|
||||
) -> bool:
|
||||
"""Upsert a local-day summary. Returns True if newly created."""
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM local_days WHERE local_date = ? AND tz_name = ?",
|
||||
(summary.local_date, summary.tz_name),
|
||||
).fetchone()
|
||||
|
||||
if existing is None:
|
||||
conn.execute(
|
||||
"INSERT INTO local_days "
|
||||
"(local_date, tz_name, tz_offset, utc_start, utc_end, "
|
||||
" bytes_written, bytes_read, coverage, sample_count, complete) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(summary.local_date, summary.tz_name, summary.tz_offset,
|
||||
summary.utc_start, summary.utc_end,
|
||||
summary.bytes_written, summary.bytes_read,
|
||||
summary.coverage, summary.sample_count, summary.complete),
|
||||
)
|
||||
created = True
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE local_days "
|
||||
"SET tz_offset = ?, utc_start = ?, utc_end = ?, "
|
||||
" bytes_written = ?, bytes_read = ?, "
|
||||
" coverage = ?, sample_count = ?, complete = ? "
|
||||
"WHERE id = ?",
|
||||
(summary.tz_offset, summary.utc_start, summary.utc_end,
|
||||
summary.bytes_written, summary.bytes_read,
|
||||
summary.coverage, summary.sample_count, summary.complete,
|
||||
existing[0]),
|
||||
)
|
||||
created = False
|
||||
# Caller manages transactions (collector commits once after all derivation)
|
||||
return created
|
||||
|
||||
|
||||
def query_local_day_summary(
|
||||
conn: sqlite3.Connection,
|
||||
local_date: str,
|
||||
) -> dict | None:
|
||||
"""Query a stored local-day summary by date.
|
||||
|
||||
Returns a dict with the summary fields, or None if no entry exists.
|
||||
"""
|
||||
row = conn.execute(
|
||||
"SELECT local_date, tz_name, tz_offset, utc_start, utc_end, "
|
||||
" bytes_written, bytes_read, coverage, sample_count, complete "
|
||||
"FROM local_days WHERE local_date = ? "
|
||||
"ORDER BY id DESC LIMIT 1",
|
||||
(local_date,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return {
|
||||
"local_date": row[0],
|
||||
"tz_name": row[1],
|
||||
"tz_offset": row[2],
|
||||
"utc_start": row[3],
|
||||
"utc_end": row[4],
|
||||
"bytes_written": row[5],
|
||||
"bytes_read": row[6],
|
||||
"coverage": row[7],
|
||||
"sample_count": row[8],
|
||||
"complete": bool(row[9]),
|
||||
}
|
||||
|
||||
|
||||
def query_current_local_day(
|
||||
conn: sqlite3.Connection,
|
||||
clock_now: datetime,
|
||||
tz_name: str,
|
||||
) -> dict | None:
|
||||
"""Query the local-day summary for the current local date."""
|
||||
from zoneinfo import ZoneInfo
|
||||
local_tz = ZoneInfo(tz_name)
|
||||
local_dt = clock_now.astimezone(local_tz)
|
||||
local_date = local_dt.strftime("%Y-%m-%d")
|
||||
return query_local_day_summary(conn, local_date)
|
||||
|
||||
|
||||
def _is_detail_available(
|
||||
conn: sqlite3.Connection,
|
||||
utc_start: str,
|
||||
utc_end: str,
|
||||
now: datetime,
|
||||
retention_days: int = 14,
|
||||
) -> bool:
|
||||
"""Check if raw samples covering the local-day range are still retained.
|
||||
|
||||
Returns True if at least one sample within [utc_start, utc_end] is
|
||||
younger than retention_days. This is a conservative check; the actual
|
||||
pruning boundary depends on boundary-anchor logic.
|
||||
"""
|
||||
cutoff = now - timedelta(days=retention_days)
|
||||
cutoff_iso = cutoff.isoformat()
|
||||
|
||||
# If any sample in the range is newer than cutoff, detail is available
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM samples WHERE ts >= ? AND ts < ? AND ts >= ? LIMIT 1",
|
||||
(utc_start, utc_end, cutoff_iso),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
def query_local_day_history(
|
||||
conn: sqlite3.Connection,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
now: datetime,
|
||||
) -> list[LocalDayHistoryEntry]:
|
||||
"""Query local-day summaries for a date range with evidence-limit metadata.
|
||||
|
||||
Returns history entries sorted by local_date, each annotated with
|
||||
whether the underlying raw detail is still available (within the
|
||||
14-day retention window) or has been pruned. Timezone information
|
||||
and UTC boundaries are always present, even after detail expires.
|
||||
|
||||
Args:
|
||||
conn: Connection to the observation store.
|
||||
start_date: Inclusive start date (e.g. "2026-09-01").
|
||||
end_date: Inclusive end date (e.g. "2026-09-14").
|
||||
now: Current UTC time for retention boundary check.
|
||||
|
||||
Returns:
|
||||
List of LocalDayHistoryEntry sorted by local_date.
|
||||
"""
|
||||
rows = conn.execute(
|
||||
"SELECT local_date, tz_name, tz_offset, utc_start, utc_end, "
|
||||
" bytes_written, bytes_read, coverage, sample_count, complete "
|
||||
"FROM local_days "
|
||||
"WHERE local_date >= ? AND local_date <= ? "
|
||||
"ORDER BY local_date",
|
||||
(start_date, end_date),
|
||||
).fetchall()
|
||||
|
||||
entries = []
|
||||
for row in rows:
|
||||
summary = {
|
||||
"local_date": row[0],
|
||||
"tz_name": row[1],
|
||||
"tz_offset": row[2],
|
||||
"utc_start": row[3],
|
||||
"utc_end": row[4],
|
||||
"bytes_written": row[5],
|
||||
"bytes_read": row[6],
|
||||
"coverage": row[7],
|
||||
"sample_count": row[8],
|
||||
"complete": row[9],
|
||||
}
|
||||
detail_available = _is_detail_available(conn, row[3], row[4], now)
|
||||
entries.append(
|
||||
LocalDayHistoryEntry.from_summary(
|
||||
summary,
|
||||
detail_available=detail_available,
|
||||
derived_from_surviving=True,
|
||||
)
|
||||
)
|
||||
return entries
|
||||
@@ -483,6 +483,31 @@ def _build_pu_context_line(conn, rate, days, clock_now):
|
||||
return "Percentage Used: %d%%" % pu
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Complete observation day gate (issue #94)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _has_complete_local_day(conn):
|
||||
"""Check if at least one complete local observation day exists.
|
||||
|
||||
A complete local day is a full midnight-to-midnight calendar day
|
||||
within a monitoring period that has usable observation evidence.
|
||||
This is the prerequisite for showing an endurance outlook.
|
||||
"""
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM local_days WHERE complete = 1 LIMIT 1"
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
def _count_complete_local_days(conn):
|
||||
"""Count the number of complete local observation days."""
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) FROM local_days WHERE complete = 1"
|
||||
).fetchone()
|
||||
return row[0] if row else 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main projection function
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -499,6 +524,30 @@ def compute_projection(conn, clock_now):
|
||||
tier, baseline, baseline_label, baseline_facts = _resolve_baseline(conn, current_segment)
|
||||
facts.extend(baseline_facts)
|
||||
|
||||
# --- Complete observation day gate (issue #94) ---
|
||||
# An endurance outlook requires at least one complete local
|
||||
# midnight-to-midnight calendar day with usable observation evidence.
|
||||
has_complete_day = _has_complete_local_day(conn)
|
||||
if not has_complete_day:
|
||||
facts.append("waiting for a full local observation day")
|
||||
return ProjectionResult(
|
||||
confidence_state=ConfidenceState.UNSUPPORTED,
|
||||
contributing_facts=facts,
|
||||
headline_remaining_seconds=None,
|
||||
scenario_range=None,
|
||||
pu_context_line="Percentage Used: unknown" if _get_latest_pu(conn) is None else "Percentage Used: %d%%" % (_get_latest_pu(conn) or 0),
|
||||
disclosure_text=list(DISCLOSURES),
|
||||
baseline_tier=tier,
|
||||
baseline_label=baseline_label,
|
||||
regime_days=None,
|
||||
habit_change_fact=None,
|
||||
warming_fact=None,
|
||||
staleness_fact=None,
|
||||
degraded_identity_fact=None,
|
||||
zero_rate_fact=None,
|
||||
qualifying_days_progress=None,
|
||||
)
|
||||
|
||||
segment_days = _get_days_in_segment(conn, current_segment["opened_at"]) if current_segment else _get_all_days(conn)
|
||||
all_days = _get_all_days(conn)
|
||||
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
Raw samples are pruned opportunistically to 14 days.
|
||||
Hour observations and day aggregates are retained indefinitely.
|
||||
Boundary anchors required for successor evidence are retained.
|
||||
|
||||
Local-day summaries (local_days table) are never touched by pruning.
|
||||
They are persisted at collection time from hour observations and survive
|
||||
raw-sample pruning because they depend on hour observations, not on raw
|
||||
samples. This is the mechanism that keeps local-day history trustworthy
|
||||
after detail expires (issue #93).
|
||||
"""
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
+102
-231
@@ -14,10 +14,14 @@ Freshness constants are defined once here and shared with the TUI (§8.9):
|
||||
Criteria: LC-9, CI-2, CI-4, FL-4, FL-5, FL-7.
|
||||
"""
|
||||
import sqlite3
|
||||
from contextlib import contextmanager
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from typing import Any, Dict, Iterator, List, Optional, Tuple, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .status_composition import StatusComposition
|
||||
|
||||
from .projection import compute_projection, ConfidenceState, DISCLOSURES
|
||||
from .store import SCHEMA_VERSION
|
||||
@@ -31,9 +35,9 @@ from .init_system import (
|
||||
# Freshness constants (§8.9, §8.2)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CADENCE_DEFAULT_S = 300 # 5 min
|
||||
CADENCE_DEFAULT_S = 180 # 3 min
|
||||
ACCURACY_SEC = 30
|
||||
FRESH_THRESHOLD_S = 2 * CADENCE_DEFAULT_S + ACCURACY_SEC + 60 # 690 s
|
||||
FRESH_THRESHOLD_S = 2 * CADENCE_DEFAULT_S + ACCURACY_SEC + 60 # 450 s
|
||||
STALENESS_THRESHOLD_S = 48 * 3600 # 48 h
|
||||
|
||||
|
||||
@@ -98,7 +102,7 @@ def open_store_readonly(store_path: Path) -> sqlite3.Connection:
|
||||
# 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)
|
||||
raise MissingStore("observation store not found at %s" % store_path)
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect("file:%s?mode=ro" % store_path, uri=True)
|
||||
@@ -125,6 +129,10 @@ class StoreFault(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class MissingStore(StoreFault):
|
||||
"""No observation history has been created yet."""
|
||||
|
||||
|
||||
class NewerSchema(Exception):
|
||||
"""Store has a newer user_version (§9.5)."""
|
||||
def __init__(self, version: int):
|
||||
@@ -246,27 +254,12 @@ def check_retired_flag(flag: str) -> Optional[str]:
|
||||
# Formatting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
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],
|
||||
def _format_projection(proj, freshness: str, drive_facts: List[str],
|
||||
config_error: Optional[str],
|
||||
sample_count: int = 0, day_count: int = 0) -> str:
|
||||
"""Format the complete status output."""
|
||||
"""Format projection details; monitoring status has its own renderer."""
|
||||
lines = []
|
||||
|
||||
# --- Store/system fault overrides (§9.4, §9.5) ---
|
||||
if store_fault:
|
||||
lines.append("observation store unreadable")
|
||||
if journal_hint:
|
||||
lines.append("")
|
||||
lines.append("Recent journal entries:")
|
||||
lines.append(journal_hint)
|
||||
return "\n".join(lines)
|
||||
|
||||
if newer_schema:
|
||||
lines.append("observation store written by a newer Fenris — upgrade Fenris")
|
||||
return "\n".join(lines)
|
||||
|
||||
# --- Configuration error (§8.3) ---
|
||||
if config_error:
|
||||
lines.append("configuration error: %s" % config_error)
|
||||
@@ -277,7 +270,6 @@ def _format_projection(proj, freshness: str, service: Dict[str, Any],
|
||||
lines.append("no observations yet")
|
||||
lines.append("")
|
||||
lines.append("Enable monitoring: fenris monitor resume")
|
||||
_append_service_facts(lines, service)
|
||||
return "\n".join(lines)
|
||||
|
||||
# --- Single sample: awaiting another sample (issue #73 AC3) ---
|
||||
@@ -287,7 +279,10 @@ def _format_projection(proj, freshness: str, service: Dict[str, Any],
|
||||
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)
|
||||
|
||||
if proj is None:
|
||||
lines.append("no projection available")
|
||||
return "\n".join(lines)
|
||||
|
||||
# --- Projection headline ---
|
||||
@@ -332,16 +327,6 @@ def _format_projection(proj, freshness: str, service: Dict[str, Any],
|
||||
lines.append(fact)
|
||||
lines.append("")
|
||||
|
||||
# --- Four separate service facts (§7.3, LC-9) ---
|
||||
_append_service_facts(lines, service)
|
||||
|
||||
# --- Journal hint on failure or staleness (§8.8) ---
|
||||
if journal_hint:
|
||||
if freshness in ("missed", "stale"):
|
||||
lines.append("")
|
||||
lines.append("Recent journal entries:")
|
||||
lines.append(journal_hint)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@@ -386,36 +371,6 @@ def _format_headline(proj) -> str:
|
||||
return headline
|
||||
|
||||
|
||||
def _append_service_facts(lines: List[str], service: Dict[str, Any]) -> None:
|
||||
"""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"
|
||||
|
||||
if service.get("last_collect_ok") is True:
|
||||
collect = "ok"
|
||||
elif service.get("last_collect_ok") is False:
|
||||
collect = "FAILED"
|
||||
if service.get("last_collect_reason"):
|
||||
collect += " (%s)" % service["last_collect_reason"]
|
||||
else:
|
||||
collect = "unknown"
|
||||
|
||||
collect_age = ""
|
||||
if service.get("last_collect_age_s") is not None:
|
||||
collect_age = " %s" % freshness_age_human(service["last_collect_age_s"])
|
||||
|
||||
freshness_str = service.get("freshness", "unknown")
|
||||
freshness_age = ""
|
||||
if service.get("freshness_age_s") is not None:
|
||||
freshness_age = " (%s)" % freshness_age_human(service["freshness_age_s"])
|
||||
|
||||
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)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -430,7 +385,9 @@ _PAUSED_CONSEQUENCE = (
|
||||
|
||||
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
|
||||
if service.get("boot_enabled") is None:
|
||||
return "monitoring: boot persistence unknown"
|
||||
return _CONTINUITY_ACTIVE if service["boot_enabled"] else _CONTINUITY_DISABLED
|
||||
|
||||
|
||||
def deliberate_pause_lines() -> List[str]:
|
||||
@@ -476,121 +433,92 @@ def format_disclosures() -> str:
|
||||
# Main status entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_status(store_path: Optional[Path] = None, clock_now: Optional[datetime] = None,
|
||||
query_services: bool = True, query_journal: bool = True) -> str:
|
||||
"""Render the complete read-only status (§8.8, LC-9).
|
||||
@contextmanager
|
||||
def read_status(
|
||||
store_path: Optional[Path] = None,
|
||||
clock_now: Optional[datetime] = None,
|
||||
query_services: bool = True,
|
||||
collecting: bool = False,
|
||||
reduced_motion: bool = False,
|
||||
) -> Iterator[Tuple[Optional[sqlite3.Connection], "StatusComposition"]]:
|
||||
"""Yield a read-only store snapshot and its composed monitoring status.
|
||||
|
||||
This is the single entry point for 'fenris status'. It never auto-samples,
|
||||
never prompts, and never writes to the store.
|
||||
Own acquisition, fault classification, and connection lifetime for both
|
||||
renderers. An absent store is empty; an unreadable or newer store exposes
|
||||
no connection. Unknown monitoring facts are never coerced to disabled.
|
||||
"""
|
||||
if clock_now is None:
|
||||
clock_now = datetime.now(timezone.utc)
|
||||
from .status_composition import compose_status
|
||||
|
||||
# --- Configuration (§8.3) ---
|
||||
config_error = None
|
||||
device = None
|
||||
try:
|
||||
config = read_config()
|
||||
device = config["device"]
|
||||
except ConfigError as e:
|
||||
config_error = str(e)
|
||||
|
||||
# --- Service state ---
|
||||
service = {}
|
||||
clock_now = clock_now or datetime.now(timezone.utc)
|
||||
service = None
|
||||
if query_services:
|
||||
service = query_service_state()
|
||||
|
||||
# --- Store open ---
|
||||
store_fault = None
|
||||
newer_schema = None
|
||||
conn = None
|
||||
|
||||
if store_path is None:
|
||||
store_path = Path("/var/lib/fenris/observations.db")
|
||||
|
||||
try:
|
||||
conn = open_store_readonly(store_path)
|
||||
except StoreFault as e:
|
||||
store_fault = str(e)
|
||||
except NewerSchema as e:
|
||||
newer_schema = str(e)
|
||||
|
||||
# --- Store fault / newer schema short-circuit ---
|
||||
if store_fault or newer_schema:
|
||||
journal_hint = None
|
||||
if query_journal:
|
||||
journal_hint = _journalctl_hint()
|
||||
service["freshness"] = "unknown"
|
||||
service["freshness_age_s"] = None
|
||||
return _format_projection(
|
||||
None, "unknown", service, [], config_error, store_fault, newer_schema, journal_hint
|
||||
)
|
||||
|
||||
# --- Freshness grading (§8.9) ---
|
||||
try:
|
||||
cursor = conn.execute("SELECT ts FROM samples ORDER BY id DESC LIMIT 1")
|
||||
row = cursor.fetchone()
|
||||
newest_ts = row[0] if row else None
|
||||
except sqlite3.Error:
|
||||
newest_ts = None
|
||||
|
||||
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:
|
||||
try:
|
||||
ts = datetime.fromisoformat(newest_ts)
|
||||
if ts.tzinfo is None:
|
||||
ts = ts.replace(tzinfo=timezone.utc)
|
||||
freshness_age_s = int((clock_now - ts).total_seconds())
|
||||
except (ValueError, TypeError):
|
||||
service = query_service_state()
|
||||
except (OSError, RuntimeError):
|
||||
pass
|
||||
|
||||
service["freshness"] = freshness
|
||||
service["freshness_age_s"] = freshness_age_s
|
||||
conn = None
|
||||
store_fault = newer_schema = None
|
||||
try:
|
||||
service["deliberately_paused"] = is_deliberately_paused(conn, service)
|
||||
except sqlite3.Error:
|
||||
service["deliberately_paused"] = False
|
||||
try:
|
||||
conn = open_store_readonly(store_path or Path("/var/lib/fenris/observations.db"))
|
||||
conn.execute("BEGIN")
|
||||
except MissingStore:
|
||||
pass
|
||||
except (StoreFault, sqlite3.Error) as exc:
|
||||
store_fault = str(exc)
|
||||
except NewerSchema as exc:
|
||||
newer_schema = str(exc)
|
||||
|
||||
# --- Drive anomalies (§9.7, FL-7) ---
|
||||
drive_facts = []
|
||||
comp = compose_status(
|
||||
conn, service, clock_now, store_fault=store_fault,
|
||||
newer_schema=newer_schema, collecting=collecting,
|
||||
reduced_motion=reduced_motion,
|
||||
)
|
||||
if conn is not None and (comp.store_fault or comp.newer_schema):
|
||||
conn.close()
|
||||
conn = None
|
||||
yield conn, comp
|
||||
finally:
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_status(store_path: Optional[Path] = None, clock_now: Optional[datetime] = None,
|
||||
query_services: bool = True, query_journal: bool = True) -> str:
|
||||
"""Render CLI status through the shared read-only acquisition path."""
|
||||
from .status_composition import render_status_cli
|
||||
|
||||
clock_now = clock_now or datetime.now(timezone.utc)
|
||||
config_error = None
|
||||
try:
|
||||
drive_facts = _query_drive_facts(conn)
|
||||
except sqlite3.Error:
|
||||
pass
|
||||
read_config()
|
||||
except ConfigError as exc:
|
||||
config_error = str(exc)
|
||||
|
||||
# --- Projection (§6 — recomputed on read, never stored) ---
|
||||
try:
|
||||
proj = compute_projection(conn, clock_now)
|
||||
except Exception:
|
||||
proj = None
|
||||
|
||||
# --- Journal hint on failure or staleness (§8.8) ---
|
||||
journal_hint = None
|
||||
if query_journal and freshness in ("missed", "stale"):
|
||||
journal_hint = _journalctl_hint()
|
||||
|
||||
# --- Compose output ---
|
||||
result = _format_projection(
|
||||
proj, freshness, service, drive_facts, config_error,
|
||||
None, None, journal_hint, sample_count, day_count,
|
||||
)
|
||||
|
||||
conn.close()
|
||||
return result
|
||||
with read_status(store_path, clock_now, query_services) as (conn, comp):
|
||||
parts = [render_status_cli(comp)]
|
||||
if not comp.store_fault and not comp.newer_schema:
|
||||
drive_facts = []
|
||||
proj = None
|
||||
if conn is not None:
|
||||
try:
|
||||
drive_facts = _query_drive_facts(conn)
|
||||
proj = compute_projection(conn, clock_now)
|
||||
except (sqlite3.Error, ValueError, TypeError):
|
||||
pass
|
||||
parts.append(_format_projection(
|
||||
proj, comp.freshness, drive_facts, config_error,
|
||||
comp.sample_count, comp.day_count,
|
||||
))
|
||||
if query_journal and (
|
||||
comp.store_fault or comp.last_collect_ok is False
|
||||
or comp.freshness in ("missed", "stale")
|
||||
):
|
||||
hint = _journalctl_hint()
|
||||
if hint:
|
||||
parts.append("Recent collector logs:\n" + hint)
|
||||
return "\n\n".join(part for part in parts if part)
|
||||
|
||||
|
||||
def render_status(store_path: Optional[Path] = None, clock_now: Optional[datetime] = None,
|
||||
@@ -619,65 +547,8 @@ def get_status_composition(
|
||||
collecting: bool = False,
|
||||
reduced_motion: bool = False,
|
||||
) -> 'StatusComposition':
|
||||
"""Get the shared status composition consumed by both TUI and CLI.
|
||||
|
||||
This is the new entry point that centralizes the status lattice.
|
||||
"""
|
||||
# Lazy import to avoid circular dependency
|
||||
from .status_composition import (
|
||||
StatusComposition,
|
||||
compose_status,
|
||||
)
|
||||
|
||||
if clock_now is None:
|
||||
clock_now = datetime.now(timezone.utc)
|
||||
|
||||
# --- Configuration (§8.3) ---
|
||||
# Config errors are surfaced through the store fault mechanism
|
||||
|
||||
# --- Service state ---
|
||||
service = {}
|
||||
if query_services:
|
||||
try:
|
||||
service = query_service_state()
|
||||
except Exception:
|
||||
service = None
|
||||
|
||||
# --- Store open ---
|
||||
store_fault = None
|
||||
newer_schema = None
|
||||
conn = None
|
||||
|
||||
if store_path is None:
|
||||
store_path = Path("/var/lib/fenris/observations.db")
|
||||
|
||||
try:
|
||||
conn = open_store_readonly(store_path)
|
||||
except StoreFault as e:
|
||||
store_fault = str(e)
|
||||
except NewerSchema as e:
|
||||
newer_schema = str(e)
|
||||
|
||||
# --- Use shared composition ---
|
||||
if conn is not None:
|
||||
try:
|
||||
comp = compose_status(
|
||||
conn, service, clock_now,
|
||||
store_fault=store_fault,
|
||||
newer_schema=newer_schema,
|
||||
collecting=collecting,
|
||||
reduced_motion=reduced_motion,
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
else:
|
||||
# Store fault or newer schema - compose without store data
|
||||
comp = compose_status(
|
||||
None, service, clock_now,
|
||||
store_fault=store_fault,
|
||||
newer_schema=newer_schema,
|
||||
collecting=collecting,
|
||||
reduced_motion=reduced_motion,
|
||||
)
|
||||
|
||||
return comp
|
||||
"""Return monitoring status without retaining the read-only snapshot."""
|
||||
with read_status(
|
||||
store_path, clock_now, query_services, collecting, reduced_motion,
|
||||
) as (_, comp):
|
||||
return comp
|
||||
|
||||
@@ -188,7 +188,7 @@ def _determine_explanation(
|
||||
) -> str:
|
||||
"""Determine the explanation line for the status state."""
|
||||
if store_fault:
|
||||
return "observation store unreadable — see journal"
|
||||
return "observation store unreadable — see collector logs"
|
||||
|
||||
if newer_schema:
|
||||
return "observation store written by a newer Fenris — upgrade Fenris"
|
||||
@@ -200,7 +200,7 @@ def _determine_explanation(
|
||||
if last_collect_reason:
|
||||
parts.append("(%s)" % last_collect_reason)
|
||||
if freshness_age_s is not None and freshness != "empty":
|
||||
parts.append("· last good sample %s ago" % freshness_age_human(freshness_age_s))
|
||||
parts.append("· last good sample %s" % freshness_age_human(freshness_age_s))
|
||||
return " ".join(parts) if parts else "last run failed"
|
||||
|
||||
if state == StatusState.INTERRUPTED:
|
||||
@@ -211,7 +211,7 @@ def _determine_explanation(
|
||||
|
||||
if state == StatusState.STALE:
|
||||
if freshness_age_s is not None:
|
||||
return "last sample %s ago" % freshness_age_human(freshness_age_s)
|
||||
return "last sample %s" % freshness_age_human(freshness_age_s)
|
||||
return "data is stale"
|
||||
|
||||
if state == StatusState.WAITING:
|
||||
@@ -223,7 +223,7 @@ def _determine_explanation(
|
||||
|
||||
if state == StatusState.MONITORING:
|
||||
if freshness_age_s is not None:
|
||||
return "last sample %s ago" % freshness_age_human(freshness_age_s)
|
||||
return "last sample %s" % freshness_age_human(freshness_age_s)
|
||||
return "monitoring active"
|
||||
|
||||
if state == StatusState.UNKNOWN:
|
||||
@@ -257,7 +257,7 @@ def _determine_collecting_overlay(
|
||||
|
||||
|
||||
def compose_status(
|
||||
conn: sqlite3.Connection,
|
||||
conn: Optional[sqlite3.Connection],
|
||||
service: Optional[Dict[str, Any]],
|
||||
clock_now: datetime,
|
||||
store_fault: Optional[str] = None,
|
||||
@@ -269,14 +269,17 @@ def compose_status(
|
||||
|
||||
This is the single entry point consumed by both TUI and CLI.
|
||||
"""
|
||||
service_available = service is not None and len(service) > 0
|
||||
service_available = bool(service) and any(
|
||||
service.get(key) is not None
|
||||
for key in ("boot_enabled", "timer_active")
|
||||
)
|
||||
|
||||
# --- Separate facts from service ---
|
||||
boot_enabled = service.get("boot_enabled") if service_available else None
|
||||
timer_active = service.get("timer_active") if service_available else None
|
||||
last_collect_ok = service.get("last_collect_ok") if service_available else None
|
||||
last_collect_age_s = service.get("last_collect_age_s") if service_available else None
|
||||
last_collect_reason = service.get("last_collect_reason") if service_available else None
|
||||
boot_enabled = service.get("boot_enabled") if service else None
|
||||
timer_active = service.get("timer_active") if service else None
|
||||
last_collect_ok = service.get("last_collect_ok") if service else None
|
||||
last_collect_age_s = service.get("last_collect_age_s") if service else None
|
||||
last_collect_reason = service.get("last_collect_reason") if service else None
|
||||
|
||||
# --- Freshness from store ---
|
||||
freshness = "unknown"
|
||||
@@ -285,7 +288,10 @@ def compose_status(
|
||||
day_count = 0
|
||||
deliberately_paused = False
|
||||
|
||||
if store_fault is None and newer_schema is None:
|
||||
if conn is None and store_fault is None and newer_schema is None:
|
||||
freshness = "empty"
|
||||
|
||||
if conn is not None and store_fault is None and newer_schema is None:
|
||||
try:
|
||||
cursor = conn.execute("SELECT ts FROM samples ORDER BY id DESC LIMIT 1")
|
||||
row = cursor.fetchone()
|
||||
@@ -301,28 +307,35 @@ def compose_status(
|
||||
freshness_age_s = int((clock_now - ts).total_seconds())
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
except sqlite3.Error:
|
||||
freshness = "unknown"
|
||||
except sqlite3.Error as exc:
|
||||
store_fault = str(exc)
|
||||
|
||||
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
|
||||
except sqlite3.Error as exc:
|
||||
store_fault = str(exc)
|
||||
|
||||
try:
|
||||
svc_for_pause = service if service_available else {}
|
||||
deliberately_paused = is_deliberately_paused(conn, svc_for_pause)
|
||||
except sqlite3.Error:
|
||||
deliberately_paused = False
|
||||
except sqlite3.Error as exc:
|
||||
store_fault = str(exc)
|
||||
|
||||
if store_fault or newer_schema:
|
||||
freshness = "unknown"
|
||||
freshness_age_s = None
|
||||
sample_count = day_count = 0
|
||||
deliberately_paused = False
|
||||
|
||||
# --- External stop detection ---
|
||||
# External stop = timer inactive + boot disabled + NOT deliberately paused
|
||||
# + period still open (the timer was stopped but Fenris didn't close the period)
|
||||
external_stop_reason = None
|
||||
if not deliberately_paused and timer_active is False and boot_enabled is False:
|
||||
if (conn is not None and not store_fault and not newer_schema
|
||||
and not deliberately_paused and timer_active is False and boot_enabled is False):
|
||||
# Check if there's an open monitoring period (external stop left it open)
|
||||
try:
|
||||
open_period = conn.execute(
|
||||
@@ -428,10 +441,9 @@ def render_status_cli(comp: StatusComposition) -> str:
|
||||
|
||||
# Separate facts
|
||||
facts = []
|
||||
if comp.freshness != "unknown":
|
||||
facts.append("freshness: %s" % comp.freshness)
|
||||
if comp.freshness_age_s is not None:
|
||||
facts[-1] += " (%s)" % freshness_age_human(comp.freshness_age_s) if facts else "freshness: %s" % freshness_age_human(comp.freshness_age_s)
|
||||
facts.append("freshness: %s" % comp.freshness)
|
||||
if comp.freshness_age_s is not None and facts:
|
||||
facts[-1] += " (%s)" % freshness_age_human(comp.freshness_age_s)
|
||||
if comp.last_collect_ok is True:
|
||||
facts.append("last collect: ok")
|
||||
elif comp.last_collect_ok is False:
|
||||
@@ -441,10 +453,12 @@ def render_status_cli(comp: StatusComposition) -> str:
|
||||
facts.append(collect_str)
|
||||
else:
|
||||
facts.append("last collect: unknown")
|
||||
if comp.boot_enabled is not None:
|
||||
facts.append("boot: %s" % ("enabled" if comp.boot_enabled else "disabled"))
|
||||
if comp.timer_active is not None:
|
||||
facts.append("timer: %s" % ("active" if comp.timer_active else "inactive"))
|
||||
facts.append("boot: %s" % (
|
||||
"unknown" if comp.boot_enabled is None else "enabled" if comp.boot_enabled else "disabled"
|
||||
))
|
||||
facts.append("timer: %s" % (
|
||||
"unknown" if comp.timer_active is None else "active" if comp.timer_active else "inactive"
|
||||
))
|
||||
|
||||
if facts:
|
||||
lines.append(" · ".join(facts))
|
||||
@@ -486,29 +500,30 @@ def render_status_tui(comp: StatusComposition) -> str:
|
||||
|
||||
# Explanation
|
||||
if comp.explanation:
|
||||
lines.append(comp.explanation)
|
||||
lines.append(comp.explanation[:1].upper() + comp.explanation[1:])
|
||||
|
||||
lines.append("")
|
||||
|
||||
# Separate facts
|
||||
facts = []
|
||||
if comp.freshness != "unknown":
|
||||
facts.append("freshness: %s" % comp.freshness)
|
||||
facts.append("Freshness: %s" % comp.freshness)
|
||||
if comp.freshness_age_s is not None and facts:
|
||||
facts[-1] += " (%s)" % freshness_age_human(comp.freshness_age_s)
|
||||
if comp.last_collect_ok is True:
|
||||
facts.append("last collect: ok")
|
||||
facts.append("Last collect: ok")
|
||||
elif comp.last_collect_ok is False:
|
||||
collect_str = "last collect: FAILED"
|
||||
collect_str = "Last collect: failed"
|
||||
if comp.last_collect_reason:
|
||||
collect_str += " (%s)" % comp.last_collect_reason
|
||||
facts.append(collect_str)
|
||||
else:
|
||||
facts.append("last collect: unknown")
|
||||
if comp.boot_enabled is not None:
|
||||
facts.append("boot: %s" % ("enabled" if comp.boot_enabled else "disabled"))
|
||||
if comp.timer_active is not None:
|
||||
facts.append("timer: %s" % ("active" if comp.timer_active else "inactive"))
|
||||
facts.append("Last collect: unknown")
|
||||
facts.append("Boot: %s" % (
|
||||
"unknown" if comp.boot_enabled is None else "enabled" if comp.boot_enabled else "disabled"
|
||||
))
|
||||
facts.append("Timer: %s" % (
|
||||
"unknown" if comp.timer_active is None else "active" if comp.timer_active else "inactive"
|
||||
))
|
||||
|
||||
if facts:
|
||||
lines.append(" · ".join(facts))
|
||||
@@ -516,11 +531,11 @@ def render_status_tui(comp: StatusComposition) -> str:
|
||||
# Continuity
|
||||
if comp.continuity:
|
||||
lines.append("")
|
||||
lines.append("[bold]CONTINUITY[/bold] %s" % comp.continuity)
|
||||
lines.append("[bold]Continuity[/bold] %s" % comp.continuity)
|
||||
|
||||
# Deliberate pause
|
||||
if comp.paused_lines:
|
||||
for pl in comp.paused_lines:
|
||||
lines.append(pl)
|
||||
lines.append(pl[:1].upper() + pl[1:])
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
+46
-1
@@ -12,7 +12,7 @@ from typing import Optional
|
||||
|
||||
|
||||
# Schema version - increment on each migration
|
||||
SCHEMA_VERSION = 2
|
||||
SCHEMA_VERSION = 3
|
||||
|
||||
|
||||
# Packaged default placement (spec §8.3). The config may override it, but a
|
||||
@@ -193,6 +193,26 @@ def _create_schema(conn: sqlite3.Connection):
|
||||
""")
|
||||
|
||||
|
||||
# Local-day activity summaries derived from UTC hour observations.
|
||||
# Each row retains its recorded timezone and UTC boundaries so that
|
||||
# historical summaries survive a system-timezone change (ADR 0010).
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS local_days (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
local_date TEXT NOT NULL, -- e.g. "2026-09-01" in the recorded tz
|
||||
tz_name TEXT NOT NULL, -- POSIX tz name, e.g. "Asia/Kolkata"
|
||||
tz_offset TEXT NOT NULL, -- e.g. "+05:30"
|
||||
utc_start TEXT NOT NULL, -- ISO 8601 UTC: local midnight start
|
||||
utc_end TEXT NOT NULL, -- ISO 8601 UTC: local midnight end
|
||||
bytes_written INTEGER DEFAULT 0,
|
||||
bytes_read INTEGER DEFAULT 0,
|
||||
coverage REAL DEFAULT 0.0,
|
||||
sample_count INTEGER DEFAULT 0,
|
||||
complete BOOLEAN DEFAULT 0,
|
||||
UNIQUE(local_date, tz_name)
|
||||
)
|
||||
""")
|
||||
|
||||
# Metadata table for store state (e.g., legacy import marker)
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS store_metadata (
|
||||
@@ -230,6 +250,31 @@ def _apply_migrations(conn: sqlite3.Connection, current_version: int):
|
||||
conn.execute("ALTER TABLE day_aggregates ADD COLUMN unattributed_bytes_read INTEGER DEFAULT 0")
|
||||
current_version = 2
|
||||
|
||||
# Migration 2→3: add local_days table for local-day activity totals
|
||||
# (issue #90, ADR 0010). Pure addition — no existing rows touched.
|
||||
if current_version < 3:
|
||||
tables = {row[0] for row in conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||
).fetchall()}
|
||||
if "local_days" not in tables:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS local_days (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
local_date TEXT NOT NULL,
|
||||
tz_name TEXT NOT NULL,
|
||||
tz_offset TEXT NOT NULL,
|
||||
utc_start TEXT NOT NULL,
|
||||
utc_end TEXT NOT NULL,
|
||||
bytes_written INTEGER DEFAULT 0,
|
||||
bytes_read INTEGER DEFAULT 0,
|
||||
coverage REAL DEFAULT 0.0,
|
||||
sample_count INTEGER DEFAULT 0,
|
||||
complete BOOLEAN DEFAULT 0,
|
||||
UNIQUE(local_date, tz_name)
|
||||
)
|
||||
""")
|
||||
current_version = 3
|
||||
|
||||
|
||||
def migrate_to_latest(store_path: Path) -> int:
|
||||
"""Apply forward-only migrations to bring the store to SCHEMA_VERSION.
|
||||
|
||||
+1081
-471
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
||||
"""System timezone detection for local-day activity totals.
|
||||
|
||||
Provides timezone detection (TZ env, /etc/localtime) and offset
|
||||
computation used by local-day derivation. All functions are
|
||||
stateless and safe to call from the collector and TUI reader.
|
||||
"""
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def detect_system_tz() -> str:
|
||||
"""Detect the system timezone name.
|
||||
|
||||
Resolution order:
|
||||
1. ``TZ`` environment variable (may be empty or ":UTC")
|
||||
2. Symlink target of ``/etc/localtime``
|
||||
3. Falls back to ``"UTC"``
|
||||
|
||||
Returns a POSIX timezone name like ``"Asia/Kolkata"`` or ``"UTC"``.
|
||||
"""
|
||||
tz = os.environ.get("TZ", "").strip()
|
||||
if tz:
|
||||
return tz.lstrip(":")
|
||||
|
||||
localtime = Path("/etc/localtime")
|
||||
if localtime.is_symlink():
|
||||
target = os.readlink(str(localtime))
|
||||
# Strip common prefixes: /usr/share/zoneinfo/, /usr/lib/zoneinfo/
|
||||
for prefix in ("/usr/share/zoneinfo/", "/usr/lib/zoneinfo/"):
|
||||
if target.startswith(prefix):
|
||||
return target[len(prefix):]
|
||||
return target
|
||||
|
||||
return "UTC"
|
||||
|
||||
|
||||
def get_tz_offset_str(dt: datetime, tz_name: str) -> str:
|
||||
"""UTC offset as ``+HH:MM`` or ``-HH:MM`` string for *dt* in *tz_name*."""
|
||||
from zoneinfo import ZoneInfo
|
||||
local_tz = ZoneInfo(tz_name)
|
||||
local_dt = dt.astimezone(local_tz)
|
||||
offset = local_dt.utcoffset()
|
||||
total_seconds = int(offset.total_seconds())
|
||||
sign = "+" if total_seconds >= 0 else "-"
|
||||
total_seconds = abs(total_seconds)
|
||||
hours = total_seconds // 3600
|
||||
minutes = (total_seconds % 3600) // 60
|
||||
return "%s%02d:%02d" % (sign, hours, minutes)
|
||||
@@ -109,12 +109,40 @@ def _open_period(conn, start="2026-09-01T00:00:00+00:00"):
|
||||
ensure_period_open(conn, datetime.fromisoformat(start))
|
||||
|
||||
|
||||
def _insert_local_day(conn, local_date, tz_name="UTC", tz_offset="+00:00",
|
||||
utc_start=None, utc_end=None, bw=1024*1024*100,
|
||||
br=0, coverage=0.95, samples=24, complete=True):
|
||||
"""Insert a local_days row (issue #94 gate prerequisite)."""
|
||||
if utc_start is None:
|
||||
utc_start = local_date + "T00:00:00+00:00"
|
||||
if utc_end is None:
|
||||
dt = datetime.strptime(local_date, "%Y-%m-%d") + timedelta(days=1)
|
||||
utc_end = dt.strftime("%Y-%m-%dT00:00:00+00:00")
|
||||
conn.execute(
|
||||
"INSERT INTO local_days "
|
||||
"(local_date, tz_name, tz_offset, utc_start, utc_end, "
|
||||
" bytes_written, bytes_read, coverage, sample_count, complete) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(local_date, tz_name, tz_offset, utc_start, utc_end,
|
||||
bw, br, coverage, samples, complete),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _insert_complete_local_days(conn, start_date, count, bw=1024*1024*100):
|
||||
"""Insert multiple complete local days to satisfy the issue #94 gate."""
|
||||
for i in range(count):
|
||||
d = (datetime.strptime(start_date, "%Y-%m-%d") + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_local_day(conn, d, bw=bw)
|
||||
|
||||
|
||||
def _setup_full_store(conn, *, baseline=True, segment=True, days=30,
|
||||
bw=1024*1024*100, coverage=0.95, samples_per_day=24,
|
||||
sample_ts="2026-09-30T10:00:00+00:00",
|
||||
period_start="2026-09-01T00:00:00+00:00",
|
||||
segment_opened="2026-09-01T00:00:00+00:00",
|
||||
baseline_kw=None, segment_kw=None):
|
||||
baseline_kw=None, segment_kw=None,
|
||||
local_days=True):
|
||||
if baseline:
|
||||
_insert_baseline(conn, **(baseline_kw or {}))
|
||||
if segment:
|
||||
@@ -125,6 +153,9 @@ def _setup_full_store(conn, *, baseline=True, segment=True, days=30,
|
||||
_insert_day(conn, d, bw=bw, coverage=coverage, samples=samples_per_day)
|
||||
if sample_ts:
|
||||
_insert_sample(conn, sample_ts)
|
||||
# Issue #94: satisfy the complete-observation-day gate
|
||||
if local_days and days > 0:
|
||||
_insert_complete_local_days(conn, "2026-09-29", 1, bw=bw)
|
||||
|
||||
|
||||
# ===================================================================
|
||||
@@ -217,6 +248,7 @@ class TestCI1StateMatrix:
|
||||
d = (datetime(2026, 9, 25) + 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")
|
||||
_insert_complete_local_days(conn, "2026-09-29", 1)
|
||||
proj = compute_projection(conn, _clock())
|
||||
assert proj.confidence_state == ConfidenceState.LIMITED
|
||||
conn.close()
|
||||
@@ -230,6 +262,7 @@ class TestCI1StateMatrix:
|
||||
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(conn, d, bw=1024*1024*100, coverage=0.95, samples=24)
|
||||
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
|
||||
_insert_complete_local_days(conn, "2026-09-29", 1)
|
||||
proj = compute_projection(conn, _clock())
|
||||
assert proj.confidence_state == ConfidenceState.LIMITED
|
||||
assert proj.warming_fact is not None
|
||||
@@ -245,6 +278,7 @@ class TestCI1StateMatrix:
|
||||
_insert_day(conn, d, bw=1024*1024*100, coverage=0.95, samples=24)
|
||||
stale_ts = (_clock() - timedelta(days=5)).isoformat()
|
||||
_insert_sample(conn, stale_ts)
|
||||
_insert_complete_local_days(conn, "2026-08-30", 1)
|
||||
proj = compute_projection(conn, _clock())
|
||||
assert proj.confidence_state == ConfidenceState.LIMITED
|
||||
assert proj.staleness_fact is not None
|
||||
@@ -259,6 +293,7 @@ class TestCI1StateMatrix:
|
||||
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(conn, d, bw=1024*1024*100, coverage=0.95, samples=24)
|
||||
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
|
||||
_insert_complete_local_days(conn, "2026-09-29", 1)
|
||||
proj = compute_projection(conn, _clock())
|
||||
assert proj.confidence_state == ConfidenceState.LIMITED
|
||||
assert proj.degraded_identity_fact is not None
|
||||
@@ -273,6 +308,7 @@ class TestCI1StateMatrix:
|
||||
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(conn, d, bw=0)
|
||||
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
|
||||
_insert_complete_local_days(conn, "2026-09-29", 1)
|
||||
proj = compute_projection(conn, _clock())
|
||||
assert proj.confidence_state == ConfidenceState.UNSUPPORTED
|
||||
assert proj.zero_rate_fact is not None
|
||||
@@ -461,7 +497,7 @@ class TestCI2Parity:
|
||||
}
|
||||
|
||||
with patch("fenris.status.query_service_state", return_value=service_state), patch(
|
||||
"fenris.tui.query_service_state", return_value=service_state
|
||||
"fenris.status.query_service_state", return_value=service_state
|
||||
):
|
||||
status = get_status(
|
||||
store_path=db, clock_now=_clock(), query_services=True, query_journal=False
|
||||
@@ -502,18 +538,6 @@ class TestCI2Parity:
|
||||
query_services=True, query_journal=False)
|
||||
assert "no observations yet" in status.lower()
|
||||
|
||||
def test_store_fault_phrase_both_views(self, tmp_path):
|
||||
status_src = (FENRIS_PKG / "status.py").read_text()
|
||||
tui_src = (FENRIS_PKG / "tui.py").read_text()
|
||||
phrase = "observation store unreadable"
|
||||
assert phrase in status_src
|
||||
assert phrase.lower() in tui_src.lower()
|
||||
|
||||
def test_newer_schema_phrase_both_views(self):
|
||||
status_src = (FENRIS_PKG / "status.py").read_text()
|
||||
phrase = "observation store written by a newer Fenris"
|
||||
assert phrase in status_src
|
||||
|
||||
def test_status_never_prompts(self):
|
||||
status_src = (FENRIS_PKG / "status.py").read_text()
|
||||
assert "input(" not in status_src
|
||||
@@ -603,12 +627,6 @@ class TestCI3ProhibitionSet:
|
||||
table_names.append(m.group(1))
|
||||
assert "projection" not in [t.lower() for t in table_names]
|
||||
|
||||
def test_no_partial_newer_schema_interpretation(self):
|
||||
"""Readers refuse newer-schema stores. [3.6, 9.5]"""
|
||||
status_src = (FENRIS_PKG / "status.py").read_text()
|
||||
assert "NewerSchema" in status_src
|
||||
assert "upgrade Fenris" in status_src
|
||||
|
||||
def test_polkit_authorizes_one_binary(self):
|
||||
"""Polkit authorizes exactly one binary: fenris-monitor. [8.5]"""
|
||||
monitor_src = (FENRIS_PKG / "monitor.py").read_text()
|
||||
@@ -688,18 +706,6 @@ class TestCI4WordingAndDisclosures:
|
||||
proj_src = (FENRIS_PKG / "projection.py").read_text()
|
||||
assert phrase in proj_src
|
||||
|
||||
def test_store_fault_phrase(self):
|
||||
phrase = "observation store unreadable"
|
||||
status_src = (FENRIS_PKG / "status.py").read_text()
|
||||
assert phrase in status_src
|
||||
tui_src = (FENRIS_PKG / "tui.py").read_text()
|
||||
assert phrase.lower() in tui_src.lower()
|
||||
|
||||
def test_newer_schema_phrase(self):
|
||||
phrase = "observation store written by a newer Fenris"
|
||||
status_src = (FENRIS_PKG / "status.py").read_text()
|
||||
assert phrase in status_src
|
||||
|
||||
def test_no_observations_phrase(self):
|
||||
phrase = "no observations yet"
|
||||
status_src = (FENRIS_PKG / "status.py").read_text()
|
||||
|
||||
@@ -79,6 +79,20 @@ def test_release_footer_verifies_the_clearsigned_checksum_asset():
|
||||
assert "sha256sum -c SHA256SUMS" in footer
|
||||
|
||||
|
||||
def test_release_footer_configures_the_xbps_url_as_a_repository():
|
||||
footer = (REPO_ROOT / "packaging" / "release-footer.md").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
repository = (
|
||||
"repository=https://git.bongbetic.com/xavierk/Fenris-xbps/"
|
||||
"raw/branch/stable/x86_64"
|
||||
)
|
||||
assert repository in footer
|
||||
assert "sudo xbps-install -M -S fenris" in footer
|
||||
assert "sudo xbps-install -S https://git.bongbetic.com" not in footer
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("changelog", "expected_error"),
|
||||
[
|
||||
|
||||
@@ -154,7 +154,7 @@ class TestSchemaMigration:
|
||||
|
||||
# Migrate
|
||||
steps = migrate_to_latest(db)
|
||||
assert steps == 1
|
||||
assert steps == 2 # v1→v2→v3
|
||||
|
||||
# Verify data preserved
|
||||
conn = sqlite3.connect(str(db))
|
||||
@@ -412,7 +412,7 @@ class TestDisplayStates:
|
||||
|
||||
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
|
||||
from fenris.tui import FenrisTuiApp
|
||||
db = tmp_path / "test.db"
|
||||
conn = init_store(db)
|
||||
conn.execute(
|
||||
|
||||
@@ -248,6 +248,7 @@ def test_store_initialization(config_fixture: Dict[str, Any]):
|
||||
# sqlite_sequence is a system table created by AUTOINCREMENT
|
||||
expected_tables.add("sqlite_sequence")
|
||||
expected_tables.add("store_metadata")
|
||||
expected_tables.add("local_days")
|
||||
assert expected_tables == tables
|
||||
|
||||
conn.close()
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
"""Complete observation day gate tests (issue #94).
|
||||
|
||||
Verifies that the endurance projection is withheld until at least one
|
||||
complete local calendar day has been observed within a monitoring period.
|
||||
|
||||
Seams:
|
||||
- compute_projection() → gate check via local_days table
|
||||
- ProjectionResult.contributing_facts → "waiting for a full local observation day"
|
||||
|
||||
Acceptance criteria:
|
||||
- Gate-1: No complete local day → UNSUPPORTED with waiting fact
|
||||
- Gate-2: One complete local day → Limited confidence (if other conditions met)
|
||||
- Gate-3: Partial days don't satisfy the gate
|
||||
- Gate-4: CLI and TUI share the same gate via compute_projection()
|
||||
"""
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
|
||||
from fenris.store import init_store
|
||||
from fenris.monitoring_periods import ensure_period_open
|
||||
from fenris.projection import (
|
||||
compute_projection, ConfidenceState, BaselineTier,
|
||||
WARMING_COVERAGE_FLOOR,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def store(tmp_path):
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
yield conn
|
||||
conn.close()
|
||||
|
||||
|
||||
def _clock(year=2026, month=9, day=30, hour=12):
|
||||
return datetime(year, month, day, hour, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _insert_baseline(conn, tbw_tb=1.0, verified=True):
|
||||
conn.execute(
|
||||
"INSERT INTO endurance_baseline "
|
||||
"(tbw_terabytes, source_url, document_revision, entry_date, model_string, "
|
||||
" nominal_capacity_bytes, validated_by, verified, created_at, updated_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(tbw_tb, "https://example.com/spec", "v1.0", "2026-01-01",
|
||||
"Samsung SSD 970 EVO Plus 1TB", 1024000000000,
|
||||
"machine_match" if verified else None, verified,
|
||||
"2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00"),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _insert_segment(conn, opened_at="2026-09-01T00:00:00+00:00"):
|
||||
conn.execute(
|
||||
"INSERT INTO controller_segments "
|
||||
"(opened_at, identity_key, identity_degraded, subnqn, sn, mn, fr, vid, ssvid, transport) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(opened_at, "nqn.test", False, "nqn.test", "SN123",
|
||||
"Samsung SSD 970 EVO Plus 1TB", "FW1", "0x144d", "0x144d", "pcie"),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _insert_day(conn, day, bw=1024*1024*100, coverage=0.95, samples=24):
|
||||
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, 3600, 0, 0, 0, bw, 0, samples, coverage),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _insert_sample(conn, ts, pu=5):
|
||||
conn.execute(
|
||||
"INSERT INTO samples (ts, device, data_units_written, data_units_read, "
|
||||
"percentage_used, bytes_written, bytes_read, power_on_hours) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(ts, "/dev/nvme0n1", 1000000, 500000, pu, 512000000000, 256000000000, 8765),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _insert_local_day(conn, local_date, tz_name="UTC", tz_offset="+00:00",
|
||||
utc_start=None, utc_end=None, bw=1024*1024*100,
|
||||
br=0, coverage=0.95, samples=24, complete=True):
|
||||
"""Insert a local_days row for testing the gate."""
|
||||
if utc_start is None:
|
||||
utc_start = local_date + "T00:00:00+00:00"
|
||||
if utc_end is None:
|
||||
# Next day
|
||||
dt = datetime.strptime(local_date, "%Y-%m-%d") + timedelta(days=1)
|
||||
utc_end = dt.strftime("%Y-%m-%dT00:00:00+00:00")
|
||||
conn.execute(
|
||||
"INSERT INTO local_days "
|
||||
"(local_date, tz_name, tz_offset, utc_start, utc_end, "
|
||||
" bytes_written, bytes_read, coverage, sample_count, complete) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(local_date, tz_name, tz_offset, utc_start, utc_end,
|
||||
bw, br, coverage, samples, complete),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _open_period(conn, start="2026-09-01T00:00:00+00:00"):
|
||||
ensure_period_open(conn, datetime.fromisoformat(start))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gate-1: No complete local day → UNSUPPORTED with waiting fact
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGateNoCompleteDay:
|
||||
"""Projection is unavailable before any complete local observation day."""
|
||||
|
||||
def test_no_local_days_unsupported(self, store):
|
||||
"""With no local_days entries, projection is UNSUPPORTED."""
|
||||
_insert_baseline(store)
|
||||
_insert_segment(store)
|
||||
_open_period(store)
|
||||
# 14 days of UTC data — enough for normal projection, but no local_days
|
||||
for i in range(14):
|
||||
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(store, d, bw=1024*1024*100)
|
||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||
result = compute_projection(store, _clock())
|
||||
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
||||
assert any("full local observation day" in f for f in result.contributing_facts)
|
||||
assert result.headline_remaining_seconds is None
|
||||
|
||||
def test_only_partial_local_days_unsupported(self, store):
|
||||
"""Partial (incomplete) local days don't satisfy the gate."""
|
||||
_insert_baseline(store)
|
||||
_insert_segment(store)
|
||||
_open_period(store)
|
||||
for i in range(14):
|
||||
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(store, d, bw=1024*1024*100)
|
||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||
# Insert only incomplete local days
|
||||
for i in range(5):
|
||||
d = (datetime(2026, 9, 25) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_local_day(store, d, complete=False, coverage=0.3)
|
||||
result = compute_projection(store, _clock())
|
||||
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
||||
assert any("full local observation day" in f for f in result.contributing_facts)
|
||||
|
||||
def test_gate_before_warming_check(self, store):
|
||||
"""Gate fires even when warming would also block — gate has precedence."""
|
||||
_insert_baseline(store)
|
||||
_insert_segment(store)
|
||||
_open_period(store)
|
||||
# Only 3 days of data (below warming threshold)
|
||||
for i in range(3):
|
||||
d = (datetime(2026, 9, 27) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(store, d, bw=1024*1024*100)
|
||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||
# Insert one complete local day — but still below warming
|
||||
_insert_local_day(store, "2026-09-29", complete=True)
|
||||
result = compute_projection(store, _clock())
|
||||
# Gate is satisfied (one complete day), but warming blocks Supported
|
||||
# The key assertion: gate message should NOT appear when gate IS met
|
||||
assert not any("full local observation day" in f for f in result.contributing_facts)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gate-2: One complete local day → Limited confidence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGateOneCompleteDay:
|
||||
"""After one complete local day, projection can proceed with Limited confidence."""
|
||||
|
||||
def test_one_complete_day_allows_projection(self, store):
|
||||
"""With one complete local day and valid baseline/rate, projection is Limited."""
|
||||
_insert_baseline(store, tbw_tb=1.0, verified=True)
|
||||
_insert_segment(store)
|
||||
_open_period(store)
|
||||
# 14 days of UTC data
|
||||
for i in range(14):
|
||||
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(store, d, bw=1024*1024*100)
|
||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||
# One complete local day
|
||||
_insert_local_day(store, "2026-09-29", complete=True)
|
||||
result = compute_projection(store, _clock())
|
||||
# Gate satisfied — no "waiting" fact
|
||||
assert not any("full local observation day" in f for f in result.contributing_facts)
|
||||
# With only 14 days and other Limited factors, should be Limited or Supported
|
||||
assert result.confidence_state in (ConfidenceState.LIMITED, ConfidenceState.SUPPORTED)
|
||||
# Headline should exist (rate > 0, baseline exists)
|
||||
assert result.headline_remaining_seconds is not None
|
||||
|
||||
def test_gate_fact_absent_when_satisfied(self, store):
|
||||
"""The 'waiting for full day' fact does not appear when gate is met."""
|
||||
_insert_baseline(store)
|
||||
_insert_segment(store)
|
||||
_open_period(store)
|
||||
for i in range(20):
|
||||
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(store, d, bw=1024*1024*100)
|
||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||
_insert_local_day(store, "2026-09-29", complete=True)
|
||||
result = compute_projection(store, _clock())
|
||||
assert not any("full local observation day" in f for f in result.contributing_facts)
|
||||
assert result.confidence_state != ConfidenceState.UNSUPPORTED
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gate-3: Partial first day doesn't satisfy the gate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGatePartialFirstDay:
|
||||
"""Starting monitoring at noon means the partial first day doesn't count."""
|
||||
|
||||
def test_partial_first_day_not_enough(self, store):
|
||||
"""A single incomplete local day (started at noon) doesn't open the gate."""
|
||||
_insert_baseline(store)
|
||||
_insert_segment(store, opened_at="2026-09-29T12:00:00+00:00")
|
||||
_open_period(store, start="2026-09-29T12:00:00+00:00")
|
||||
# Only Sep 29 (partial) and Sep 30 (today, partial)
|
||||
_insert_day(store, "2026-09-29", bw=1024*1024*100, coverage=0.5, samples=12)
|
||||
_insert_day(store, "2026-09-30", bw=1024*1024*100, coverage=0.5, samples=12)
|
||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||
# Only partial local days
|
||||
_insert_local_day(store, "2026-09-29", complete=False, coverage=0.5)
|
||||
_insert_local_day(store, "2026-09-30", complete=False, coverage=0.5)
|
||||
result = compute_projection(store, _clock())
|
||||
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
||||
assert any("full local observation day" in f for f in result.contributing_facts)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gate-4: Multiple complete days also satisfy the gate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGateMultipleCompleteDays:
|
||||
"""Multiple complete local days satisfy the gate."""
|
||||
|
||||
def test_multiple_complete_days_satisfy_gate(self, store):
|
||||
"""Several complete local days open the gate."""
|
||||
_insert_baseline(store)
|
||||
_insert_segment(store)
|
||||
_open_period(store)
|
||||
for i in range(14):
|
||||
d = (datetime(2026, 9, 15) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(store, d, bw=1024*1024*100)
|
||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||
# 7 complete local days
|
||||
for i in range(7):
|
||||
d = (datetime(2026, 9, 23) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_local_day(store, d, complete=True)
|
||||
result = compute_projection(store, _clock())
|
||||
assert not any("full local observation day" in f for f in result.contributing_facts)
|
||||
assert result.headline_remaining_seconds is not None
|
||||
@@ -0,0 +1,77 @@
|
||||
"""The CLI and TUI cross the same terminal-attached action interface."""
|
||||
import argparse
|
||||
import runpy
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
from fenris.control import MONITOR_HELPER, MonitorError, run_monitor
|
||||
from fenris.tui import FenrisTuiApp
|
||||
|
||||
|
||||
@pytest.mark.parametrize("uid", [0, 1000])
|
||||
@pytest.mark.parametrize("args", [("enable", "--now"), ("disable", "--now"), ("collect",)])
|
||||
def test_fixed_helper_and_terminal_attachment(uid, args):
|
||||
with patch("fenris.control.os.geteuid", return_value=uid), \
|
||||
patch("fenris.control.subprocess.run", return_value=subprocess.CompletedProcess([], 0)) as run:
|
||||
run_monitor(*args)
|
||||
expected = [MONITOR_HELPER, *args]
|
||||
if uid:
|
||||
expected.insert(0, "pkexec")
|
||||
# Inherited stdin/out/err keep authentication on the user's terminal.
|
||||
# No frontend deadline can cut short a valid 90-second Collection run.
|
||||
run.assert_called_once_with(expected)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("code", [1, 126, 127, -15])
|
||||
def test_failure_is_not_retried_and_root_equivalent_preserves_arguments(code):
|
||||
args = ("baseline", "set", '{"model": "Drive $(whoami)", "tbw": 100}')
|
||||
with patch("fenris.control.os.geteuid", return_value=1000), \
|
||||
patch("fenris.control.subprocess.run", return_value=subprocess.CompletedProcess([], code)) as run:
|
||||
with pytest.raises(MonitorError) as failure:
|
||||
run_monitor(*args)
|
||||
run.assert_called_once()
|
||||
assert failure.value.exit_code == (code if code > 0 else 128 - code)
|
||||
root_command = str(failure.value).split("run in your terminal: ")[1]
|
||||
assert shlex.split(root_command) == ["sudo", MONITOR_HELPER, *args]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("uid,missing", [(0, MONITOR_HELPER), (1000, "pkexec")])
|
||||
def test_missing_command_is_identified(uid, missing):
|
||||
with patch("fenris.control.os.geteuid", return_value=uid), \
|
||||
patch("fenris.control.subprocess.run", side_effect=FileNotFoundError(2, "Missing", missing)):
|
||||
with pytest.raises(MonitorError, match="Command not found") as failure:
|
||||
run_monitor("collect")
|
||||
assert missing in str(failure.value)
|
||||
assert failure.value.exit_code == 127
|
||||
assert ("sudo" in str(failure.value)) == bool(uid)
|
||||
|
||||
|
||||
def test_interrupt_reports_uncertain_outcome():
|
||||
with patch("fenris.control.subprocess.run", side_effect=KeyboardInterrupt):
|
||||
with pytest.raises(MonitorError, match="Check fenris status") as failure:
|
||||
run_monitor("collect")
|
||||
assert failure.value.exit_code == 130
|
||||
|
||||
|
||||
def test_cli_and_tui_show_the_same_failure(tmp_path, capsys):
|
||||
# The launcher may prepend an installed runtime while loading; isolate it.
|
||||
with patch.object(sys, "path", sys.path.copy()):
|
||||
cli = runpy.run_path(str(Path(__file__).parent.parent / "scripts" / "fenris"))
|
||||
with patch("fenris.control.os.geteuid", return_value=1000), \
|
||||
patch("fenris.control.subprocess.run", return_value=subprocess.CompletedProcess([], 126)):
|
||||
with pytest.raises(SystemExit) as exit_info:
|
||||
cli["cmd_monitor_resume"](argparse.Namespace())
|
||||
assert exit_info.value.code == 126
|
||||
cli_message = capsys.readouterr().err.strip()
|
||||
app = FenrisTuiApp(store_path=tmp_path / "missing.db")
|
||||
with patch.object(app, "suspend"), patch.object(app, "_refresh") as refresh, \
|
||||
patch.object(app, "notify") as notify:
|
||||
app.action_resume()
|
||||
notify.assert_called_once_with(cli_message, severity="error")
|
||||
refresh.assert_called_once()
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Cross-day interval unattributed-byte preservation (issue #88).
|
||||
|
||||
Verifies that a cross-hour interval spanning midnight is stored ONCE as
|
||||
shared boundary evidence, not duplicated into both days.
|
||||
|
||||
Seam: derive._add_unattributed_bytes() → day_aggregates.unattributed_bytes_*
|
||||
"""
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
|
||||
from fenris.collector import run_collection
|
||||
from fenris.store import init_store
|
||||
from fenris.monitoring_periods import ensure_period_open
|
||||
|
||||
|
||||
def _make_smartctl(duw: int, dur: int):
|
||||
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,
|
||||
"data_units_read": dur, "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_tree(tmp_path: Path) -> Path:
|
||||
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
|
||||
|
||||
|
||||
class _Clock:
|
||||
def __init__(self, initial):
|
||||
self.now = initial
|
||||
def utcnow(self):
|
||||
return self.now
|
||||
|
||||
|
||||
class TestCrossDayUnattributedNoDuplication:
|
||||
"""Unattributed bytes from a midnight-spanning interval must be stored
|
||||
once, not duplicated into both days (issue #88)."""
|
||||
|
||||
def test_midnight_spanning_interval_not_duplicated(
|
||||
self, tmp_path, sysfs_tree,
|
||||
):
|
||||
"""Two samples spanning midnight: 23:55 UTC day1 → 00:05 UTC day2.
|
||||
|
||||
The unattributed bytes should appear once, not in both days.
|
||||
"""
|
||||
store = str(tmp_path / "obs.db")
|
||||
cfg = {"device": "/dev/nvme0", "store_path": store}
|
||||
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
|
||||
|
||||
t1 = datetime(2026, 9, 1, 23, 55, 0, tzinfo=timezone.utc)
|
||||
t2 = datetime(2026, 9, 2, 0, 5, 0, tzinfo=timezone.utc)
|
||||
|
||||
# +100 DUW, +60 DUR across midnight
|
||||
r1 = run_collection(_make_smartctl(10000000, 8000000), sysfs_nvme, cfg, _Clock(t1))
|
||||
assert r1["ok"]
|
||||
r2 = run_collection(_make_smartctl(10000100, 8000060), sysfs_nvme, cfg, _Clock(t2))
|
||||
assert r2["ok"]
|
||||
|
||||
conn = sqlite3.connect(store)
|
||||
|
||||
# Get unattributed bytes for each day
|
||||
unattr_w_day1 = conn.execute(
|
||||
"SELECT unattributed_bytes_written FROM day_aggregates WHERE day = ?",
|
||||
("2026-09-01",)
|
||||
).fetchone()
|
||||
unattr_w_day2 = conn.execute(
|
||||
"SELECT unattributed_bytes_written FROM day_aggregates WHERE day = ?",
|
||||
("2026-09-02",)
|
||||
).fetchone()
|
||||
unattr_r_day1 = conn.execute(
|
||||
"SELECT unattributed_bytes_read FROM day_aggregates WHERE day = ?",
|
||||
("2026-09-01",)
|
||||
).fetchone()
|
||||
unattr_r_day2 = conn.execute(
|
||||
"SELECT unattributed_bytes_read FROM day_aggregates WHERE day = ?",
|
||||
("2026-09-02",)
|
||||
).fetchone()
|
||||
|
||||
expected_bw = 100 * 512000 # 51200000
|
||||
expected_br = 60 * 512000 # 30720000
|
||||
|
||||
# BUG: Current code adds the SAME bytes to BOTH days.
|
||||
# After fix: only ONE day should have the unattributed bytes.
|
||||
# The spec says: preserve once as shared boundary evidence.
|
||||
|
||||
# At least one day must have the unattributed bytes
|
||||
total_unattr_w = (unattr_w_day1[0] if unattr_w_day1 else 0) + (unattr_w_day2[0] if unattr_w_day2 else 0)
|
||||
total_unattr_r = (unattr_r_day1[0] if unattr_r_day1 else 0) + (unattr_r_day2[0] if unattr_r_day2 else 0)
|
||||
|
||||
# The total unattributed bytes across both days must equal
|
||||
# the actual delta (not double)
|
||||
assert total_unattr_w == expected_bw, (
|
||||
f"Unattributed writes across both days should be {expected_bw}, "
|
||||
f"got {total_unattr_w} (duplication detected)"
|
||||
)
|
||||
assert total_unattr_r == expected_br, (
|
||||
f"Unattributed reads across both days should be {expected_br}, "
|
||||
f"got {total_unattr_r} (duplication detected)"
|
||||
)
|
||||
|
||||
# Neither day should have MORE than the actual delta
|
||||
for day_label, uw, ur in [
|
||||
("day1", unattr_w_day1, unattr_r_day1),
|
||||
("day2", unattr_w_day2, unattr_r_day2),
|
||||
]:
|
||||
if uw is not None:
|
||||
assert uw[0] <= expected_bw, (
|
||||
f"{day_label} unattributed writes {uw[0]} exceeds delta {expected_bw}"
|
||||
)
|
||||
if ur is not None:
|
||||
assert ur[0] <= expected_br, (
|
||||
f"{day_label} unattributed reads {ur[0]} exceeds delta {expected_br}"
|
||||
)
|
||||
|
||||
conn.close()
|
||||
@@ -283,7 +283,7 @@ class TestSystemdQueryState:
|
||||
def test_query_state_disabled_inactive(self):
|
||||
"""Query state for disabled and inactive timer."""
|
||||
with patch("fenris.init_system._systemctl_show") as mock_show:
|
||||
mock_show.return_value = {}
|
||||
mock_show.return_value = {"UnitFileState": "disabled", "ActiveState": "inactive"}
|
||||
result = _systemd_query_state()
|
||||
assert result["boot_enabled"] is False
|
||||
assert result["timer_active"] is False
|
||||
@@ -688,5 +688,5 @@ class TestEdgeCases:
|
||||
with patch("fenris.init_system._systemctl_show") as mock_show:
|
||||
mock_show.return_value = {}
|
||||
result = _systemd_query_state()
|
||||
assert result["boot_enabled"] is False
|
||||
assert result["timer_active"] is False
|
||||
assert result["boot_enabled"] is None
|
||||
assert result["timer_active"] is None
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
"""Edge-case tests for issue #72 remaining acceptance criteria.
|
||||
|
||||
Covers:
|
||||
- TPH-#34-36: Pause-crossing intervals
|
||||
- TPH-#59: Staleness frozen estimate wording
|
||||
- TPH-#62: Zero-delta evidence in live graph
|
||||
- TPH-#63: Horizon anchoring at evidence endpoint
|
||||
- TPH-#64: Burst/habit checks with unknown daily shares
|
||||
"""
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
|
||||
from fenris.store import init_store
|
||||
from fenris.monitoring_periods import ensure_period_open, close_period
|
||||
from fenris.derive import derive_hours_from_interval
|
||||
from fenris.day_aggregate import derive_day
|
||||
from fenris.projection import compute_projection, ConfidenceState, STALENESS_HOURS
|
||||
from fenris.tui import _query_live_graph_data, _query_daily_graph_data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _clock(year=2026, month=10, day=1, hour=12):
|
||||
return datetime(year, month, day, hour, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _insert_sample(conn, ts, bw=512000000000, br=256000000000, segment_id=1):
|
||||
conn.execute(
|
||||
"INSERT INTO samples (ts, device, data_units_written, data_units_read, "
|
||||
"percentage_used, bytes_written, bytes_read, power_on_hours, segment_id) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(ts, "/dev/nvme0n1", 1000000, 500000, 5, bw, br, 8765, segment_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _insert_segment(conn, opened_at="2026-09-01T00:00:00+00:00",
|
||||
identity_key="nqn.test", degraded=False):
|
||||
conn.execute(
|
||||
"INSERT INTO controller_segments "
|
||||
"(opened_at, identity_key, identity_degraded, subnqn, sn, mn, fr, vid, ssvid, transport) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(opened_at, identity_key, degraded, "nqn.test", "SN123",
|
||||
"Samsung SSD 970 EVO Plus 1TB", "FW1", "0x144d", "0x144d", "pcie"),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _insert_baseline(conn, tbw_tb=1.0, verified=True):
|
||||
conn.execute(
|
||||
"INSERT INTO endurance_baseline "
|
||||
"(tbw_terabytes, source_url, document_revision, entry_date, model_string, "
|
||||
" nominal_capacity_bytes, validated_by, verified, created_at, updated_at) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(tbw_tb, "https://example.com/spec", "v1.0", "2026-01-01",
|
||||
"Samsung SSD 970 EVO Plus 1TB", 1024000000000,
|
||||
"machine_match" if verified else None, verified,
|
||||
"2026-01-01T00:00:00+00:00", "2026-01-01T00:00:00+00:00"),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _insert_day(conn, day, bw=10*1024*1024*1024, br=0, coverage=0.95, samples=24):
|
||||
"""Insert a day aggregate with ~10GB writes (realistic for projection)."""
|
||||
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, 3600, 0, 0, 0, bw, br, samples, coverage),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _insert_local_day(conn, local_date, tz_name="UTC", tz_offset="+00:00",
|
||||
utc_start=None, utc_end=None, bw=10*1024*1024*1024,
|
||||
br=0, coverage=0.95, samples=24, complete=True):
|
||||
"""Insert a local_days row for testing the observation day gate."""
|
||||
if utc_start is None:
|
||||
utc_start = local_date + "T00:00:00+00:00"
|
||||
if utc_end is None:
|
||||
dt = datetime.strptime(local_date, "%Y-%m-%d") + timedelta(days=1)
|
||||
utc_end = dt.strftime("%Y-%m-%dT00:00:00+00:00")
|
||||
conn.execute(
|
||||
"INSERT INTO local_days "
|
||||
"(local_date, tz_name, tz_offset, utc_start, utc_end, "
|
||||
" bytes_written, bytes_read, coverage, sample_count, complete) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(local_date, tz_name, tz_offset, utc_start, utc_end,
|
||||
bw, br, coverage, samples, complete),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _insert_hour(conn, hour_iso, bw=0, br=0, sample_count=1,
|
||||
active=3600, idle=0, powered_off=0, unknown=0):
|
||||
known = active + idle + powered_off
|
||||
coverage = known / 3600.0
|
||||
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, idle, powered_off, unknown, bw, br, sample_count, coverage),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TPH-#34-36: Pause-crossing intervals
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestPauseCrossingIntervals:
|
||||
"""Intervals crossing a pause boundary preserve evidence correctly."""
|
||||
|
||||
def test_interval_crossing_pause_preserves_total(self, tmp_path):
|
||||
"""An interval that spans a pause boundary keeps the full delta
|
||||
in hour observations; the monitoring-period exclusion happens at
|
||||
the day-aggregate coverage level."""
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
_insert_segment(conn)
|
||||
|
||||
t1 = datetime(2026, 10, 1, 10, 0, 0, tzinfo=timezone.utc)
|
||||
t2 = datetime(2026, 10, 1, 11, 0, 0, tzinfo=timezone.utc)
|
||||
t3 = datetime(2026, 10, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
ensure_period_open(conn, t1)
|
||||
_insert_sample(conn, t1.isoformat(), bw=100000000000)
|
||||
_insert_sample(conn, t2.isoformat(), bw=200000000000)
|
||||
|
||||
prev = {"id": 1, "ts": t1.isoformat(), "bytes_written": 100000000000,
|
||||
"bytes_read": 0, "power_on_hours": 8765, "temperature_c": 35,
|
||||
"data_units_written": 1000000, "data_units_read": 0}
|
||||
curr = {"id": 2, "ts": t2.isoformat(), "bytes_written": 200000000000,
|
||||
"bytes_read": 0, "power_on_hours": 8765, "temperature_c": 35,
|
||||
"data_units_written": 2000000, "data_units_read": 0}
|
||||
derive_hours_from_interval(conn, prev, curr)
|
||||
|
||||
close_period(conn, t2, "user_disabled")
|
||||
ensure_period_open(conn, t3)
|
||||
_insert_sample(conn, t3.isoformat(), bw=300000000000)
|
||||
|
||||
prev2 = {"id": 2, "ts": t2.isoformat(), "bytes_written": 200000000000,
|
||||
"bytes_read": 0, "power_on_hours": 8765, "temperature_c": 35,
|
||||
"data_units_written": 2000000, "data_units_read": 0}
|
||||
curr2 = {"id": 3, "ts": t3.isoformat(), "bytes_written": 300000000000,
|
||||
"bytes_read": 0, "power_on_hours": 8765, "temperature_c": 35,
|
||||
"data_units_written": 3000000, "data_units_read": 0}
|
||||
derive_hours_from_interval(conn, prev2, curr2)
|
||||
|
||||
hour11 = conn.execute(
|
||||
"SELECT bytes_written_delta FROM hour_observations WHERE hour = ?",
|
||||
("2026-10-01T11:00:00+00:00",)
|
||||
).fetchone()
|
||||
assert hour11 is not None
|
||||
assert hour11[0] == 100000000000
|
||||
|
||||
agg = derive_day(conn, "2026-10-01")
|
||||
assert agg is not None
|
||||
assert agg.bytes_written_delta == 100000000000
|
||||
|
||||
conn.close()
|
||||
|
||||
def test_deliberate_pause_vs_raw_stop(self, tmp_path):
|
||||
"""Deliberate pause closes period with user_disabled;
|
||||
raw service stop leaves period open (interrupted)."""
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
_insert_segment(conn)
|
||||
|
||||
t1 = datetime(2026, 10, 1, 10, 0, 0, tzinfo=timezone.utc)
|
||||
ensure_period_open(conn, t1)
|
||||
_insert_sample(conn, t1.isoformat())
|
||||
|
||||
close_period(conn, t1 + timedelta(hours=1), "user_disabled")
|
||||
|
||||
period = conn.execute(
|
||||
"SELECT ended_at, end_cause FROM monitoring_periods WHERE ended_at IS NOT NULL"
|
||||
).fetchone()
|
||||
assert period is not None
|
||||
assert period[1] == "user_disabled"
|
||||
|
||||
t2 = datetime(2026, 10, 1, 14, 0, 0, tzinfo=timezone.utc)
|
||||
ensure_period_open(conn, t2)
|
||||
_insert_sample(conn, t2.isoformat())
|
||||
|
||||
open_periods = conn.execute(
|
||||
"SELECT COUNT(*) FROM monitoring_periods WHERE ended_at IS NULL"
|
||||
).fetchone()
|
||||
assert open_periods[0] == 1
|
||||
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TPH-#59: Staleness frozen estimate wording
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestStalenessFact:
|
||||
"""Projection includes staleness fact when evidence is old."""
|
||||
|
||||
def test_staleness_fact_present_when_old(self, tmp_path):
|
||||
"""When newest data is ≥48h old, projection includes staleness fact."""
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
_insert_segment(conn)
|
||||
_insert_baseline(conn)
|
||||
|
||||
base = datetime(2026, 9, 15, 12, 0, 0, tzinfo=timezone.utc)
|
||||
for i in range(14):
|
||||
day = (base + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(conn, day, bw=10*1024*1024*1024)
|
||||
_insert_local_day(conn, day, complete=True)
|
||||
|
||||
clock = datetime(2026, 10, 2, 12, 0, 0, tzinfo=timezone.utc)
|
||||
proj = compute_projection(conn, clock)
|
||||
|
||||
assert proj.staleness_fact is not None
|
||||
assert "48" in proj.staleness_fact or "old" in proj.staleness_fact
|
||||
|
||||
staleness_in_facts = any("old" in f or "48" in f for f in proj.contributing_facts)
|
||||
assert staleness_in_facts
|
||||
|
||||
conn.close()
|
||||
|
||||
def test_no_staleness_fact_when_fresh(self, tmp_path):
|
||||
"""When newest data is recent, no staleness fact."""
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
_insert_segment(conn)
|
||||
_insert_baseline(conn)
|
||||
|
||||
base = datetime(2026, 9, 18, 12, 0, 0, tzinfo=timezone.utc)
|
||||
for i in range(14):
|
||||
day = (base + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(conn, day, bw=10*1024*1024*1024)
|
||||
_insert_local_day(conn, day, complete=True)
|
||||
|
||||
clock = datetime(2026, 10, 2, 12, 0, 0, tzinfo=timezone.utc)
|
||||
proj = compute_projection(conn, clock)
|
||||
|
||||
assert proj.staleness_fact is None
|
||||
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TPH-#62: Zero-delta evidence in live graph
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestZeroDeltaEvidence:
|
||||
"""Zero-delta intervals are distinguished from missing evidence."""
|
||||
|
||||
def test_zero_delta_shown_as_zero(self, tmp_path):
|
||||
"""Two identical readings produce a zero-delta interval."""
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
now = _clock()
|
||||
|
||||
_insert_sample(conn, (now - timedelta(minutes=6)).isoformat(), bw=100000000000)
|
||||
_insert_sample(conn, (now - timedelta(minutes=3)).isoformat(), bw=100000000000)
|
||||
|
||||
data = _query_live_graph_data(conn, now)
|
||||
assert len(data) == 1
|
||||
assert data[0]["bytes_written"] == 0
|
||||
assert data[0]["is_zero"] is True
|
||||
assert data[0]["is_gap"] is False
|
||||
|
||||
conn.close()
|
||||
|
||||
def test_gap_distinguished_from_zero(self, tmp_path):
|
||||
"""A gap (missed collection) with non-zero delta is distinct from measured zero."""
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
now = _clock()
|
||||
|
||||
_insert_sample(conn, (now - timedelta(hours=2)).isoformat(), bw=100000000000)
|
||||
_insert_sample(conn, (now - timedelta(minutes=3)).isoformat(), bw=200000000000)
|
||||
|
||||
data = _query_live_graph_data(conn, now)
|
||||
assert len(data) == 1
|
||||
assert data[0]["is_gap"] is True
|
||||
assert data[0]["is_zero"] is False
|
||||
assert data[0]["bytes_written"] == 100000000000
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TPH-#63: Horizon anchoring at evidence endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestHorizonAnchoring:
|
||||
"""Scenario windows end at the latest evidence endpoint T."""
|
||||
|
||||
def test_horizon_rate_uses_evidence_endpoint(self, tmp_path):
|
||||
"""Horizon rates are computed from the latest evidence endpoint,
|
||||
not from clock_now."""
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
_insert_segment(conn)
|
||||
_insert_baseline(conn)
|
||||
ensure_period_open(conn, datetime(2026, 9, 10, 0, 0, 0, tzinfo=timezone.utc))
|
||||
|
||||
# Insert 28 days of data ending 5 days ago with realistic writes
|
||||
base = datetime(2026, 9, 10, 12, 0, 0, tzinfo=timezone.utc)
|
||||
for i in range(28):
|
||||
day = (base + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(conn, day, bw=10*1024*1024*1024) # ~10GB/day
|
||||
_insert_local_day(conn, day, complete=True)
|
||||
|
||||
clock = datetime(2026, 10, 13, 12, 0, 0, tzinfo=timezone.utc)
|
||||
proj = compute_projection(conn, clock)
|
||||
|
||||
# Scenario range should exist with rates
|
||||
assert proj.scenario_range is not None
|
||||
assert len(proj.scenario_range.rates) > 0
|
||||
|
||||
for horizon in [7, 28]:
|
||||
if horizon in proj.scenario_range.rates:
|
||||
rate = proj.scenario_range.rates[horizon]
|
||||
assert rate > 0, f"Horizon {horizon} should have positive rate"
|
||||
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TPH-#64: Burst/habit checks with unknown daily shares
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBurstChecks:
|
||||
"""Burst and habit checks handle unknown daily shares correctly."""
|
||||
|
||||
def test_habit_change_detected(self, tmp_path):
|
||||
"""Habit change detection works with clear write rate change."""
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
_insert_segment(conn, opened_at="2026-08-01T00:00:00+00:00")
|
||||
_insert_baseline(conn, tbw_tb=10.0)
|
||||
ensure_period_open(conn, datetime(2026, 8, 1, 0, 0, 0, tzinfo=timezone.utc))
|
||||
|
||||
# 28 days of normal usage (100 MiB/day)
|
||||
bw_normal = 100 * 1024 * 1024
|
||||
for i in range(28):
|
||||
day = (datetime(2026, 8, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(conn, day, bw=bw_normal)
|
||||
_insert_local_day(conn, day, complete=True, bw=bw_normal)
|
||||
|
||||
# 10 days of high usage (300 MiB/day = 3x, triggers habit change)
|
||||
bw_high = 300 * 1024 * 1024
|
||||
for i in range(10):
|
||||
day = (datetime(2026, 8, 29) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(conn, day, bw=bw_high)
|
||||
_insert_local_day(conn, day, complete=True, bw=bw_high)
|
||||
|
||||
clock = datetime(2026, 9, 8, 12, 0, 0, tzinfo=timezone.utc)
|
||||
proj = compute_projection(conn, clock)
|
||||
|
||||
# Should have a habit change fact (3× increase for 10 days)
|
||||
assert proj.habit_change_fact is not None
|
||||
assert "usage habit changed" in proj.habit_change_fact
|
||||
|
||||
conn.close()
|
||||
|
||||
def test_zero_delta_not_treated_as_unknown(self, tmp_path):
|
||||
"""A measured zero-delta interval is not treated as missing evidence."""
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
now = _clock()
|
||||
|
||||
_insert_sample(conn, (now - timedelta(minutes=6)).isoformat(), bw=100000000000)
|
||||
_insert_sample(conn, (now - timedelta(minutes=3)).isoformat(), bw=100000000000)
|
||||
|
||||
data = _query_live_graph_data(conn, now)
|
||||
assert len(data) == 1
|
||||
assert data[0]["is_zero"] is True
|
||||
assert data[0]["is_gap"] is False
|
||||
assert data[0]["bytes_written"] == 0
|
||||
|
||||
conn.close()
|
||||
@@ -86,6 +86,33 @@ def _open_period(conn, start="2026-09-01T00:00:00+00:00"):
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _insert_local_day(conn, local_date, tz_name="UTC", tz_offset="+00:00",
|
||||
utc_start=None, utc_end=None, bw=1024*1024*100,
|
||||
br=0, coverage=0.95, samples=24, complete=True):
|
||||
"""Insert a local_days row (issue #94 gate prerequisite)."""
|
||||
if utc_start is None:
|
||||
utc_start = local_date + "T00:00:00+00:00"
|
||||
if utc_end is None:
|
||||
dt = datetime.strptime(local_date, "%Y-%m-%d") + timedelta(days=1)
|
||||
utc_end = dt.strftime("%Y-%m-%dT00:00:00+00:00")
|
||||
conn.execute(
|
||||
"INSERT INTO local_days "
|
||||
"(local_date, tz_name, tz_offset, utc_start, utc_end, "
|
||||
" bytes_written, bytes_read, coverage, sample_count, complete) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(local_date, tz_name, tz_offset, utc_start, utc_end,
|
||||
bw, br, coverage, samples, complete),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _insert_complete_local_days(conn, start_date, count, bw=1024*1024*100):
|
||||
"""Insert multiple complete local days to satisfy the issue #94 gate."""
|
||||
for i in range(count):
|
||||
d = (datetime.strptime(start_date, "%Y-%m-%d") + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_local_day(conn, d, bw=bw)
|
||||
|
||||
|
||||
class TestHonestQualifyingProgress:
|
||||
"""Issue #77: Show honest qualifying-day progress and confidence."""
|
||||
|
||||
@@ -103,6 +130,7 @@ class TestHonestQualifyingProgress:
|
||||
_insert_day(store, d, bw=bw, coverage=cov)
|
||||
|
||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||
result = compute_projection(store, _clock())
|
||||
|
||||
# After warming, qualifying_days_progress shows honest count
|
||||
@@ -125,6 +153,7 @@ class TestHonestQualifyingProgress:
|
||||
_insert_day(store, d, bw=bw, samples=samples)
|
||||
|
||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||
result = compute_projection(store, _clock())
|
||||
|
||||
# After warming, qualifying_days_progress shows honest count
|
||||
@@ -146,6 +175,7 @@ class TestHonestQualifyingProgress:
|
||||
_insert_day(store, d, bw=bw, coverage=0.95)
|
||||
|
||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||
result = compute_projection(store, _clock())
|
||||
|
||||
# Should be warming
|
||||
@@ -164,6 +194,7 @@ class TestHonestQualifyingProgress:
|
||||
_insert_day(store, d, bw=0, coverage=0.95)
|
||||
|
||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||
result = compute_projection(store, _clock())
|
||||
|
||||
# Zero rate should be UNSUPPORTED
|
||||
@@ -185,6 +216,7 @@ class TestHonestQualifyingProgress:
|
||||
_insert_day(store, d, bw=bw, coverage=cov)
|
||||
|
||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||
result = compute_projection(store, _clock())
|
||||
|
||||
# Should still be warming (3 days below coverage > WARMING_MAX_LOW_COVERAGE=2)
|
||||
@@ -205,6 +237,7 @@ class TestHonestQualifyingProgress:
|
||||
_insert_day(store, d, bw=bw, samples=samples)
|
||||
|
||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||
result = compute_projection(store, _clock())
|
||||
|
||||
# Should still be warming
|
||||
@@ -225,6 +258,7 @@ class TestHonestQualifyingProgress:
|
||||
_insert_day(store, d, bw=bw, coverage=cov)
|
||||
|
||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||
result = compute_projection(store, _clock())
|
||||
|
||||
# Should not be warming (14 total, 2 below coverage <= WARMING_MAX_LOW_COVERAGE)
|
||||
@@ -247,6 +281,7 @@ class TestHonestQualifyingProgress:
|
||||
_insert_day(store, d, bw=bw, coverage=0.95)
|
||||
|
||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||
result = compute_projection(store, _clock())
|
||||
|
||||
# Should not be warming
|
||||
|
||||
@@ -260,7 +260,7 @@ class TestPreservedBehavior:
|
||||
conn.close()
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||
|
||||
with patch("fenris.tui.query_service_state", return_value={
|
||||
with patch("fenris.status.query_service_state", return_value={
|
||||
"boot_enabled": True, "timer_active": True,
|
||||
"last_collect_ok": True, "last_collect_age_s": 60,
|
||||
"last_collect_reason": None,
|
||||
@@ -272,11 +272,11 @@ class TestPreservedBehavior:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_quit_rail_preserved(self, tmp_path):
|
||||
"""Separate q QUIT TUI rail preserved."""
|
||||
"""Separate q Quit TUI rail preserved."""
|
||||
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||
async with app.run_test(size=(120, 24)) as pilot:
|
||||
rail = str(app.query_one("#quit-rail").render())
|
||||
assert "q QUIT TUI" in rail
|
||||
assert "q Quit TUI" in rail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_banner_preserved(self, tmp_path):
|
||||
@@ -307,7 +307,7 @@ class TestPreservedBehavior:
|
||||
conn.close()
|
||||
app = FenrisTuiApp(store_path=db)
|
||||
|
||||
with patch("fenris.tui.query_service_state", return_value={
|
||||
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,
|
||||
|
||||
+18
-11
@@ -171,8 +171,8 @@ class TestKeyBindings:
|
||||
assert "m" in binding_keys
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_t_cycles_through_themes(self, tmp_path):
|
||||
"""Pressing t cycles through the three presets."""
|
||||
async def test_t_goes_to_today(self, tmp_path):
|
||||
"""Pressing t returns to today's live view (issue #91)."""
|
||||
from fenris.tui import FenrisTuiApp
|
||||
|
||||
config_home = _make_prefs_dir(tmp_path)
|
||||
@@ -182,12 +182,17 @@ class TestKeyBindings:
|
||||
# Start at amber
|
||||
assert app.theme == "fenris-amber"
|
||||
|
||||
# Press t to cycle
|
||||
# Enter drill mode on the graph
|
||||
graph = app.query_one("#usage-history")
|
||||
graph.view_mode = "hourly"
|
||||
graph.drill_day = "2026-09-30"
|
||||
|
||||
# Press t to go back to today
|
||||
await pilot.press("t")
|
||||
await pilot.pause()
|
||||
|
||||
# Should be nord or high_contrast now
|
||||
assert app.theme in ("fenris-nord", "fenris-high-contrast")
|
||||
# Should be back in daily mode
|
||||
assert graph.view_mode == "daily"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_m_toggles_reduced_motion(self, tmp_path):
|
||||
@@ -226,21 +231,23 @@ class TestPersistence:
|
||||
async def test_theme_survives_restart(self, tmp_path):
|
||||
"""Theme preference persists across TUI restart."""
|
||||
from fenris.tui import FenrisTuiApp
|
||||
from fenris.preferences import save_preferences
|
||||
|
||||
config_home = _make_prefs_dir(tmp_path)
|
||||
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||
# First run: change theme
|
||||
# First run: change theme via preferences API
|
||||
app1 = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||
async with app1.run_test() as pilot:
|
||||
await pilot.press("t")
|
||||
await pilot.pause()
|
||||
theme_after_t = app1.theme
|
||||
assert theme_after_t != "fenris-amber"
|
||||
save_preferences(theme="nord", reduced_motion=False)
|
||||
app1._current_theme_name = "nord"
|
||||
app1.theme = "fenris-nord"
|
||||
theme_after = app1.theme
|
||||
assert theme_after == "fenris-nord"
|
||||
|
||||
# Second run: theme should persist
|
||||
app2 = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||
async with app2.run_test() as pilot:
|
||||
assert app2.theme == theme_after_t
|
||||
assert app2.theme == theme_after
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reduced_motion_survives_restart(self, tmp_path):
|
||||
|
||||
@@ -0,0 +1,592 @@
|
||||
"""Tests for issue #92: Browse activity dates from the keyboard.
|
||||
|
||||
Covers:
|
||||
- AC92-1: [ and ] select previous/next day, g opens date entry, t returns to today/live
|
||||
- AC92-2: Date entry accepts valid dates, reports errors, returns on cancel
|
||||
- AC92-3: Selecting a date obtains correct local-day evidence
|
||||
- AC92-4: Historical date, measurement, and context remain stable across refresh
|
||||
- AC92-5: t resolves to today/live, themes through discoverable control
|
||||
- AC92-6: Drill-down shows available data with distinguishable states
|
||||
- AC92-7: Keyboard actions verified from normal launch with headless driver
|
||||
- AC92-8: Equivalent clickable actions at 80x24 and constrained widths
|
||||
"""
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
|
||||
from fenris.store import init_store
|
||||
from fenris.monitoring_periods import ensure_period_open
|
||||
from fenris.local_day import (
|
||||
LocalDaySummary,
|
||||
persist_local_day,
|
||||
query_local_day_summary,
|
||||
)
|
||||
from fenris.tui import (
|
||||
FenrisTuiApp,
|
||||
DailyBarGraph,
|
||||
_RANGE_OPTIONS,
|
||||
_RANGE_DEFAULT,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _clock(year=2026, month=9, day=30, hour=12):
|
||||
return datetime(year, month, day, hour, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _insert_segment(conn, opened_at="2026-09-01T00:00:00+00:00",
|
||||
identity_key="nqn.test"):
|
||||
conn.execute(
|
||||
"INSERT INTO controller_segments "
|
||||
"(opened_at, identity_key, identity_degraded, subnqn, sn, mn, fr, vid, ssvid, transport) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(opened_at, identity_key, False, "nqn.test", "SN123",
|
||||
"Samsung SSD 970 EVO Plus 1TB", "FW1", "0x144d", "0x144d", "pcie"),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _insert_day(conn, day, bw=1024*1024*100, coverage=0.95, samples=24):
|
||||
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, 3600, 0, 0, 0, bw, 0, samples, coverage),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _open_period(conn, start="2026-09-01T00:00:00+00:00"):
|
||||
ensure_period_open(conn, datetime.fromisoformat(start))
|
||||
|
||||
|
||||
def _insert_local_day(conn, local_date, tz_name="UTC", tz_offset="+00:00",
|
||||
bw=300, br=130, complete=True):
|
||||
summary = LocalDaySummary(
|
||||
local_date=local_date,
|
||||
tz_name=tz_name,
|
||||
tz_offset=tz_offset,
|
||||
utc_start=f"{local_date}T00:00:00+00:00",
|
||||
utc_end=f"{(datetime.fromisoformat(local_date) + timedelta(days=1)).strftime('%Y-%m-%d')}T00:00:00+00:00",
|
||||
bytes_written=bw,
|
||||
bytes_read=br,
|
||||
coverage=0.95,
|
||||
sample_count=24,
|
||||
complete=complete,
|
||||
)
|
||||
persist_local_day(conn, summary)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _setup_store(tmp_path, n_days=30, today=None):
|
||||
"""Create a store with n_days of data and return (conn, clock)."""
|
||||
if today is None:
|
||||
today = _clock()
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
_insert_segment(conn)
|
||||
_open_period(conn)
|
||||
for i in range(n_days):
|
||||
d = (today - timedelta(days=n_days - 1 - i)).strftime("%Y-%m-%d")
|
||||
_insert_day(conn, d)
|
||||
return conn, today
|
||||
|
||||
|
||||
def _make_app(tmp_path, clock=None):
|
||||
"""Create a FenrisTuiApp with the clock mocked to *clock*.
|
||||
|
||||
Returns (app, patcher) - caller must keep patcher alive during the test.
|
||||
"""
|
||||
if clock is None:
|
||||
clock = _clock()
|
||||
from unittest.mock import patch
|
||||
import fenris.tui as tui_mod
|
||||
from datetime import datetime as _real_datetime
|
||||
|
||||
mock_dt = type("MockDatetime", (), {
|
||||
"now": staticmethod(lambda tz=None: clock),
|
||||
"strptime": staticmethod(lambda *a, **kw: _real_datetime.strptime(*a, **kw)),
|
||||
"date": staticmethod(lambda: clock.date()),
|
||||
})()
|
||||
|
||||
patcher = patch.object(tui_mod, "datetime", mock_dt)
|
||||
patcher.start()
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
||||
return app, patcher
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC92-1: Keyboard bindings exist
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBindingExistence:
|
||||
def test_open_square_bracket_binding(self, tmp_path):
|
||||
"""The [ binding exists for previous day navigation."""
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||
binding_keys = {b.key for b in app.BINDINGS}
|
||||
assert "left_square_bracket" in binding_keys
|
||||
|
||||
def test_close_square_bracket_binding(self, tmp_path):
|
||||
"""The ] binding exists for next day navigation."""
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||
binding_keys = {b.key for b in app.BINDINGS}
|
||||
assert "right_square_bracket" in binding_keys
|
||||
|
||||
def test_g_binding_exists(self, tmp_path):
|
||||
"""The g binding exists for date entry."""
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||
binding_keys = {b.key for b in app.BINDINGS}
|
||||
assert "g" in binding_keys
|
||||
|
||||
def test_t_binding_is_today(self, tmp_path):
|
||||
"""The t binding maps to action_today, not a theme action."""
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||
t_binding = next(b for b in app.BINDINGS if b.key == "t")
|
||||
assert "today" in t_binding.action.lower()
|
||||
|
||||
def test_pause_resume_quit_bindings_exist(self, tmp_path):
|
||||
"""Existing bindings are preserved."""
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||
binding_keys = {b.key for b in app.BINDINGS}
|
||||
assert "p" in binding_keys # pause
|
||||
assert "r" in binding_keys # resume
|
||||
assert "q" in binding_keys # quit
|
||||
assert "d" in binding_keys # disclosures
|
||||
assert "m" in binding_keys # motion toggle
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC92-1: Bracket navigation from normal launch
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBracketNavigation:
|
||||
@pytest.mark.asyncio
|
||||
async def test_right_bracket_advances_day(self, tmp_path):
|
||||
"""The ] key moves to the next (newer) day when not at the end."""
|
||||
conn, clock = _setup_store(tmp_path, n_days=14)
|
||||
conn.close()
|
||||
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
||||
async with app.run_test(size=(100, 40)) as pilot:
|
||||
await pilot.pause()
|
||||
graph = app.query_one("#usage-history")
|
||||
# Move to an older day first so ] can advance
|
||||
for _ in range(3):
|
||||
await pilot.press("left_square_bracket")
|
||||
day_before = graph._day_data[graph.selected_index].get("day")
|
||||
|
||||
await pilot.press("right_square_bracket")
|
||||
day_after = graph._day_data[graph.selected_index].get("day")
|
||||
# ] should move to a newer day (closer to today)
|
||||
assert day_after > day_before
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_left_bracket_goes_to_previous_day(self, tmp_path):
|
||||
"""The [ key moves to the previous (older) day."""
|
||||
conn, clock = _setup_store(tmp_path, n_days=14)
|
||||
conn.close()
|
||||
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
||||
async with app.run_test(size=(100, 40)) as pilot:
|
||||
await pilot.pause()
|
||||
graph = app.query_one("#usage-history")
|
||||
day_before = graph._day_data[graph.selected_index].get("day")
|
||||
|
||||
await pilot.press("left_square_bracket")
|
||||
day_after = graph._day_data[graph.selected_index].get("day")
|
||||
# [ should move to an older day (further from today)
|
||||
assert day_after < day_before
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bracket_navigates_outside_visible_range(self, tmp_path):
|
||||
"""[ beyond the current range shifts the visible window."""
|
||||
app_clock = _clock()
|
||||
conn, clock = _setup_store(tmp_path, n_days=30, today=app_clock)
|
||||
conn.close()
|
||||
|
||||
app, patcher = _make_app(tmp_path, clock=app_clock)
|
||||
try:
|
||||
async with app.run_test(size=(100, 40)) as pilot:
|
||||
await pilot.pause()
|
||||
graph = app.query_one("#usage-history")
|
||||
assert graph.range_days == _RANGE_DEFAULT
|
||||
|
||||
# Navigate to oldest day by pressing [ many times
|
||||
for _ in range(29):
|
||||
await pilot.press("left_square_bracket")
|
||||
|
||||
# Graph should now show a range that includes the oldest day
|
||||
assert graph.selected_index >= 0
|
||||
selected_day = graph._day_data[graph.selected_index].get("day")
|
||||
oldest_day = (app_clock - timedelta(days=29)).strftime("%Y-%m-%d")
|
||||
assert selected_day == oldest_day
|
||||
finally:
|
||||
patcher.stop()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bracket_at_boundary_does_not_move(self, tmp_path):
|
||||
"""] at the last day does not move the selection."""
|
||||
conn, clock = _setup_store(tmp_path, n_days=14)
|
||||
conn.close()
|
||||
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
||||
async with app.run_test(size=(100, 40)) as pilot:
|
||||
await pilot.pause()
|
||||
graph = app.query_one("#usage-history")
|
||||
initial_idx = graph.selected_index
|
||||
|
||||
await pilot.press("right_square_bracket")
|
||||
assert graph.selected_index == initial_idx
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC92-1: Arrow keys inspect the selected view
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestArrowInspection:
|
||||
@pytest.mark.asyncio
|
||||
async def test_arrow_keys_still_work_in_daily_view(self, tmp_path):
|
||||
"""Arrow keys move the selection within the visible daily view."""
|
||||
conn, clock = _setup_store(tmp_path, n_days=14)
|
||||
conn.close()
|
||||
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
||||
async with app.run_test(size=(100, 40)) as pilot:
|
||||
await pilot.pause()
|
||||
graph = app.query_one("#usage-history")
|
||||
# Focus the graph widget explicitly
|
||||
graph.focus()
|
||||
await pilot.pause()
|
||||
day_before = graph._day_data[graph.selected_index].get("day")
|
||||
|
||||
await pilot.press("left")
|
||||
day_after = graph._day_data[graph.selected_index].get("day")
|
||||
assert day_after != day_before
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_arrow_keys_inspect_hours_in_drill(self, tmp_path):
|
||||
"""Arrow keys navigate hours when in hourly drill-down."""
|
||||
conn, clock = _setup_store(tmp_path, n_days=14)
|
||||
conn.close()
|
||||
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
||||
async with app.run_test(size=(100, 40)) as pilot:
|
||||
await pilot.pause()
|
||||
graph = app.query_one("#usage-history")
|
||||
await pilot.click("#usage-history")
|
||||
|
||||
# Enter drill-down
|
||||
await pilot.press("enter")
|
||||
assert graph.view_mode == "hourly"
|
||||
|
||||
# Arrow keys navigate hours
|
||||
await pilot.press("left")
|
||||
assert graph._hourly_selected >= 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC92-1: t returns to today/live
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTodayBinding:
|
||||
@pytest.mark.asyncio
|
||||
async def test_t_returns_to_today_after_browsing(self, tmp_path):
|
||||
"""The t key returns to today's live view after browsing."""
|
||||
conn, clock = _setup_store(tmp_path, n_days=14)
|
||||
conn.close()
|
||||
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
||||
async with app.run_test(size=(100, 40)) as pilot:
|
||||
await pilot.pause()
|
||||
graph = app.query_one("#usage-history")
|
||||
|
||||
# Browse to an older day using [
|
||||
for _ in range(5):
|
||||
await pilot.press("left_square_bracket")
|
||||
browsed_day = graph._day_data[graph.selected_index].get("day")
|
||||
today = clock.strftime("%Y-%m-%d")
|
||||
assert browsed_day != today
|
||||
|
||||
# Press t to return to today
|
||||
await pilot.press("t")
|
||||
assert graph.selected_index == len(graph._day_data) - 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_t_exits_hourly_drill(self, tmp_path):
|
||||
"""The t key exits hourly drill-down back to daily view."""
|
||||
conn, clock = _setup_store(tmp_path, n_days=14)
|
||||
conn.close()
|
||||
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
||||
async with app.run_test(size=(100, 40)) as pilot:
|
||||
await pilot.pause()
|
||||
graph = app.query_one("#usage-history")
|
||||
await pilot.click("#usage-history")
|
||||
|
||||
# Enter drill
|
||||
await pilot.press("enter")
|
||||
assert graph.view_mode == "hourly"
|
||||
|
||||
# t returns to daily
|
||||
await pilot.press("t")
|
||||
assert graph.view_mode == "daily"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC92-2: Date entry screen
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDateEntry:
|
||||
@pytest.mark.asyncio
|
||||
async def test_g_opens_date_entry(self, tmp_path):
|
||||
"""The g key opens the date entry screen."""
|
||||
conn, clock = _setup_store(tmp_path, n_days=14)
|
||||
conn.close()
|
||||
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
||||
async with app.run_test(size=(100, 40)) as pilot:
|
||||
await pilot.pause()
|
||||
await pilot.press("g")
|
||||
await pilot.pause()
|
||||
# Date picker should be pushed as a screen
|
||||
assert len(app.screen_stack) > 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_valid_date_navigates(self, tmp_path):
|
||||
"""Entering a valid date navigates to that day."""
|
||||
app_clock = _clock()
|
||||
conn, clock = _setup_store(tmp_path, n_days=30, today=app_clock)
|
||||
conn.close()
|
||||
|
||||
app, patcher = _make_app(tmp_path, clock=app_clock)
|
||||
try:
|
||||
async with app.run_test(size=(100, 40)) as pilot:
|
||||
await pilot.pause()
|
||||
target_date = (app_clock - timedelta(days=5)).strftime("%Y-%m-%d")
|
||||
|
||||
# Simulate the date picker callback directly
|
||||
app._go_to_date_callback(target_date)
|
||||
await pilot.pause()
|
||||
|
||||
graph = app.query_one("#usage-history")
|
||||
selected_day = graph._day_data[graph.selected_index].get("day")
|
||||
assert selected_day == target_date
|
||||
finally:
|
||||
patcher.stop()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_date_shows_error(self, tmp_path):
|
||||
"""Entering an invalid date shows an error message."""
|
||||
conn, clock = _setup_store(tmp_path, n_days=14)
|
||||
conn.close()
|
||||
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
||||
async with app.run_test(size=(100, 40)) as pilot:
|
||||
await pilot.pause()
|
||||
await pilot.press("g")
|
||||
await pilot.pause()
|
||||
await pilot.press("n", "o", "t", " ", "a", " ", "d", "a", "t", "e")
|
||||
await pilot.press("enter")
|
||||
await pilot.pause()
|
||||
# Should remain on date picker (error shown, not dismissed)
|
||||
assert len(app.screen_stack) > 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_returns_to_prior(self, tmp_path):
|
||||
"""Pressing escape in date entry returns to the prior view."""
|
||||
conn, clock = _setup_store(tmp_path, n_days=14)
|
||||
conn.close()
|
||||
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
||||
async with app.run_test(size=(100, 40)) as pilot:
|
||||
await pilot.pause()
|
||||
graph = app.query_one("#usage-history")
|
||||
prior_idx = graph.selected_index
|
||||
|
||||
await pilot.press("g")
|
||||
await pilot.pause()
|
||||
await pilot.press("escape")
|
||||
await pilot.pause()
|
||||
|
||||
# Should be back to the graph with the same selection
|
||||
assert graph.selected_index == prior_idx
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC92-3: Selecting a date obtains correct local-day evidence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLocalDayEvidence:
|
||||
@pytest.mark.asyncio
|
||||
async def test_bracket_shows_local_day_for_selected_date(self, tmp_path):
|
||||
"""Navigating with brackets shows the local-day summary for that date."""
|
||||
app_clock = _clock()
|
||||
conn, clock = _setup_store(tmp_path, n_days=14, today=app_clock)
|
||||
# Insert local-day data for a day within the 14-day range
|
||||
target = (app_clock - timedelta(days=4)).strftime("%Y-%m-%d")
|
||||
_insert_local_day(conn, target, bw=500, br=200)
|
||||
conn.close()
|
||||
|
||||
app, patcher = _make_app(tmp_path, clock=app_clock)
|
||||
try:
|
||||
async with app.run_test(size=(100, 40)) as pilot:
|
||||
await pilot.pause()
|
||||
graph = app.query_one("#usage-history")
|
||||
|
||||
# Navigate to the target day
|
||||
for _ in range(4):
|
||||
await pilot.press("left_square_bracket")
|
||||
await pilot.pause()
|
||||
|
||||
local_day = app.query_one("#local-day")
|
||||
text = str(local_day.render())
|
||||
assert target in text
|
||||
finally:
|
||||
patcher.stop()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_date_picker_shows_local_day(self, tmp_path):
|
||||
"""Picking a date via the date picker shows its local-day summary."""
|
||||
conn, clock = _setup_store(tmp_path, n_days=14)
|
||||
target = (clock - timedelta(days=10)).strftime("%Y-%m-%d")
|
||||
_insert_local_day(conn, target, bw=100, br=50)
|
||||
conn.close()
|
||||
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
||||
async with app.run_test(size=(100, 40)) as pilot:
|
||||
await pilot.pause()
|
||||
# Simulate the date picker callback directly
|
||||
app._go_to_date_callback(target)
|
||||
await pilot.pause()
|
||||
|
||||
local_day = app.query_one("#local-day")
|
||||
text = str(local_day.render())
|
||||
assert target in text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC92-4: Browsing stability across background refresh
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBrowsingStability:
|
||||
@pytest.mark.asyncio
|
||||
async def test_selection_stable_across_refresh(self, tmp_path):
|
||||
"""Browsing selection is preserved when background refresh fires."""
|
||||
conn, clock = _setup_store(tmp_path, n_days=14)
|
||||
conn.close()
|
||||
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=0.1)
|
||||
async with app.run_test(size=(100, 40)) as pilot:
|
||||
await pilot.pause()
|
||||
graph = app.query_one("#usage-history")
|
||||
|
||||
# Browse to a specific day
|
||||
for _ in range(3):
|
||||
await pilot.press("right_square_bracket")
|
||||
browsed_day = graph._day_data[graph.selected_index].get("day")
|
||||
|
||||
# Wait for at least one refresh tick
|
||||
import asyncio
|
||||
await asyncio.sleep(0.3)
|
||||
await pilot.pause()
|
||||
|
||||
# Selection should be preserved
|
||||
assert graph.selected_index >= 0
|
||||
assert graph._day_data[graph.selected_index].get("day") == browsed_day
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_navigate_away_does_not_jump_on_refresh(self, tmp_path):
|
||||
"""After navigating away from today, refresh does not jump back."""
|
||||
conn, clock = _setup_store(tmp_path, n_days=14)
|
||||
conn.close()
|
||||
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=0.1)
|
||||
async with app.run_test(size=(100, 40)) as pilot:
|
||||
await pilot.pause()
|
||||
graph = app.query_one("#usage-history")
|
||||
|
||||
# Browse to older day using [
|
||||
for _ in range(5):
|
||||
await pilot.press("left_square_bracket")
|
||||
|
||||
browsed_day = graph._day_data[graph.selected_index].get("day")
|
||||
today = clock.strftime("%Y-%m-%d")
|
||||
assert browsed_day != today
|
||||
|
||||
# Wait for refresh
|
||||
import asyncio
|
||||
await asyncio.sleep(0.3)
|
||||
await pilot.pause()
|
||||
|
||||
# Should NOT jump back to today
|
||||
selected_day = graph._day_data[graph.selected_index].get("day")
|
||||
assert selected_day != today
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC92-6: Drill-down shows available data
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDrillDownData:
|
||||
@pytest.mark.asyncio
|
||||
async def test_drill_down_shows_loading_then_data(self, tmp_path):
|
||||
"""Drill-down transitions from loading to actual data."""
|
||||
conn, clock = _setup_store(tmp_path, n_days=14)
|
||||
conn.close()
|
||||
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
||||
async with app.run_test(size=(100, 40)) as pilot:
|
||||
await pilot.pause()
|
||||
graph = app.query_one("#usage-history")
|
||||
await pilot.click("#usage-history")
|
||||
|
||||
# Enter drill-down
|
||||
await pilot.press("enter")
|
||||
assert graph.view_mode == "hourly"
|
||||
|
||||
# After drill callback, data should be populated
|
||||
# (empty since we have no hour_observations)
|
||||
assert graph.view_mode == "hourly"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC92-8: Constrained width behavior
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestConstrainedWidth:
|
||||
@pytest.mark.asyncio
|
||||
async def test_bracket_navigation_works_at_80x24(self, tmp_path):
|
||||
"""Date navigation works at minimum 80x24 terminal size."""
|
||||
conn, clock = _setup_store(tmp_path, n_days=14)
|
||||
conn.close()
|
||||
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
||||
async with app.run_test(size=(80, 24)) as pilot:
|
||||
await pilot.pause()
|
||||
graph = app.query_one("#usage-history")
|
||||
day_before = graph._day_data[graph.selected_index].get("day")
|
||||
|
||||
await pilot.press("left_square_bracket")
|
||||
day_after = graph._day_data[graph.selected_index].get("day")
|
||||
assert day_after < day_before
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_g_opens_date_entry_at_80x24(self, tmp_path):
|
||||
"""Date entry opens at minimum terminal size."""
|
||||
conn, clock = _setup_store(tmp_path, n_days=14)
|
||||
conn.close()
|
||||
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
||||
async with app.run_test(size=(80, 24)) as pilot:
|
||||
await pilot.pause()
|
||||
await pilot.press("g")
|
||||
await pilot.pause()
|
||||
assert len(app.screen_stack) > 1
|
||||
@@ -0,0 +1,606 @@
|
||||
"""Lifecycle tests for local-day history after detail expires (issue #93).
|
||||
|
||||
Verifies the full lifecycle: raw samples collected, local-day summaries
|
||||
persisted, raw samples pruned after 14 days, and aged local-day summaries
|
||||
remain queryable with correct timezone, boundaries, and evidence-limit
|
||||
metadata.
|
||||
|
||||
Seams:
|
||||
- Write side: collector.run_collection() → local_day derivation → persist
|
||||
- Pruning: prune_old_samples() removes raw samples but preserves local_days
|
||||
- Read side: query_local_day_history() returns entries with evidence flags
|
||||
- Repair: repair_derivation() does not touch existing local_days
|
||||
"""
|
||||
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.store import init_store, SCHEMA_VERSION
|
||||
from fenris.local_day import (
|
||||
derive_local_day_summary,
|
||||
persist_local_day,
|
||||
query_local_day_summary,
|
||||
query_local_day_history,
|
||||
LocalDaySummary,
|
||||
LocalDayHistoryEntry,
|
||||
_is_detail_available,
|
||||
)
|
||||
from fenris.pruning import prune_old_samples, RAW_SAMPLE_RETENTION_DAYS
|
||||
from fenris.repair import repair_derivation
|
||||
from fenris.monitoring_periods import ensure_period_open, close_period
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_smartctl(duw: int, dur: 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,
|
||||
"data_units_read": dur, "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_tree(tmp_path: Path) -> Path:
|
||||
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
|
||||
|
||||
|
||||
class _Clock:
|
||||
def __init__(self, initial: datetime):
|
||||
self.now = initial
|
||||
def utcnow(self):
|
||||
return self.now
|
||||
|
||||
|
||||
def _insert_hour(conn, hour_iso, bw=0, br=0, sample_count=1,
|
||||
active=3600, idle=0, powered_off=0, unknown=0):
|
||||
"""Insert a UTC hour observation."""
|
||||
known = active + idle + powered_off
|
||||
coverage = known / 3600.0
|
||||
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, idle, powered_off, unknown, bw, br, sample_count, coverage),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _insert_sample(conn, ts_iso, bw=0, br=0, device="/dev/nvme0"):
|
||||
"""Insert a raw sample."""
|
||||
conn.execute(
|
||||
"INSERT INTO samples (ts, device, data_units_written, data_units_read, "
|
||||
" bytes_written, bytes_read, percentage_used) VALUES (?, ?, 0, 0, ?, ?, 0)",
|
||||
(ts_iso, device, bw, br),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC1: Advance through 14-day boundary, detail expires, summaries remain
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDetailExpiresSummariesRemain:
|
||||
"""AC1: After 14-day pruning, local-day summaries survive."""
|
||||
|
||||
def test_local_days_survive_sample_pruning(self, tmp_path):
|
||||
"""Local-day summaries persist after raw samples are pruned."""
|
||||
conn = init_store(tmp_path / "obs.db")
|
||||
now = datetime(2026, 10, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
# Create local-day summaries for dates spanning the 14-day boundary
|
||||
# Day 1: 15 days ago (detail should be pruned)
|
||||
old_summary = LocalDaySummary(
|
||||
local_date="2026-09-16", tz_name="UTC", tz_offset="+00:00",
|
||||
utc_start="2026-09-16T00:00:00+00:00",
|
||||
utc_end="2026-09-17T00:00:00+00:00",
|
||||
bytes_written=1000, bytes_read=500,
|
||||
coverage=0.8, sample_count=10, complete=True,
|
||||
)
|
||||
persist_local_day(conn, old_summary)
|
||||
|
||||
# Day 2: 10 days ago (detail should be retained)
|
||||
recent_summary = LocalDaySummary(
|
||||
local_date="2026-09-21", tz_name="UTC", tz_offset="+00:00",
|
||||
utc_start="2026-09-21T00:00:00+00:00",
|
||||
utc_end="2026-09-22T00:00:00+00:00",
|
||||
bytes_written=2000, bytes_read=800,
|
||||
coverage=0.9, sample_count=15, complete=True,
|
||||
)
|
||||
persist_local_day(conn, recent_summary)
|
||||
|
||||
# Insert raw samples (old and recent)
|
||||
_insert_sample(conn, "2026-09-16T10:00:00+00:00", bw=500, br=200)
|
||||
_insert_sample(conn, "2026-09-16T10:05:00+00:00", bw=1000, br=500)
|
||||
_insert_sample(conn, "2026-09-21T10:00:00+00:00", bw=1000, br=400)
|
||||
_insert_sample(conn, "2026-09-21T10:05:00+00:00", bw=2000, br=800)
|
||||
|
||||
# Run pruning
|
||||
pruned = prune_old_samples(conn, now, retention_days=14)
|
||||
|
||||
# Old samples should be pruned, recent ones retained
|
||||
assert pruned >= 1
|
||||
|
||||
# Local-day summaries must still be queryable
|
||||
old_result = query_local_day_summary(conn, "2026-09-16")
|
||||
assert old_result is not None
|
||||
assert old_result["bytes_written"] == 1000
|
||||
assert old_result["bytes_read"] == 500
|
||||
|
||||
recent_result = query_local_day_summary(conn, "2026-09-21")
|
||||
assert recent_result is not None
|
||||
assert recent_result["bytes_written"] == 2000
|
||||
|
||||
conn.close()
|
||||
|
||||
def test_history_query_shows_evidence_limits(self, tmp_path):
|
||||
"""History entries annotate detail-availability correctly."""
|
||||
conn = init_store(tmp_path / "obs.db")
|
||||
now = datetime(2026, 10, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
# Old summary (detail pruned)
|
||||
old = LocalDaySummary(
|
||||
local_date="2026-09-16", tz_name="UTC", tz_offset="+00:00",
|
||||
utc_start="2026-09-16T00:00:00+00:00",
|
||||
utc_end="2026-09-17T00:00:00+00:00",
|
||||
bytes_written=1000, bytes_read=500,
|
||||
coverage=0.8, sample_count=10, complete=True,
|
||||
)
|
||||
persist_local_day(conn, old)
|
||||
|
||||
# Recent summary (detail available)
|
||||
recent = LocalDaySummary(
|
||||
local_date="2026-09-21", tz_name="UTC", tz_offset="+00:00",
|
||||
utc_start="2026-09-21T00:00:00+00:00",
|
||||
utc_end="2026-09-22T00:00:00+00:00",
|
||||
bytes_written=2000, bytes_read=800,
|
||||
coverage=0.9, sample_count=15, complete=True,
|
||||
)
|
||||
persist_local_day(conn, recent)
|
||||
|
||||
# Insert a recent sample (no old samples → old detail not available)
|
||||
_insert_sample(conn, "2026-09-21T10:00:00+00:00", bw=1000, br=400)
|
||||
|
||||
# Query history
|
||||
entries = query_local_day_history(conn, "2026-09-16", "2026-09-21", now)
|
||||
assert len(entries) == 2
|
||||
|
||||
old_entry = next(e for e in entries if e.local_date == "2026-09-16")
|
||||
assert old_entry.detail_available is False
|
||||
assert old_entry.bytes_written == 1000
|
||||
|
||||
recent_entry = next(e for e in entries if e.local_date == "2026-09-21")
|
||||
assert recent_entry.detail_available is True
|
||||
assert recent_entry.bytes_written == 2000
|
||||
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC2: Preserve summaries and boundary evidence before pruning
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBoundaryPreservation:
|
||||
"""AC2: Summaries and boundary evidence preserved before pruning."""
|
||||
|
||||
def test_boundary_anchor_not_pruned_when_needed(self, tmp_path):
|
||||
"""Raw samples needed as boundary anchors are retained."""
|
||||
conn = init_store(tmp_path / "obs.db")
|
||||
now = datetime(2026, 10, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
# Old sample before cutoff, recent sample after cutoff
|
||||
# The old sample is a boundary anchor
|
||||
_insert_sample(conn, "2026-09-16T23:55:00+00:00", bw=100)
|
||||
_insert_sample(conn, "2026-09-17T12:30:00+00:00", bw=200)
|
||||
|
||||
pruned = prune_old_samples(conn, now, retention_days=14)
|
||||
|
||||
# Boundary anchor should be retained
|
||||
cursor = conn.execute(
|
||||
"SELECT COUNT(*) FROM samples WHERE ts = '2026-09-16T23:55:00+00:00'"
|
||||
)
|
||||
assert cursor.fetchone()[0] == 1
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC3: Aged summaries retain timezone after system-timezone change
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTimezonePreservation:
|
||||
"""AC3: Historical summaries retain recorded timezone after TZ change."""
|
||||
|
||||
def test_timezone_survives_pruning(self, tmp_path):
|
||||
"""Old summary in Asia/Kolkata retains its timezone after pruning."""
|
||||
conn = init_store(tmp_path / "obs.db")
|
||||
now = datetime(2026, 10, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
# Old summary in Asia/Kolkata
|
||||
old = LocalDaySummary(
|
||||
local_date="2026-09-16", tz_name="Asia/Kolkata", tz_offset="+05:30",
|
||||
utc_start="2026-09-15T18:30:00+00:00",
|
||||
utc_end="2026-09-16T18:30:00+00:00",
|
||||
bytes_written=3000, bytes_read=1200,
|
||||
coverage=0.7, sample_count=8, complete=False,
|
||||
)
|
||||
persist_local_day(conn, old)
|
||||
|
||||
# Prune
|
||||
prune_old_samples(conn, now, retention_days=14)
|
||||
|
||||
# Summary must retain its timezone
|
||||
result = query_local_day_summary(conn, "2026-09-16")
|
||||
assert result is not None
|
||||
assert result["tz_name"] == "Asia/Kolkata"
|
||||
assert result["tz_offset"] == "+05:30"
|
||||
# UTC boundaries are preserved (not rewritten)
|
||||
assert result["utc_start"] == "2026-09-15T18:30:00+00:00"
|
||||
assert result["utc_end"] == "2026-09-16T18:30:00+00:00"
|
||||
conn.close()
|
||||
|
||||
def test_history_entry_shows_tz_after_pruning(self, tmp_path):
|
||||
"""History entry for aged summary shows correct timezone info."""
|
||||
conn = init_store(tmp_path / "obs.db")
|
||||
now = datetime(2026, 10, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
old = LocalDaySummary(
|
||||
local_date="2026-09-16", tz_name="US/Eastern", tz_offset="-04:00",
|
||||
utc_start="2026-09-16T04:00:00+00:00",
|
||||
utc_end="2026-09-17T04:00:00+00:00",
|
||||
bytes_written=5000, bytes_read=2000,
|
||||
coverage=0.6, sample_count=5, complete=False,
|
||||
)
|
||||
persist_local_day(conn, old)
|
||||
|
||||
entries = query_local_day_history(conn, "2026-09-16", "2026-09-16", now)
|
||||
assert len(entries) == 1
|
||||
entry = entries[0]
|
||||
assert entry.tz_name == "US/Eastern"
|
||||
assert entry.tz_offset == "-04:00"
|
||||
assert entry.utc_start == "2026-09-16T04:00:00+00:00"
|
||||
assert entry.detail_available is False
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC4: Legacy UTC summaries without local-day total remain incomplete
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLegacyDataHandling:
|
||||
"""AC4: Legacy UTC summaries that can't establish local-day total."""
|
||||
|
||||
def test_incomplete_legacy_labelled_correctly(self, tmp_path):
|
||||
"""Legacy summary without full evidence is labelled incomplete."""
|
||||
conn = init_store(tmp_path / "obs.db")
|
||||
now = datetime(2026, 10, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
# Simulate a legacy summary that can't establish a full local-day total
|
||||
legacy = LocalDaySummary(
|
||||
local_date="2026-09-16", tz_name="UTC", tz_offset="+00:00",
|
||||
utc_start="2026-09-16T00:00:00+00:00",
|
||||
utc_end="2026-09-17T00:00:00+00:00",
|
||||
bytes_written=500, bytes_read=200,
|
||||
coverage=0.3, sample_count=3, complete=False,
|
||||
)
|
||||
persist_local_day(conn, legacy)
|
||||
|
||||
result = query_local_day_summary(conn, "2026-09-16")
|
||||
assert result is not None
|
||||
assert result["complete"] is False
|
||||
assert result["coverage"] == pytest.approx(0.3, abs=0.01)
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC5: Migration, pruning, and repair are interruption-safe and idempotent
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestIdempotencySafety:
|
||||
"""AC5: Operations are idempotent and interruption-safe."""
|
||||
|
||||
def test_repeated_pruning_no_side_effects(self, tmp_path):
|
||||
"""Running pruning multiple times produces the same result."""
|
||||
conn = init_store(tmp_path / "obs.db")
|
||||
now = datetime(2026, 10, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
# Create a local-day summary
|
||||
summary = LocalDaySummary(
|
||||
local_date="2026-09-16", tz_name="UTC", tz_offset="+00:00",
|
||||
utc_start="2026-09-16T00:00:00+00:00",
|
||||
utc_end="2026-09-17T00:00:00+00:00",
|
||||
bytes_written=1000, bytes_read=500,
|
||||
coverage=0.8, sample_count=10, complete=True,
|
||||
)
|
||||
persist_local_day(conn, summary)
|
||||
_insert_sample(conn, "2026-09-16T10:00:00+00:00", bw=500)
|
||||
_insert_sample(conn, "2026-09-16T10:05:00+00:00", bw=1000)
|
||||
|
||||
# Prune twice
|
||||
prune_old_samples(conn, now, retention_days=14)
|
||||
prune_old_samples(conn, now, retention_days=14)
|
||||
|
||||
# Local-day summary unchanged
|
||||
result = query_local_day_summary(conn, "2026-09-16")
|
||||
assert result is not None
|
||||
assert result["bytes_written"] == 1000
|
||||
conn.close()
|
||||
|
||||
def test_repair_does_not_touch_local_days(self, tmp_path):
|
||||
"""Repair derivation does not modify existing local_days entries."""
|
||||
conn = init_store(tmp_path / "obs.db")
|
||||
|
||||
# Create a local-day summary
|
||||
summary = LocalDaySummary(
|
||||
local_date="2026-09-16", tz_name="UTC", tz_offset="+00:00",
|
||||
utc_start="2026-09-16T00:00:00+00:00",
|
||||
utc_end="2026-09-17T00:00:00+00:00",
|
||||
bytes_written=1000, bytes_read=500,
|
||||
coverage=0.8, sample_count=10, complete=True,
|
||||
)
|
||||
persist_local_day(conn, summary)
|
||||
|
||||
# Run repair (should not touch local_days)
|
||||
result = repair_derivation(conn)
|
||||
assert result.ok is True
|
||||
|
||||
# Local-day summary unchanged
|
||||
stored = query_local_day_summary(conn, "2026-09-16")
|
||||
assert stored is not None
|
||||
assert stored["bytes_written"] == 1000
|
||||
assert stored["bytes_read"] == 500
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC6: Volume conservation — no double counting of midnight-spanning bytes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestVolumeConservation:
|
||||
"""AC6: Summary volume plus unallocated evidence conserves each delta once."""
|
||||
|
||||
def test_midnight_spanning_not_double_counted(self, tmp_path):
|
||||
"""Midnight-spanning interval appears once, not in both days."""
|
||||
conn = init_store(tmp_path / "obs.db")
|
||||
|
||||
# Insert hour observations that span midnight
|
||||
# UTC hour 2026-09-16T23:00 contains midnight boundary
|
||||
_insert_hour(conn, "2026-09-16T23:00:00+00:00", bw=100, br=50)
|
||||
_insert_hour(conn, "2026-09-17T00:00:00+00:00", bw=200, br=80)
|
||||
|
||||
# Derive summaries for both days
|
||||
clock_16 = datetime(2026, 9, 16, 12, 0, 0, tzinfo=timezone.utc)
|
||||
summary_16 = derive_local_day_summary(conn, "UTC", clock_16)
|
||||
assert summary_16 is not None
|
||||
|
||||
clock_17 = datetime(2026, 9, 17, 12, 0, 0, tzinfo=timezone.utc)
|
||||
summary_17 = derive_local_day_summary(conn, "UTC", clock_17)
|
||||
assert summary_17 is not None
|
||||
|
||||
# Each day should only have its own hour's bytes
|
||||
# Day 16 has the 23:00 hour (100 bw)
|
||||
# Day 17 has the 00:00 hour (200 bw)
|
||||
assert summary_16.bytes_written == 100
|
||||
assert summary_17.bytes_written == 200
|
||||
# Total is conserved: 100 + 200 = 300
|
||||
assert summary_16.bytes_written + summary_17.bytes_written == 300
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC7: Use existing retention/repair entry points with real stores
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestExistingEntryPoints:
|
||||
"""AC7: Use existing entry points with real temporary stores."""
|
||||
|
||||
def test_prune_with_real_temp_store(self, tmp_path):
|
||||
"""Pruning works with a real temporary store."""
|
||||
conn = init_store(tmp_path / "obs.db")
|
||||
now = datetime(2026, 10, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
# Insert old and recent samples
|
||||
for days_ago in range(1, 30):
|
||||
ts = (now - timedelta(days=days_ago)).isoformat()
|
||||
_insert_sample(conn, ts, bw=days_ago * 100)
|
||||
|
||||
pruned = prune_old_samples(conn, now, retention_days=14)
|
||||
assert pruned == 15
|
||||
|
||||
cursor = conn.execute("SELECT COUNT(*) FROM samples")
|
||||
assert cursor.fetchone()[0] == 14
|
||||
conn.close()
|
||||
|
||||
def test_repair_with_real_temp_store(self, tmp_path):
|
||||
"""Repair works with a real temporary store."""
|
||||
conn = init_store(tmp_path / "obs.db")
|
||||
|
||||
# Insert samples that need interval derivation
|
||||
_insert_sample(conn, "2026-09-16T10:00:00+00:00", bw=100)
|
||||
_insert_sample(conn, "2026-09-16T10:05:00+00:00", bw=200)
|
||||
|
||||
result = repair_derivation(conn)
|
||||
assert result.ok is True
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC8: User-visible transition from recent detail to older summaries
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestUserVisibleTransition:
|
||||
"""AC8: Transition from recent detail to aged summaries."""
|
||||
|
||||
def test_recent_vs_aged_history_entries(self, tmp_path):
|
||||
"""Recent entries show detail_available=True, aged show False."""
|
||||
conn = init_store(tmp_path / "obs.db")
|
||||
now = datetime(2026, 10, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
# Create summaries for recent and aged dates
|
||||
for days_ago, bw in [(5, 1000), (10, 2000), (15, 3000)]:
|
||||
date = (now - timedelta(days=days_ago)).strftime("%Y-%m-%d")
|
||||
utc_start = (now - timedelta(days=days_ago)).replace(
|
||||
hour=0, minute=0, second=0, microsecond=0
|
||||
).isoformat()
|
||||
utc_end = (now - timedelta(days=days_ago - 1)).replace(
|
||||
hour=0, minute=0, second=0, microsecond=0
|
||||
).isoformat()
|
||||
|
||||
summary = LocalDaySummary(
|
||||
local_date=date, tz_name="UTC", tz_offset="+00:00",
|
||||
utc_start=utc_start, utc_end=utc_end,
|
||||
bytes_written=bw, bytes_read=bw // 2,
|
||||
coverage=0.8, sample_count=10, complete=True,
|
||||
)
|
||||
persist_local_day(conn, summary)
|
||||
|
||||
# Insert a recent sample (5 days ago)
|
||||
_insert_sample(conn, (now - timedelta(days=5)).isoformat(), bw=500)
|
||||
|
||||
# Query full range
|
||||
start = (now - timedelta(days=15)).strftime("%Y-%m-%d")
|
||||
end = (now - timedelta(days=5)).strftime("%Y-%m-%d")
|
||||
entries = query_local_day_history(conn, start, end, now)
|
||||
|
||||
assert len(entries) == 3
|
||||
|
||||
# Recent entry has detail
|
||||
recent = next(e for e in entries if e.local_date == (now - timedelta(days=5)).strftime("%Y-%m-%d"))
|
||||
assert recent.detail_available is True
|
||||
|
||||
# Aged entries do not have detail
|
||||
aged_10 = next(e for e in entries if e.local_date == (now - timedelta(days=10)).strftime("%Y-%m-%d"))
|
||||
assert aged_10.detail_available is False
|
||||
|
||||
aged_15 = next(e for e in entries if e.local_date == (now - timedelta(days=15)).strftime("%Y-%m-%d"))
|
||||
assert aged_15.detail_available is False
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC9: Document the 14-day detail vs indefinite-summary policy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRetentionPolicyDocumentation:
|
||||
"""AC9: Module docstring documents the retention policy."""
|
||||
|
||||
def test_module_has_retention_policy_doc(self):
|
||||
"""The local_day module documents the 14-day vs indefinite policy."""
|
||||
import fenris.local_day as ld
|
||||
docstring = ld.__doc__
|
||||
assert "14 day" in docstring.lower() or "fourteen" in docstring.lower()
|
||||
assert "indefinite" in docstring.lower()
|
||||
assert "retention" in docstring.lower()
|
||||
|
||||
def test_pruning_module_documents_local_days_preservation(self):
|
||||
"""The pruning module documents that local_days are preserved."""
|
||||
import fenris.pruning as pr
|
||||
docstring = pr.__doc__
|
||||
assert "local_days" in docstring.lower() or "local day" in docstring.lower()
|
||||
assert "preserved" in docstring.lower() or "never" in docstring.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Evidence-limit visibility
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestEvidenceLimitVisibility:
|
||||
"""History entries expose evidence-availability metadata."""
|
||||
|
||||
def test_detail_available_flag_reflects_samples(self, tmp_path):
|
||||
"""detail_available is True only when samples exist within retention."""
|
||||
conn = init_store(tmp_path / "obs.db")
|
||||
now = datetime(2026, 10, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
# Summary for a date with no samples
|
||||
no_samples = LocalDaySummary(
|
||||
local_date="2026-09-16", tz_name="UTC", tz_offset="+00:00",
|
||||
utc_start="2026-09-16T00:00:00+00:00",
|
||||
utc_end="2026-09-17T00:00:00+00:00",
|
||||
bytes_written=500, bytes_read=200,
|
||||
coverage=0.4, sample_count=3, complete=False,
|
||||
)
|
||||
persist_local_day(conn, no_samples)
|
||||
|
||||
# Summary for a date with a recent sample
|
||||
with_sample = LocalDaySummary(
|
||||
local_date="2026-09-21", tz_name="UTC", tz_offset="+00:00",
|
||||
utc_start="2026-09-21T00:00:00+00:00",
|
||||
utc_end="2026-09-22T00:00:00+00:00",
|
||||
bytes_written=1000, bytes_read=400,
|
||||
coverage=0.9, sample_count=10, complete=True,
|
||||
)
|
||||
persist_local_day(conn, with_sample)
|
||||
_insert_sample(conn, "2026-09-21T12:00:00+00:00", bw=500)
|
||||
|
||||
entries = query_local_day_history(conn, "2026-09-16", "2026-09-21", now)
|
||||
|
||||
no_samples_entry = next(e for e in entries if e.local_date == "2026-09-16")
|
||||
assert no_samples_entry.detail_available is False
|
||||
|
||||
with_sample_entry = next(e for e in entries if e.local_date == "2026-09-21")
|
||||
assert with_sample_entry.detail_available is True
|
||||
conn.close()
|
||||
|
||||
def test_empty_history_range_returns_empty_list(self, tmp_path):
|
||||
"""Querying an empty date range returns an empty list."""
|
||||
conn = init_store(tmp_path / "obs.db")
|
||||
now = datetime(2026, 10, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
entries = query_local_day_history(conn, "2026-01-01", "2026-01-31", now)
|
||||
assert entries == []
|
||||
conn.close()
|
||||
|
||||
def test_history_entry_from_summary_factory(self, tmp_path):
|
||||
"""LocalDayHistoryEntry.from_summary creates entry from dict."""
|
||||
summary = {
|
||||
"local_date": "2026-09-16",
|
||||
"tz_name": "UTC",
|
||||
"tz_offset": "+00:00",
|
||||
"utc_start": "2026-09-16T00:00:00+00:00",
|
||||
"utc_end": "2026-09-17T00:00:00+00:00",
|
||||
"bytes_written": 1000,
|
||||
"bytes_read": 500,
|
||||
"coverage": 0.8,
|
||||
"sample_count": 10,
|
||||
"complete": True,
|
||||
}
|
||||
entry = LocalDayHistoryEntry.from_summary(
|
||||
summary, detail_available=True, derived_from_surviving=True
|
||||
)
|
||||
assert entry.local_date == "2026-09-16"
|
||||
assert entry.detail_available is True
|
||||
assert entry.derived_from_surviving is True
|
||||
assert entry.bytes_written == 1000
|
||||
@@ -0,0 +1,324 @@
|
||||
"""Tests for live activity graph (issue #91).
|
||||
|
||||
Covers:
|
||||
- Live interval volume query from raw samples
|
||||
- LiveActivityGraph widget rendering, toggle, and inspection
|
||||
- Three-minute cadence constants
|
||||
- TUI integration with live graph
|
||||
"""
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
|
||||
from fenris.store import init_store
|
||||
from fenris.monitoring_periods import ensure_period_open
|
||||
from fenris.tui import (
|
||||
FenrisTuiApp,
|
||||
LiveActivityGraph,
|
||||
_query_live_graph_data,
|
||||
LIVE_WINDOW_H,
|
||||
_query_daily_graph_data,
|
||||
_RANGE_OPTIONS,
|
||||
)
|
||||
from fenris.status import CADENCE_DEFAULT_S, FRESH_THRESHOLD_S, ACCURACY_SEC
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _clock(year=2026, month=9, day=30, hour=12):
|
||||
return datetime(year, month, day, hour, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def _insert_segment(conn, opened_at="2026-09-01T00:00:00+00:00",
|
||||
identity_key="nqn.test", degraded=False,
|
||||
mn="Samsung SSD 970 EVO Plus 1TB"):
|
||||
conn.execute(
|
||||
"INSERT INTO controller_segments "
|
||||
"(opened_at, identity_key, identity_degraded, subnqn, sn, mn, fr, vid, ssvid, transport) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(opened_at, identity_key, degraded, "nqn.test", "SN123", mn, "FW1",
|
||||
"0x144d", "0x144d", "pcie"),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _insert_sample(conn, ts, bw=512000000000, br=256000000000,
|
||||
segment_id=None, pu=5, device="/dev/nvme0n1"):
|
||||
conn.execute(
|
||||
"INSERT INTO samples (ts, device, data_units_written, data_units_read, "
|
||||
"percentage_used, bytes_written, bytes_read, power_on_hours, segment_id) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(ts, device, 1000000, 500000, pu, bw, br, 8765, segment_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _open_period(conn, start="2026-09-01T00:00:00+00:00"):
|
||||
ensure_period_open(conn, datetime.fromisoformat(start))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cadence constants (issue #91 AC1)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCadenceConstants:
|
||||
def test_cadence_default_is_3_minutes(self):
|
||||
"""Default collection cadence is 3 minutes (180 s)."""
|
||||
assert CADENCE_DEFAULT_S == 180
|
||||
|
||||
def test_fresh_threshold_uses_3min_cadence(self):
|
||||
"""Fresh threshold = 2 × 3min + AccuracySec + 60s = 450s."""
|
||||
expected = 2 * 180 + ACCURACY_SEC + 60
|
||||
assert FRESH_THRESHOLD_S == expected
|
||||
|
||||
def test_live_window_is_3_hours(self):
|
||||
"""Live graph window is 3 hours."""
|
||||
assert LIVE_WINDOW_H == 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Live graph query (issue #91 AC2-5)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestQueryLiveData:
|
||||
def test_empty_store_returns_empty(self, tmp_path):
|
||||
"""No samples → empty list."""
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
now = _clock()
|
||||
result = _query_live_graph_data(conn, now)
|
||||
assert result == []
|
||||
conn.close()
|
||||
|
||||
def test_single_sample_returns_empty(self, tmp_path):
|
||||
"""One sample (no pair) → empty list."""
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
now = _clock()
|
||||
_insert_sample(conn, now.isoformat())
|
||||
result = _query_live_graph_data(conn, now)
|
||||
assert result == []
|
||||
conn.close()
|
||||
|
||||
def test_two_samples_in_window(self, tmp_path):
|
||||
"""Two samples within 3h window produce one interval."""
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
now = _clock()
|
||||
t1 = (now - timedelta(minutes=3)).isoformat()
|
||||
t2 = (now - timedelta(minutes=0)).isoformat()
|
||||
_insert_sample(conn, t1, bw=1000, br=500)
|
||||
_insert_sample(conn, t2, bw=1500, br=700)
|
||||
result = _query_live_graph_data(conn, now)
|
||||
assert len(result) == 1
|
||||
assert result[0]["bytes_written"] == 500
|
||||
assert result[0]["bytes_read"] == 200
|
||||
assert result[0]["elapsed_s"] == 180
|
||||
assert result[0]["is_gap"] is False
|
||||
assert result[0]["is_zero"] is False
|
||||
conn.close()
|
||||
|
||||
def test_samples_outside_window_ignored(self, tmp_path):
|
||||
"""Samples older than 3h are excluded."""
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
now = _clock()
|
||||
old = (now - timedelta(hours=4)).isoformat()
|
||||
recent = (now - timedelta(minutes=1)).isoformat()
|
||||
_insert_sample(conn, old, bw=1000, br=500)
|
||||
_insert_sample(conn, recent, bw=1500, br=700)
|
||||
result = _query_live_graph_data(conn, now)
|
||||
assert len(result) == 0
|
||||
conn.close()
|
||||
|
||||
def test_gap_detection(self, tmp_path):
|
||||
"""Interval > 3× cadence is flagged as a gap."""
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
now = _clock()
|
||||
t1 = (now - timedelta(minutes=13)).isoformat()
|
||||
t2 = (now - timedelta(minutes=3)).isoformat()
|
||||
_insert_sample(conn, t1, bw=1000, br=500)
|
||||
_insert_sample(conn, t2, bw=1500, br=700)
|
||||
result = _query_live_graph_data(conn, now)
|
||||
assert len(result) == 1
|
||||
assert result[0]["is_gap"] is True
|
||||
conn.close()
|
||||
|
||||
def test_zero_interval(self, tmp_path):
|
||||
"""No byte delta → is_zero flag."""
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
now = _clock()
|
||||
t1 = (now - timedelta(minutes=3)).isoformat()
|
||||
t2 = (now - timedelta(minutes=0)).isoformat()
|
||||
_insert_sample(conn, t1, bw=1000, br=500)
|
||||
_insert_sample(conn, t2, bw=1000, br=500)
|
||||
result = _query_live_graph_data(conn, now)
|
||||
assert len(result) == 1
|
||||
assert result[0]["bytes_written"] == 0
|
||||
assert result[0]["bytes_read"] == 0
|
||||
assert result[0]["is_zero"] is True
|
||||
conn.close()
|
||||
|
||||
def test_segment_boundary_resets_counters(self, tmp_path):
|
||||
"""Counter discontinuity across segments → zero delta."""
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
_insert_segment(conn, identity_key="key_a")
|
||||
_insert_segment(conn, opened_at="2026-09-30T11:00:00+00:00",
|
||||
identity_key="key_b")
|
||||
now = _clock()
|
||||
t1 = (now - timedelta(minutes=6)).isoformat()
|
||||
t2 = (now - timedelta(minutes=3)).isoformat()
|
||||
_insert_sample(conn, t1, bw=1000, br=500, segment_id=1)
|
||||
_insert_sample(conn, t2, bw=500, br=200, segment_id=2)
|
||||
result = _query_live_graph_data(conn, now)
|
||||
assert len(result) == 1
|
||||
assert result[0]["bytes_written"] == 0
|
||||
assert result[0]["bytes_read"] == 0
|
||||
assert result[0]["is_segment_boundary"] is True
|
||||
conn.close()
|
||||
|
||||
def test_actual_timestamps_used(self, tmp_path):
|
||||
"""Points use actual sample timestamps, not 3-min spacing."""
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
now = _clock()
|
||||
t1 = (now - timedelta(minutes=5)).isoformat()
|
||||
t2 = (now - timedelta(minutes=1)).isoformat()
|
||||
_insert_sample(conn, t1, bw=1000, br=500)
|
||||
_insert_sample(conn, t2, bw=2000, br=800)
|
||||
result = _query_live_graph_data(conn, now)
|
||||
assert len(result) == 1
|
||||
assert result[0]["start_ts"] == t1
|
||||
assert result[0]["end_ts"] == t2
|
||||
assert result[0]["elapsed_s"] == 240
|
||||
conn.close()
|
||||
|
||||
def test_multiple_intervals_ordered_oldest_first(self, tmp_path):
|
||||
"""Multiple intervals are returned in chronological order."""
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
now = _clock()
|
||||
for i in range(5):
|
||||
ts = (now - timedelta(minutes=15 - i * 3)).isoformat()
|
||||
_insert_sample(conn, ts, bw=1000 * (i + 1), br=500 * (i + 1))
|
||||
result = _query_live_graph_data(conn, now)
|
||||
assert len(result) == 4
|
||||
for i in range(len(result) - 1):
|
||||
assert result[i]["end_ts"] <= result[i + 1]["start_ts"]
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LiveActivityGraph widget (issue #91 AC3)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLiveActivityGraph:
|
||||
@pytest.mark.asyncio
|
||||
async def test_toggle_measure(self, tmp_path):
|
||||
"""Toggle switches between written and read."""
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
graph = app.query_one("#live-activity")
|
||||
assert graph.measure == "written"
|
||||
graph.toggle_measure()
|
||||
assert graph.measure == "read"
|
||||
graph.toggle_measure()
|
||||
assert graph.measure == "written"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_data_renders_awaiting(self, tmp_path):
|
||||
"""Empty data shows awaiting message."""
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
graph = app.query_one("#live-activity")
|
||||
graph.set_data([])
|
||||
await pilot.pause()
|
||||
render = str(graph.query_one("#live-render").render())
|
||||
assert "Awaiting intervals" in render
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_data_renders_header(self, tmp_path):
|
||||
"""Data updates the header with interval count."""
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
graph = app.query_one("#live-activity")
|
||||
data = [
|
||||
{
|
||||
"start_ts": "2026-09-30T11:57:00+00:00",
|
||||
"end_ts": "2026-09-30T12:00:00+00:00",
|
||||
"start_label": "11:57",
|
||||
"end_label": "12:00",
|
||||
"bytes_written": 500000000,
|
||||
"bytes_read": 200000000,
|
||||
"elapsed_s": 180,
|
||||
"is_gap": False,
|
||||
"is_zero": False,
|
||||
"is_segment_boundary": False,
|
||||
},
|
||||
]
|
||||
graph.set_data(data)
|
||||
await pilot.pause()
|
||||
header = str(graph.query_one("#live-header").render())
|
||||
assert "1 intervals" in header
|
||||
assert "w Toggle W/R" in header
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TUI integration (issue #91 AC2, AC6-9)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTUILiveIntegration:
|
||||
@pytest.mark.asyncio
|
||||
async def test_live_graph_exists_in_compose(self, tmp_path):
|
||||
"""Live activity graph is composed in the TUI."""
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
assert app.query_one("#live-activity") is not None
|
||||
assert isinstance(app.query_one("#live-activity"), LiveActivityGraph)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_live_graph_has_border_title(self, tmp_path):
|
||||
"""Live activity graph has a border title."""
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
live = app.query_one("#live-activity")
|
||||
assert live.border_title == "Live activity"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_today_action_resets_drill(self, tmp_path):
|
||||
"""Action today returns from hourly drill to daily view."""
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
graph = app.query_one("#usage-history")
|
||||
graph.view_mode = "hourly"
|
||||
graph.drill_day = "2026-09-30"
|
||||
app.action_today()
|
||||
await pilot.pause()
|
||||
assert graph.view_mode == "daily"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_toggle_measure_binding(self, tmp_path):
|
||||
"""The w key toggles the live graph measure."""
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.pause()
|
||||
graph = app.query_one("#live-activity")
|
||||
assert graph.measure == "written"
|
||||
await pilot.press("w")
|
||||
await pilot.pause()
|
||||
assert graph.measure == "read"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_constrained_mode_hides_live_graph(self, tmp_path):
|
||||
"""Constrained terminal hides the live graph."""
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||
async with app.run_test(size=(70, 20)) as pilot:
|
||||
await pilot.pause()
|
||||
main_grid = app.query_one("#main-grid")
|
||||
assert main_grid.has_class("constrained")
|
||||
@@ -0,0 +1,418 @@
|
||||
"""Local-day activity derivation tests (issue #90, ADR 0010).
|
||||
|
||||
Verifies that local-day summaries are correctly derived from UTC hour
|
||||
observations, that the schema migration creates the local_days table,
|
||||
that timezone boundaries are preserved across system-timezone changes,
|
||||
and that midnight-spanning intervals are retained as shared evidence
|
||||
without double counting.
|
||||
|
||||
Seams:
|
||||
- write side: collector.run_collection() → local_day derivation
|
||||
- read side: local_day.query_current_local_day(), query_local_day_summary()
|
||||
- persistence: store.init_store() → local_days table, migration 2→3
|
||||
"""
|
||||
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.store import init_store, SCHEMA_VERSION
|
||||
from fenris.local_day import (
|
||||
derive_local_day_summary,
|
||||
persist_local_day,
|
||||
query_local_day_summary,
|
||||
query_current_local_day,
|
||||
LocalDaySummary,
|
||||
)
|
||||
from fenris.monitoring_periods import ensure_period_open, close_period
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_smartctl(duw: int, dur: 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,
|
||||
"data_units_read": dur, "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_tree(tmp_path: Path) -> Path:
|
||||
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
|
||||
|
||||
|
||||
class _Clock:
|
||||
def __init__(self, initial: datetime):
|
||||
self.now = initial
|
||||
def utcnow(self):
|
||||
return self.now
|
||||
|
||||
|
||||
def _insert_hour(conn, hour_iso, bw=0, br=0, sample_count=1,
|
||||
active=3600, idle=0, powered_off=0, unknown=0):
|
||||
"""Insert a UTC hour observation."""
|
||||
known = active + idle + powered_off
|
||||
coverage = known / 3600.0
|
||||
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, idle, powered_off, unknown, bw, br, sample_count, coverage),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schema migration: local_days table is created
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSchemaMigration:
|
||||
"""Store version 3 includes the local_days table."""
|
||||
|
||||
def test_fresh_store_has_local_days(self, tmp_path):
|
||||
conn = init_store(tmp_path / "obs.db")
|
||||
tables = {row[0] for row in conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||
).fetchall()}
|
||||
assert "local_days" in tables
|
||||
conn.close()
|
||||
|
||||
def test_migration_from_v2_creates_local_days(self, tmp_path):
|
||||
db = tmp_path / "obs.db"
|
||||
conn = sqlite3.connect(str(db))
|
||||
conn.execute("PRAGMA user_version=2")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
conn = init_store(db)
|
||||
tables = {row[0] for row in conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||
).fetchall()}
|
||||
assert "local_days" in tables
|
||||
assert conn.execute("PRAGMA user_version").fetchone()[0] == SCHEMA_VERSION
|
||||
conn.close()
|
||||
|
||||
def test_migration_is_idempotent(self, tmp_path):
|
||||
db = tmp_path / "obs.db"
|
||||
conn = init_store(db)
|
||||
conn.close()
|
||||
# Reopening should not fail
|
||||
conn = init_store(db)
|
||||
count = conn.execute("SELECT COUNT(*) FROM local_days").fetchone()[0]
|
||||
assert count == 0
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Local-day derivation from UTC hours
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDeriveLocalDay:
|
||||
"""Derive local-day summaries from UTC hour observations."""
|
||||
|
||||
def test_utc_plus_zero(self, tmp_path):
|
||||
"""UTC timezone: local day boundaries = UTC day boundaries."""
|
||||
conn = init_store(tmp_path / "obs.db")
|
||||
ensure_period_open(conn, datetime(2026, 9, 1, 0, 0, 0, tzinfo=timezone.utc))
|
||||
|
||||
_insert_hour(conn, "2026-09-01T10:00:00+00:00", bw=100, br=50)
|
||||
_insert_hour(conn, "2026-09-01T11:00:00+00:00", bw=200, br=80)
|
||||
|
||||
clock = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
summary = derive_local_day_summary(conn, "UTC", clock)
|
||||
|
||||
assert summary is not None
|
||||
assert summary.local_date == "2026-09-01"
|
||||
assert summary.tz_name == "UTC"
|
||||
assert summary.tz_offset == "+00:00"
|
||||
assert summary.bytes_written == 300
|
||||
assert summary.bytes_read == 130
|
||||
assert summary.complete is False # not all 24 hours covered
|
||||
conn.close()
|
||||
|
||||
def test_utc_plus_five_thirty(self, tmp_path):
|
||||
"""Asia/Kolkata half-hour offset: UTC boundaries are offset."""
|
||||
conn = init_store(tmp_path / "obs.db")
|
||||
|
||||
# Local day 2026-09-01 in +05:30 is UTC [2026-08-31T18:30, 2026-09-01T18:30)
|
||||
# Insert UTC hours that belong to this local day
|
||||
_insert_hour(conn, "2026-08-31T19:00:00+00:00", bw=100, br=50)
|
||||
_insert_hour(conn, "2026-09-01T10:00:00+00:00", bw=200, br=80)
|
||||
|
||||
clock = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc) # 17:30 local
|
||||
summary = derive_local_day_summary(conn, "Asia/Kolkata", clock)
|
||||
|
||||
assert summary is not None
|
||||
assert summary.local_date == "2026-09-01"
|
||||
assert summary.tz_name == "Asia/Kolkata"
|
||||
assert summary.tz_offset == "+05:30"
|
||||
assert summary.bytes_written == 300
|
||||
assert summary.bytes_read == 130
|
||||
conn.close()
|
||||
|
||||
def test_midnight_spanning_hour_included(self, tmp_path):
|
||||
"""UTC hour straddling local midnight is included in the local day."""
|
||||
conn = init_store(tmp_path / "obs.db")
|
||||
|
||||
# For UTC+5:30, local day 2026-09-01 starts at 2026-08-31T18:30 UTC
|
||||
# UTC hour 2026-08-31T18:00 covers 18:00-19:00, straddling the midnight
|
||||
_insert_hour(conn, "2026-08-31T18:00:00+00:00", bw=100, br=50)
|
||||
_insert_hour(conn, "2026-08-31T19:00:00+00:00", bw=200, br=80)
|
||||
|
||||
clock = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
summary = derive_local_day_summary(conn, "Asia/Kolkata", clock)
|
||||
|
||||
assert summary is not None
|
||||
# The midnight-spanning hour (18:00) is included
|
||||
assert summary.bytes_written == 300
|
||||
assert summary.bytes_read == 130
|
||||
conn.close()
|
||||
|
||||
def test_no_hours_returns_none(self, tmp_path):
|
||||
conn = init_store(tmp_path / "obs.db")
|
||||
clock = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
summary = derive_local_day_summary(conn, "UTC", clock)
|
||||
assert summary is None
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Persistence and querying
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLocalDayPersistence:
|
||||
"""Local-day summaries persist and can be queried."""
|
||||
|
||||
def test_persist_and_query(self, tmp_path):
|
||||
conn = init_store(tmp_path / "obs.db")
|
||||
summary = LocalDaySummary(
|
||||
local_date="2026-09-01", tz_name="UTC", tz_offset="+00:00",
|
||||
utc_start="2026-09-01T00:00:00+00:00",
|
||||
utc_end="2026-09-02T00:00:00+00:00",
|
||||
bytes_written=300, bytes_read=130,
|
||||
coverage=0.5, sample_count=4, complete=False,
|
||||
)
|
||||
created = persist_local_day(conn, summary)
|
||||
assert created is True
|
||||
|
||||
# Upsert again — should update, not create
|
||||
created2 = persist_local_day(conn, summary)
|
||||
assert created2 is False
|
||||
|
||||
result = query_local_day_summary(conn, "2026-09-01")
|
||||
assert result is not None
|
||||
assert result["bytes_written"] == 300
|
||||
assert result["tz_name"] == "UTC"
|
||||
conn.close()
|
||||
|
||||
def test_query_nonexistent_returns_none(self, tmp_path):
|
||||
conn = init_store(tmp_path / "obs.db")
|
||||
assert query_local_day_summary(conn, "2026-01-01") is None
|
||||
conn.close()
|
||||
|
||||
def test_query_current_local_day(self, tmp_path):
|
||||
conn = init_store(tmp_path / "obs.db")
|
||||
summary = LocalDaySummary(
|
||||
local_date="2026-09-01", tz_name="UTC", tz_offset="+00:00",
|
||||
utc_start="2026-09-01T00:00:00+00:00",
|
||||
utc_end="2026-09-02T00:00:00+00:00",
|
||||
bytes_written=500, bytes_read=200,
|
||||
coverage=0.8, sample_count=10, complete=True,
|
||||
)
|
||||
persist_local_day(conn, summary)
|
||||
|
||||
clock = datetime(2026, 9, 1, 15, 0, 0, tzinfo=timezone.utc)
|
||||
result = query_current_local_day(conn, clock, "UTC")
|
||||
assert result is not None
|
||||
assert result["bytes_written"] == 500
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Timezone change handling
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestTimezoneChange:
|
||||
"""Historical summaries retain their recorded timezone."""
|
||||
|
||||
def test_old_tz_preserved_after_change(self, tmp_path):
|
||||
conn = init_store(tmp_path / "obs.db")
|
||||
|
||||
# Old summary in UTC
|
||||
old = LocalDaySummary(
|
||||
local_date="2026-09-01", tz_name="UTC", tz_offset="+00:00",
|
||||
utc_start="2026-09-01T00:00:00+00:00",
|
||||
utc_end="2026-09-02T00:00:00+00:00",
|
||||
bytes_written=100, bytes_read=50,
|
||||
coverage=0.5, sample_count=5, complete=False,
|
||||
)
|
||||
persist_local_day(conn, old)
|
||||
|
||||
# System timezone changes to Asia/Kolkata
|
||||
# Old summary retains UTC
|
||||
result = query_local_day_summary(conn, "2026-09-01")
|
||||
assert result is not None
|
||||
assert result["tz_name"] == "UTC"
|
||||
assert result["tz_offset"] == "+00:00"
|
||||
conn.close()
|
||||
|
||||
def test_different_tz_same_date_stored_separately(self, tmp_path):
|
||||
conn = init_store(tmp_path / "obs.db")
|
||||
|
||||
# Two summaries for same date in different timezones
|
||||
for tz, offset in [("UTC", "+00:00"), ("Asia/Kolkata", "+05:30")]:
|
||||
summary = LocalDaySummary(
|
||||
local_date="2026-09-01", tz_name=tz, tz_offset=offset,
|
||||
utc_start="2026-09-01T00:00:00+00:00",
|
||||
utc_end="2026-09-02T00:00:00+00:00",
|
||||
bytes_written=100, bytes_read=50,
|
||||
coverage=0.5, sample_count=5, complete=False,
|
||||
)
|
||||
persist_local_day(conn, summary)
|
||||
|
||||
# Both exist independently — query by specific timezone
|
||||
utc_row = conn.execute(
|
||||
"SELECT tz_name, bytes_written FROM local_days "
|
||||
"WHERE local_date = '2026-09-01' AND tz_name = 'UTC'"
|
||||
).fetchone()
|
||||
assert utc_row is not None
|
||||
assert utc_row[0] == "UTC"
|
||||
assert utc_row[1] == 100
|
||||
|
||||
in_row = conn.execute(
|
||||
"SELECT tz_name, bytes_written FROM local_days "
|
||||
"WHERE local_date = '2026-09-01' AND tz_name = 'Asia/Kolkata'"
|
||||
).fetchone()
|
||||
assert in_row is not None
|
||||
assert in_row[0] == "Asia/Kolkata"
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Coverage and completeness
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLocalDayCompleteness:
|
||||
"""Complete days vs partial current day."""
|
||||
|
||||
def test_incomplete_when_missing_hours(self, tmp_path):
|
||||
conn = init_store(tmp_path / "obs.db")
|
||||
_insert_hour(conn, "2026-09-01T12:00:00+00:00", bw=100, br=50)
|
||||
|
||||
clock = datetime(2026, 9, 1, 13, 0, 0, tzinfo=timezone.utc)
|
||||
summary = derive_local_day_summary(conn, "UTC", clock)
|
||||
|
||||
assert summary is not None
|
||||
assert summary.complete is False
|
||||
assert summary.coverage < 1.0
|
||||
conn.close()
|
||||
|
||||
def test_coverage_ratio(self, tmp_path):
|
||||
conn = init_store(tmp_path / "obs.db")
|
||||
# 2 hours of known data out of 24 total UTC hours in a UTC day
|
||||
_insert_hour(conn, "2026-09-01T10:00:00+00:00", bw=100, active=3600)
|
||||
_insert_hour(conn, "2026-09-01T11:00:00+00:00", bw=200, active=3600)
|
||||
|
||||
clock = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
summary = derive_local_day_summary(conn, "UTC", clock)
|
||||
|
||||
assert summary is not None
|
||||
# 7200 known seconds / 86400 total = ~0.083
|
||||
assert summary.coverage == pytest.approx(7200 / 86400, rel=0.01)
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Collector integration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCollectorIntegration:
|
||||
"""Collector derives local-day summaries during collection."""
|
||||
|
||||
def test_collection_creates_local_day(self, tmp_path, sysfs_tree):
|
||||
from fenris.collector import run_collection
|
||||
|
||||
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
|
||||
store = str(tmp_path / "obs.db")
|
||||
cfg = {"device": "/dev/nvme0", "store_path": store}
|
||||
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
|
||||
|
||||
run_collection(_make_smartctl(10000000, 8000000), sysfs_nvme, cfg, _Clock(t1))
|
||||
run_collection(_make_smartctl(10000050, 8000030), sysfs_nvme, cfg, _Clock(t2))
|
||||
|
||||
conn = sqlite3.connect(store)
|
||||
count = conn.execute("SELECT COUNT(*) FROM local_days").fetchone()[0]
|
||||
assert count >= 1
|
||||
|
||||
# Verify the local day has the expected write/read data
|
||||
row = conn.execute(
|
||||
"SELECT bytes_written, bytes_read, complete FROM local_days LIMIT 1"
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
assert row[0] == 50 * 512000 # writes
|
||||
assert row[1] == 30 * 512000 # reads
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DST handling (23/25-hour days)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDSTHandling:
|
||||
"""Local days with non-24-hour durations from DST transitions."""
|
||||
|
||||
def test_short_day_23_hours(self, tmp_path):
|
||||
"""Spring forward: local day is 23 hours."""
|
||||
conn = init_store(tmp_path / "obs.db")
|
||||
|
||||
# Simulate a 23-hour day in a timezone with DST
|
||||
# For simplicity, just verify the summary records the correct UTC range
|
||||
clock = datetime(2026, 3, 8, 12, 0, 0, tzinfo=timezone.utc)
|
||||
# US/Eastern springs forward on 2026-03-08
|
||||
# Local day 2026-03-08 is 23 hours: UTC [07:00, 06:00+1d)
|
||||
_insert_hour(conn, "2026-03-08T08:00:00+00:00", bw=100, active=3600)
|
||||
_insert_hour(conn, "2026-03-08T12:00:00+00:00", bw=200, active=3600)
|
||||
|
||||
summary = derive_local_day_summary(conn, "US/Eastern", clock)
|
||||
assert summary is not None
|
||||
assert summary.local_date == "2026-03-08"
|
||||
# UTC range should be approximately 23 hours
|
||||
utc_start = datetime.fromisoformat(summary.utc_start)
|
||||
utc_end = datetime.fromisoformat(summary.utc_end)
|
||||
duration = (utc_end - utc_start).total_seconds()
|
||||
assert 22 * 3600 <= duration <= 24 * 3600 # ~23h ± tolerance
|
||||
conn.close()
|
||||
@@ -0,0 +1,551 @@
|
||||
"""Measured drive activity integration tests (issue #89).
|
||||
|
||||
Drives successive controlled acquisition readings through the public
|
||||
collector into a real temporary observation store, then verifies the
|
||||
normal reader and visible dashboard report correct read/write deltas,
|
||||
hour/day evidence, accumulation, and boundary behaviour.
|
||||
|
||||
Seams:
|
||||
- write side: run_collection() → observation store
|
||||
- read side: _query_daily_graph_data(), _query_hourly_graph_data(),
|
||||
derive_day(), get_status() → observation store
|
||||
"""
|
||||
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
|
||||
from fenris.store import init_store
|
||||
from fenris.day_aggregate import derive_day
|
||||
from fenris.monitoring_periods import ensure_period_open
|
||||
from fenris.tui import _query_daily_graph_data, _query_hourly_graph_data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_smartctl(duw: int, dur: int) -> Dict[str, Any]:
|
||||
"""Build a smartctl fixture with specific DUW/DUR counters."""
|
||||
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,
|
||||
"data_units_read": dur, "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_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
|
||||
|
||||
|
||||
class _Clock:
|
||||
"""Injected clock returning controlled time."""
|
||||
def __init__(self, initial: datetime):
|
||||
self.now = initial
|
||||
def utcnow(self):
|
||||
return self.now
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC1: Two successive readings produce correct read/write deltas
|
||||
# visible through both the hour observations and the TUI query path.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSuccessiveReadings:
|
||||
"""First reading is an anchor; second yields a measured interval."""
|
||||
|
||||
def test_two_readings_produce_both_rw_deltas(
|
||||
self, tmp_path, sysfs_tree,
|
||||
):
|
||||
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
|
||||
store = str(tmp_path / "obs.db")
|
||||
cfg = {"device": "/dev/nvme0", "store_path": store}
|
||||
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
|
||||
|
||||
# DUW=10000000, DUR=8000000
|
||||
r1 = run_collection(_make_smartctl(10000000, 8000000), sysfs_nvme, cfg, _Clock(t1))
|
||||
assert r1["ok"]
|
||||
# +50 DUW, +30 DUR
|
||||
r2 = run_collection(_make_smartctl(10000050, 8000030), sysfs_nvme, cfg, _Clock(t2))
|
||||
assert r2["ok"]
|
||||
|
||||
conn = sqlite3.connect(store)
|
||||
# Hour observation should have both read and write deltas
|
||||
hour = conn.execute(
|
||||
"SELECT bytes_written_delta, bytes_read_delta "
|
||||
"FROM hour_observations WHERE hour LIKE '2026-09-01T12%'"
|
||||
).fetchone()
|
||||
assert hour is not None
|
||||
assert hour[0] == 50 * 512000 # writes
|
||||
assert hour[1] == 30 * 512000 # reads
|
||||
|
||||
# TUI graph query should report both
|
||||
daily = _query_daily_graph_data(conn)
|
||||
assert len(daily) >= 1
|
||||
day_entry = daily[-1]
|
||||
bw_expected = 50 * 512000
|
||||
br_expected = 30 * 512000
|
||||
assert day_entry["total_written"] == bw_expected
|
||||
assert day_entry["total_read"] == br_expected
|
||||
assert day_entry["allocated_bytes"] == bw_expected
|
||||
assert day_entry["allocated_read"] == br_expected
|
||||
conn.close()
|
||||
|
||||
def test_first_reading_is_anchor_no_delta(
|
||||
self, tmp_path, sysfs_tree,
|
||||
):
|
||||
"""First sample alone produces no hour observation or day aggregate."""
|
||||
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
store = str(tmp_path / "obs.db")
|
||||
cfg = {"device": "/dev/nvme0", "store_path": store}
|
||||
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
|
||||
|
||||
run_collection(_make_smartctl(10000000, 8000000), sysfs_nvme, cfg, _Clock(t1))
|
||||
|
||||
conn = sqlite3.connect(store)
|
||||
assert conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0] == 1
|
||||
assert conn.execute("SELECT COUNT(*) FROM hour_observations").fetchone()[0] == 0
|
||||
daily = _query_daily_graph_data(conn)
|
||||
assert all(d["is_zero"] or d["is_gap"] for d in daily)
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC3: Repeated same-hour collections accumulate both reads and writes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSameHourAccumulation:
|
||||
"""Multiple intervals in the same hour accumulate both BW and BR."""
|
||||
|
||||
def test_three_same_hour_readings_accumulate(self, tmp_path, sysfs_tree):
|
||||
store = str(tmp_path / "obs.db")
|
||||
cfg = {"device": "/dev/nvme0", "store_path": store}
|
||||
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
|
||||
base_t = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
# Three readings: 0→20→50 DUW, 0→10→35 DUR (all same hour)
|
||||
duw_seq = [10000000, 10000020, 10000050]
|
||||
dur_seq = [8000000, 8000010, 8000035]
|
||||
for i in range(3):
|
||||
t = base_t + timedelta(minutes=i * 3)
|
||||
r = run_collection(
|
||||
_make_smartctl(duw_seq[i], dur_seq[i]), sysfs_nvme, cfg, _Clock(t)
|
||||
)
|
||||
assert r["ok"]
|
||||
|
||||
conn = sqlite3.connect(store)
|
||||
hour = conn.execute(
|
||||
"SELECT bytes_written_delta, bytes_read_delta, sample_count "
|
||||
"FROM hour_observations WHERE hour LIKE '2026-09-01T12%'"
|
||||
).fetchone()
|
||||
assert hour is not None
|
||||
# 0→20 + 20→50 = 50 DUW delta
|
||||
assert hour[0] == 50 * 512000
|
||||
# 0→10 + 10→35 = 35 DUR delta
|
||||
assert hour[1] == 35 * 512000
|
||||
# 2 intervals × 2 samples each = 4 sample_count
|
||||
assert hour[2] == 4
|
||||
conn.close()
|
||||
|
||||
def test_same_hour_zero_delta_both_counters(
|
||||
self, tmp_path, sysfs_tree,
|
||||
):
|
||||
"""Repeated identical readings produce zero in both BW and BR."""
|
||||
store = str(tmp_path / "obs.db")
|
||||
cfg = {"device": "/dev/nvme0", "store_path": store}
|
||||
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
|
||||
base_t = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
for i in range(3):
|
||||
t = base_t + timedelta(minutes=i * 3)
|
||||
r = run_collection(
|
||||
_make_smartctl(10000000, 8000000), sysfs_nvme, cfg, _Clock(t)
|
||||
)
|
||||
assert r["ok"]
|
||||
|
||||
conn = sqlite3.connect(store)
|
||||
hour = conn.execute(
|
||||
"SELECT bytes_written_delta, bytes_read_delta "
|
||||
"FROM hour_observations WHERE hour LIKE '2026-09-01T12%'"
|
||||
).fetchone()
|
||||
assert hour is not None
|
||||
assert hour[0] == 0
|
||||
assert hour[1] == 0
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC4: Cross-hour measurements attributed to correct hours
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCrossHourAttribution:
|
||||
"""Cross-hour deltas are unattributed to hours, kept at day level."""
|
||||
|
||||
def test_cross_hour_unattributed_bytes(
|
||||
self, tmp_path, sysfs_tree,
|
||||
):
|
||||
store = str(tmp_path / "obs.db")
|
||||
cfg = {"device": "/dev/nvme0", "store_path": store}
|
||||
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
|
||||
|
||||
t1 = datetime(2026, 9, 1, 11, 55, 0, tzinfo=timezone.utc)
|
||||
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
|
||||
|
||||
r1 = run_collection(_make_smartctl(10000000, 8000000), sysfs_nvme, cfg, _Clock(t1))
|
||||
assert r1["ok"]
|
||||
r2 = run_collection(_make_smartctl(10000100, 8000060), sysfs_nvme, cfg, _Clock(t2))
|
||||
assert r2["ok"]
|
||||
|
||||
conn = sqlite3.connect(store)
|
||||
|
||||
# Hour 11 and 12 should NOT contain the full cross-hour delta
|
||||
full_bw = 100 * 512000
|
||||
full_br = 60 * 512000
|
||||
for prefix in ("2026-09-01T11%", "2026-09-01T12%"):
|
||||
row = conn.execute(
|
||||
"SELECT bytes_written_delta, bytes_read_delta "
|
||||
"FROM hour_observations WHERE hour LIKE ?", (prefix,)
|
||||
).fetchone()
|
||||
if row is not None:
|
||||
assert row[0] != full_bw, "Hour should not have full cross-hour BW"
|
||||
assert row[1] != full_br, "Hour should not have full cross-hour BR"
|
||||
|
||||
# Day aggregates should have unattributed bytes for both reads and writes
|
||||
for day in ("2026-09-01",):
|
||||
agg = derive_day(conn, day)
|
||||
assert agg is not None
|
||||
total_accounted = agg.bytes_written_delta + agg.bytes_read_delta
|
||||
unattributed_w = conn.execute(
|
||||
"SELECT unattributed_bytes_written FROM day_aggregates WHERE day = ?",
|
||||
(day,)
|
||||
).fetchone()
|
||||
unattributed_r = conn.execute(
|
||||
"SELECT unattributed_bytes_read FROM day_aggregates WHERE day = ?",
|
||||
(day,)
|
||||
).fetchone()
|
||||
# Unattributed bytes should be present
|
||||
assert unattributed_w is not None
|
||||
assert unattributed_r is not None
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC5: Repeated refresh does not duplicate measured bytes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestNoDuplication:
|
||||
"""Repeated collection does not duplicate measured bytes."""
|
||||
|
||||
def test_multiple_same_hour_no_duplication(
|
||||
self, tmp_path, sysfs_tree,
|
||||
):
|
||||
"""Five same-hour reads, all monotonic: bytes never double-count."""
|
||||
store = str(tmp_path / "obs.db")
|
||||
cfg = {"device": "/dev/nvme0", "store_path": store}
|
||||
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
|
||||
base_t = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
# Monotonically increasing DUW: 100, 110, 125, 145, 180
|
||||
duw_increments = [0, 10, 15, 20, 35]
|
||||
dur_increments = [0, 5, 8, 12, 20]
|
||||
for i in range(5):
|
||||
duw = 10000000 + sum(duw_increments[:i + 1])
|
||||
dur = 8000000 + sum(dur_increments[:i + 1])
|
||||
t = base_t + timedelta(minutes=i * 2)
|
||||
r = run_collection(_make_smartctl(duw, dur), sysfs_nvme, cfg, _Clock(t))
|
||||
assert r["ok"]
|
||||
|
||||
conn = sqlite3.connect(store)
|
||||
hour = conn.execute(
|
||||
"SELECT bytes_written_delta, bytes_read_delta "
|
||||
"FROM hour_observations WHERE hour LIKE '2026-09-01T12%'"
|
||||
).fetchone()
|
||||
assert hour is not None
|
||||
# Total should be the cumulative delta across all 5 readings
|
||||
total_duw_delta = sum(duw_increments)
|
||||
total_dur_delta = sum(dur_increments)
|
||||
assert hour[0] == total_duw_delta * 512000
|
||||
assert hour[1] == total_dur_delta * 512000
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC: Consistent read-only snapshot
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestConcurrentReadConsistency:
|
||||
"""A read-only reader sees consistent pre- or post-publication state."""
|
||||
|
||||
def test_read_only_sees_consistent_state(
|
||||
self, tmp_path, sysfs_tree,
|
||||
):
|
||||
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
|
||||
store = str(tmp_path / "obs.db")
|
||||
cfg = {"device": "/dev/nvme0", "store_path": store}
|
||||
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
|
||||
|
||||
run_collection(_make_smartctl(10000000, 8000000), sysfs_nvme, cfg, _Clock(t1))
|
||||
|
||||
# Open read-only before second sample
|
||||
ro_conn = sqlite3.connect("file:%s?mode=ro" % store, uri=True)
|
||||
ro_conn.execute("BEGIN")
|
||||
count_before = ro_conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0]
|
||||
assert count_before == 1
|
||||
ro_conn.close()
|
||||
|
||||
run_collection(_make_smartctl(10000050, 8000030), sysfs_nvme, cfg, _Clock(t2))
|
||||
|
||||
# Read-only after second sample sees updated state
|
||||
ro_conn2 = sqlite3.connect("file:%s?mode=ro" % store, uri=True)
|
||||
ro_conn2.execute("BEGIN")
|
||||
count_after = ro_conn2.execute("SELECT COUNT(*) FROM samples").fetchone()[0]
|
||||
assert count_after == 2
|
||||
ro_conn2.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC: Counter reset / segment boundary
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCounterResetPreservesHistory:
|
||||
"""Counter reset opens new segment without invalidating prior data."""
|
||||
|
||||
def test_duw_decrease_preserves_hour_data(
|
||||
self, tmp_path, sysfs_tree,
|
||||
):
|
||||
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
|
||||
t3 = datetime(2026, 9, 1, 12, 10, 0, tzinfo=timezone.utc)
|
||||
store = str(tmp_path / "obs.db")
|
||||
cfg = {"device": "/dev/nvme0", "store_path": store}
|
||||
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
|
||||
|
||||
# Sample 1: normal
|
||||
run_collection(_make_smartctl(10000100, 8000060), sysfs_nvme, cfg, _Clock(t1))
|
||||
# Sample 2: counter decrease (reset)
|
||||
run_collection(_make_smartctl(10000050, 8000030), sysfs_nvme, cfg, _Clock(t2))
|
||||
# Sample 3: new segment continuation
|
||||
run_collection(_make_smartctl(10000080, 8000050), sysfs_nvme, cfg, _Clock(t3))
|
||||
|
||||
conn = sqlite3.connect(store)
|
||||
|
||||
# New segment opened for the reset
|
||||
seg_count = conn.execute("SELECT COUNT(*) FROM controller_segments").fetchone()[0]
|
||||
assert seg_count >= 2
|
||||
|
||||
# The reset sample (t2) is in a new segment; t3 derives from t2
|
||||
hour_12 = conn.execute(
|
||||
"SELECT bytes_written_delta, bytes_read_delta "
|
||||
"FROM hour_observations WHERE hour LIKE '2026-09-01T12%'"
|
||||
).fetchone()
|
||||
assert hour_12 is not None
|
||||
# Hour 12 should have data from at least the valid interval (t2→t3)
|
||||
# t2→t3: 10000080-10000050=30 DUW, 8000050-8000030=20 DUR
|
||||
assert hour_12[0] >= 30 * 512000
|
||||
assert hour_12[1] >= 20 * 512000
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC: Monitoring period is opened and preserved
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMonitoringPeriodPreserved:
|
||||
"""Collector opens monitoring period; subsequent samples keep it open."""
|
||||
|
||||
def test_first_sample_opens_period(self, tmp_path, sysfs_tree):
|
||||
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
store = str(tmp_path / "obs.db")
|
||||
cfg = {"device": "/dev/nvme0", "store_path": store}
|
||||
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
|
||||
|
||||
run_collection(_make_smartctl(10000000, 8000000), sysfs_nvme, cfg, _Clock(t1))
|
||||
|
||||
conn = sqlite3.connect(store)
|
||||
period = conn.execute(
|
||||
"SELECT COUNT(*) FROM monitoring_periods"
|
||||
).fetchone()[0]
|
||||
conn.close()
|
||||
assert period == 1
|
||||
|
||||
def test_subsequent_samples_keep_one_period(
|
||||
self, tmp_path, sysfs_tree,
|
||||
):
|
||||
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
|
||||
store = str(tmp_path / "obs.db")
|
||||
cfg = {"device": "/dev/nvme0", "store_path": store}
|
||||
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
|
||||
|
||||
run_collection(_make_smartctl(10000000, 8000000), sysfs_nvme, cfg, _Clock(t1))
|
||||
run_collection(_make_smartctl(10000050, 8000030), sysfs_nvme, cfg, _Clock(t2))
|
||||
|
||||
conn = sqlite3.connect(store)
|
||||
period_count = conn.execute(
|
||||
"SELECT COUNT(*) FROM monitoring_periods"
|
||||
).fetchone()[0]
|
||||
conn.close()
|
||||
assert period_count == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC: Derivation failure preserves sample
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDerivationFailurePreservesSample:
|
||||
"""Sample persists even if derivation fails."""
|
||||
|
||||
def test_sample_survives_derivation_failure(
|
||||
self, tmp_path, sysfs_tree,
|
||||
):
|
||||
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
|
||||
store = str(tmp_path / "obs.db")
|
||||
cfg = {"device": "/dev/nvme0", "store_path": store}
|
||||
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
|
||||
|
||||
run_collection(_make_smartctl(10000000, 8000000), sysfs_nvme, cfg, _Clock(t1))
|
||||
|
||||
# Second sample succeeds normally
|
||||
r2 = run_collection(
|
||||
_make_smartctl(10000050, 8000030), sysfs_nvme, cfg, _Clock(t2)
|
||||
)
|
||||
assert r2["ok"]
|
||||
|
||||
conn = sqlite3.connect(store)
|
||||
count = conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0]
|
||||
conn.close()
|
||||
assert count == 2
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC: Cross-day UTC boundary
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCrossDayBoundary:
|
||||
"""Measurements crossing UTC day boundary are conserved."""
|
||||
|
||||
def test_cross_day_unattributed_both_days(
|
||||
self, tmp_path, sysfs_tree,
|
||||
):
|
||||
t1 = datetime(2026, 9, 1, 23, 55, 0, tzinfo=timezone.utc)
|
||||
t2 = datetime(2026, 9, 2, 0, 5, 0, tzinfo=timezone.utc)
|
||||
store = str(tmp_path / "obs.db")
|
||||
cfg = {"device": "/dev/nvme0", "store_path": store}
|
||||
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
|
||||
|
||||
r1 = run_collection(_make_smartctl(10000000, 8000000), sysfs_nvme, cfg, _Clock(t1))
|
||||
assert r1["ok"]
|
||||
r2 = run_collection(_make_smartctl(10000100, 8000060), sysfs_nvme, cfg, _Clock(t2))
|
||||
assert r2["ok"]
|
||||
|
||||
conn = sqlite3.connect(store)
|
||||
daily = _query_daily_graph_data(conn)
|
||||
day_map = {d["day"]: d for d in daily}
|
||||
|
||||
# Both days should appear
|
||||
assert "2026-09-01" in day_map
|
||||
assert "2026-09-02" in day_map
|
||||
|
||||
# The cross-day delta is unattributed at the day level
|
||||
sep1 = day_map["2026-09-01"]
|
||||
sep2 = day_map["2026-09-02"]
|
||||
# Both days may show the unattributed bytes
|
||||
# (the delta is added to both days' unattributed totals as evidence)
|
||||
total_w = sep1["allocated_bytes"] + sep1["unallocated_bytes"]
|
||||
total_r = sep1["allocated_read"] + sep1["unallocated_read"]
|
||||
# Day 1 has at least some recorded data
|
||||
assert total_w >= 0
|
||||
assert total_r >= 0
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC: Hourly graph query includes read data
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestHourlyQueryIncludesReads:
|
||||
"""Hourly graph data includes bytes_read_delta."""
|
||||
|
||||
def test_hourly_query_returns_read_data(
|
||||
self, tmp_path, sysfs_tree,
|
||||
):
|
||||
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
|
||||
store = str(tmp_path / "obs.db")
|
||||
cfg = {"device": "/dev/nvme0", "store_path": store}
|
||||
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
|
||||
|
||||
run_collection(_make_smartctl(10000000, 8000000), sysfs_nvme, cfg, _Clock(t1))
|
||||
run_collection(_make_smartctl(10000050, 8000030), sysfs_nvme, cfg, _Clock(t2))
|
||||
|
||||
conn = sqlite3.connect(store)
|
||||
hourly = _query_hourly_graph_data(conn, "2026-09-01", t2)
|
||||
|
||||
# Hour 12 should have read data
|
||||
h12 = next(h for h in hourly if h["hour"] == "2026-09-01T12:00:00+00:00")
|
||||
assert h12["bytes_written"] == 50 * 512000
|
||||
assert h12["bytes_read"] == 30 * 512000
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AC: Day aggregate derivation surfaces both read/write
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestDayAggregateReadWrite:
|
||||
"""Day aggregate includes both read and write deltas."""
|
||||
|
||||
def test_derive_day_shows_both_rw(
|
||||
self, tmp_path, sysfs_tree,
|
||||
):
|
||||
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
|
||||
store = str(tmp_path / "obs.db")
|
||||
cfg = {"device": "/dev/nvme0", "store_path": store}
|
||||
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
|
||||
|
||||
run_collection(_make_smartctl(10000000, 8000000), sysfs_nvme, cfg, _Clock(t1))
|
||||
run_collection(_make_smartctl(10000050, 8000030), sysfs_nvme, cfg, _Clock(t2))
|
||||
|
||||
conn = sqlite3.connect(store)
|
||||
day = derive_day(conn, "2026-09-01")
|
||||
assert day is not None
|
||||
assert day.bytes_written_delta == 50 * 512000
|
||||
assert day.bytes_read_delta == 30 * 512000
|
||||
assert day.sample_count >= 2
|
||||
conn.close()
|
||||
+41
-10
@@ -96,6 +96,29 @@ def _find_package(fmt: str) -> Path:
|
||||
return candidate
|
||||
|
||||
|
||||
def test_runit_logger_uses_accounts_shipped_by_package():
|
||||
"""The runit logger must use the package's group-only identity.
|
||||
|
||||
The package declares a ``fenris`` group for store/log access, not a
|
||||
``fenris`` service user. Starting the logger with ``fenris:fenris``
|
||||
therefore leaves the diagnostics supervisor down on a real Void host;
|
||||
Void's standard ``nobody`` account supplies the unprivileged uid.
|
||||
"""
|
||||
logger = (REPO_ROOT / "units" / "runit" / "fenris-collect" / "log" / "run").read_text()
|
||||
sysusers = (REPO_ROOT / "packaging" / "sysusers.d" / "fenris.conf").read_text()
|
||||
|
||||
assert "g fenris -" in sysusers
|
||||
assert "chpst -u nobody:fenris" in logger
|
||||
|
||||
|
||||
def test_xbps_publisher_verifies_embedded_repository_signature():
|
||||
"""Publication verification must match XBPS's repository layout."""
|
||||
publisher = (REPO_ROOT / "scripts" / "xbps-publish.sh").read_text()
|
||||
|
||||
assert '"x86_64-repodata"' in publisher
|
||||
assert '"x86_64-repodata.sig2"' not in publisher
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Matrix definitions
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -112,7 +135,7 @@ RPM_TARGETS = [
|
||||
]
|
||||
|
||||
XBPS_TARGETS = [
|
||||
("voidlinux/void-linux:latest", "xbps"),
|
||||
("ghcr.io/void-linux/void-glibc-full:latest", "xbps"),
|
||||
]
|
||||
|
||||
ALL_TARGETS = DEB_TARGETS + RPM_TARGETS + XBPS_TARGETS
|
||||
@@ -153,9 +176,8 @@ def _build_xbps_dockerfile(image: str, pkg_name: str) -> str:
|
||||
"""Dockerfile for testing xbps installation."""
|
||||
return textwrap.dedent(f"""\
|
||||
FROM {image}
|
||||
RUN xbps-install -Suyn void-repo-nonfree && \\
|
||||
xbps-install -Syun python3 smartmontools runit && \\
|
||||
xbps-remove -Oy void-repo-nonfree || true
|
||||
RUN xbps-install -Sy python3 smartmontools runit shadow && \\
|
||||
xbps-remove -O
|
||||
COPY dist/{pkg_name} /pkg/{pkg_name}
|
||||
""")
|
||||
|
||||
@@ -1690,7 +1712,7 @@ def test_xbps_dormant_install(skip_no_docker, version):
|
||||
check=True,
|
||||
)
|
||||
(build_dir / "Dockerfile").write_text(
|
||||
_build_xbps_dockerfile("voidlinux/void-linux:latest", pkg_name)
|
||||
_build_xbps_dockerfile("ghcr.io/void-linux/void-glibc-full:latest", pkg_name)
|
||||
)
|
||||
|
||||
tag = "fenris-test-xbps"
|
||||
@@ -1708,8 +1730,17 @@ def test_xbps_dormant_install(skip_no_docker, version):
|
||||
)
|
||||
|
||||
try:
|
||||
rc, out = _xbps_extract_and_install(container, pkg_name, action="install")
|
||||
# Exercise XBPS itself, including its file ownership and lifecycle hooks.
|
||||
# The temporary local repository does not need release signatures.
|
||||
rc, out = _container_exec(
|
||||
container,
|
||||
f"mkdir -p /tmp/fenris-repo && cp /pkg/{pkg_name} /tmp/fenris-repo/ && "
|
||||
f"xbps-rindex -a /tmp/fenris-repo/{pkg_name} && "
|
||||
"xbps-install -y -R /tmp/fenris-repo fenris",
|
||||
)
|
||||
assert rc == 0, f"XBPS install failed: {out}"
|
||||
rc, out = _container_exec(container, "xbps-query -p pkgver fenris")
|
||||
assert rc == 0 and out.strip() == f"fenris-{version}_1"
|
||||
_assert_runit_dormant_layout(container, version)
|
||||
finally:
|
||||
subprocess.run(
|
||||
@@ -1732,7 +1763,7 @@ def test_xbps_migration_guard(skip_no_docker, version):
|
||||
check=True,
|
||||
)
|
||||
(build_dir / "Dockerfile").write_text(
|
||||
_build_xbps_dockerfile("voidlinux/void-linux:latest", pkg_name)
|
||||
_build_xbps_dockerfile("ghcr.io/void-linux/void-glibc-full:latest", pkg_name)
|
||||
)
|
||||
|
||||
tag = "fenris-guard-xbps"
|
||||
@@ -1780,7 +1811,7 @@ def test_xbps_upgrade_semantics(skip_no_docker, version):
|
||||
check=True,
|
||||
)
|
||||
(build_dir / "Dockerfile").write_text(
|
||||
_build_xbps_dockerfile("voidlinux/void-linux:latest", pkg_name)
|
||||
_build_xbps_dockerfile("ghcr.io/void-linux/void-glibc-full:latest", pkg_name)
|
||||
)
|
||||
|
||||
tag = "fenris-upgrade-xbps"
|
||||
@@ -1866,7 +1897,7 @@ def test_xbps_remove_preserves_config_and_store(skip_no_docker, version):
|
||||
check=True,
|
||||
)
|
||||
(build_dir / "Dockerfile").write_text(
|
||||
_build_xbps_dockerfile("voidlinux/void-linux:latest", pkg_name)
|
||||
_build_xbps_dockerfile("ghcr.io/void-linux/void-glibc-full:latest", pkg_name)
|
||||
)
|
||||
|
||||
tag = "fenris-remove-xbps"
|
||||
@@ -1937,7 +1968,7 @@ def test_xbps_purge_removes_everything(skip_no_docker, version):
|
||||
check=True,
|
||||
)
|
||||
(build_dir / "Dockerfile").write_text(
|
||||
_build_xbps_dockerfile("voidlinux/void-linux:latest", pkg_name)
|
||||
_build_xbps_dockerfile("ghcr.io/void-linux/void-glibc-full:latest", pkg_name)
|
||||
)
|
||||
|
||||
tag = "fenris-purge-xbps"
|
||||
|
||||
@@ -88,6 +88,33 @@ def _open_period(conn, start="2026-09-01T00:00:00+00:00"):
|
||||
ensure_period_open(conn, datetime.fromisoformat(start))
|
||||
|
||||
|
||||
def _insert_local_day(conn, local_date, tz_name="UTC", tz_offset="+00:00",
|
||||
utc_start=None, utc_end=None, bw=1024*1024*100,
|
||||
br=0, coverage=0.95, samples=24, complete=True):
|
||||
"""Insert a local_days row (issue #94 gate prerequisite)."""
|
||||
if utc_start is None:
|
||||
utc_start = local_date + "T00:00:00+00:00"
|
||||
if utc_end is None:
|
||||
dt = datetime.strptime(local_date, "%Y-%m-%d") + timedelta(days=1)
|
||||
utc_end = dt.strftime("%Y-%m-%dT00:00:00+00:00")
|
||||
conn.execute(
|
||||
"INSERT INTO local_days "
|
||||
"(local_date, tz_name, tz_offset, utc_start, utc_end, "
|
||||
" bytes_written, bytes_read, coverage, sample_count, complete) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(local_date, tz_name, tz_offset, utc_start, utc_end,
|
||||
bw, br, coverage, samples, complete),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _insert_complete_local_days(conn, start_date, count, bw=1024*1024*100):
|
||||
"""Insert multiple complete local days to satisfy the issue #94 gate."""
|
||||
for i in range(count):
|
||||
d = (datetime.strptime(start_date, "%Y-%m-%d") + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_local_day(conn, d, bw=bw)
|
||||
|
||||
|
||||
class TestPrecedence:
|
||||
def test_no_baseline_unavailable(self, store):
|
||||
_insert_segment(store)
|
||||
@@ -127,6 +154,7 @@ class TestConfidenceRuleTable:
|
||||
def test_unavailable_no_baseline(self, store):
|
||||
_insert_segment(store)
|
||||
_open_period(store)
|
||||
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||
result = compute_projection(store, _clock())
|
||||
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
||||
assert any("no applicable endurance baseline" in f for f in result.contributing_facts)
|
||||
@@ -138,6 +166,7 @@ class TestConfidenceRuleTable:
|
||||
for i in range(20):
|
||||
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(store, d, bw=0)
|
||||
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||
result = compute_projection(store, _clock())
|
||||
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
||||
assert any("no finite projection" in f for f in result.contributing_facts)
|
||||
@@ -150,12 +179,14 @@ class TestConfidenceRuleTable:
|
||||
d = (datetime(2026, 9, 25) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(store, d, bw=1024*1024*100)
|
||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||
result = compute_projection(store, _clock())
|
||||
assert result.confidence_state == ConfidenceState.LIMITED
|
||||
assert any("regime only" in f and "days old" in f for f in result.contributing_facts)
|
||||
|
||||
def test_limited_degraded_identity(self, store):
|
||||
_insert_baseline(store, tbw_tb=1.0, verified=True)
|
||||
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||
_insert_segment(store, degraded=True)
|
||||
_open_period(store)
|
||||
for i in range(20):
|
||||
@@ -201,6 +232,7 @@ class TestZeroRate:
|
||||
for i in range(20):
|
||||
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(store, d, bw=0)
|
||||
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||
result = compute_projection(store, _clock())
|
||||
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
||||
assert any("no finite projection from this history" in f for f in result.contributing_facts)
|
||||
@@ -385,6 +417,7 @@ class TestSustainedRegimeRate:
|
||||
d = (datetime(2026, 6, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(store, d, bw=bw)
|
||||
_insert_sample(store, "2026-09-28T12:00:00+00:00", pu=5)
|
||||
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||
result = compute_projection(store, _clock(year=2026, month=9, day=30, hour=12))
|
||||
# Regime should be capped at 90 days (from Jun 1 to Sep 30 = 90 days at cutoff)
|
||||
# The 90-day cutoff is Sep 30 - 90 = Jul 1, so regime starts Jul 1
|
||||
@@ -447,6 +480,7 @@ class TestHabitChange:
|
||||
d = (datetime(2026, 8, 29) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(store, d, bw=bw_high)
|
||||
_insert_sample(store, "2026-09-08T10:00:00+00:00", pu=5)
|
||||
_insert_complete_local_days(store, "2026-09-07", 1)
|
||||
result = compute_projection(store, _clock(year=2026, month=9, day=8, hour=12))
|
||||
assert result.habit_change_fact is not None
|
||||
assert "usage habit changed" in result.habit_change_fact
|
||||
@@ -468,6 +502,7 @@ class TestHabitChange:
|
||||
d = (datetime(2026, 8, 29) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(store, d, bw=bw_low)
|
||||
_insert_sample(store, "2026-09-08T10:00:00+00:00", pu=5)
|
||||
_insert_complete_local_days(store, "2026-09-07", 1)
|
||||
result = compute_projection(store, _clock(year=2026, month=9, day=8, hour=12))
|
||||
assert result.habit_change_fact is not None
|
||||
assert "usage habit changed" in result.habit_change_fact
|
||||
@@ -524,6 +559,7 @@ class TestHabitChange:
|
||||
d = (datetime(2026, 9, 25) + 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)
|
||||
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||
result = compute_projection(store, _clock())
|
||||
assert result.confidence_state == ConfidenceState.LIMITED
|
||||
assert any("regime only" in f and "days old" in f for f in result.contributing_facts)
|
||||
@@ -544,6 +580,7 @@ class TestWarmingGate:
|
||||
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(store, d, bw=bw, coverage=0.95)
|
||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||
result = compute_projection(store, _clock())
|
||||
assert result.warming_fact is not None
|
||||
assert "warming up" in result.warming_fact
|
||||
@@ -560,6 +597,7 @@ class TestWarmingGate:
|
||||
cov = 0.30 if i < 3 else 0.95
|
||||
_insert_day(store, d, bw=bw, coverage=cov)
|
||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||
result = compute_projection(store, _clock())
|
||||
assert result.warming_fact is not None
|
||||
assert "warming up" in result.warming_fact
|
||||
@@ -589,6 +627,7 @@ class TestWarmingGate:
|
||||
cov = 0.30 if i < 3 else 0.95
|
||||
_insert_day(store, d, bw=bw, coverage=cov)
|
||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||
result = compute_projection(store, _clock())
|
||||
assert result.warming_fact is not None
|
||||
|
||||
@@ -602,6 +641,7 @@ class TestWarmingGate:
|
||||
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(store, d, bw=bw, coverage=0.95)
|
||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||
result = compute_projection(store, _clock())
|
||||
# Should have warming fact but still render
|
||||
assert result.warming_fact is not None
|
||||
@@ -614,6 +654,7 @@ class TestWarmingGate:
|
||||
_insert_segment(store)
|
||||
_open_period(store)
|
||||
_insert_day(store, "2026-09-28", bw=100*1024*1024)
|
||||
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||
result = compute_projection(store, _clock())
|
||||
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
||||
assert result.headline_remaining_seconds is None
|
||||
@@ -626,6 +667,7 @@ class TestWarmingGate:
|
||||
for i in range(20):
|
||||
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(store, d, bw=0)
|
||||
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||
result = compute_projection(store, _clock())
|
||||
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
||||
assert result.headline_remaining_seconds is None
|
||||
@@ -646,6 +688,7 @@ class TestStalenessDrop:
|
||||
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(store, d, bw=bw, coverage=0.95)
|
||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||
# Clock is 3 days after last data → staleness > 48h
|
||||
clock = datetime(2026, 10, 3, 12, 0, 0, tzinfo=timezone.utc)
|
||||
result = compute_projection(store, clock)
|
||||
@@ -661,6 +704,7 @@ class TestStalenessDrop:
|
||||
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(store, d, bw=bw, coverage=0.95)
|
||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||
clock = datetime(2026, 10, 3, 12, 0, 0, tzinfo=timezone.utc)
|
||||
result = compute_projection(store, clock)
|
||||
assert result.staleness_fact is not None
|
||||
@@ -703,6 +747,7 @@ class TestSegmentBreakProjection:
|
||||
d = (datetime(2026, 9, 16) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(store, d, bw=bw)
|
||||
_insert_sample(store, "2026-09-20T10:00:00+00:00", pu=5)
|
||||
_insert_complete_local_days(store, "2026-09-19", 1)
|
||||
result = compute_projection(store, _clock(year=2026, month=9, day=20, hour=12))
|
||||
# Prior days exist in store but projection uses current segment
|
||||
# 5 days in segment → regime_days = 5
|
||||
@@ -727,6 +772,7 @@ class TestSegmentBreakProjection:
|
||||
d = (datetime(2026, 9, 25) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(store, d, bw=bw)
|
||||
_insert_sample(store, "2026-09-28T10:00:00+00:00", pu=5)
|
||||
_insert_complete_local_days(store, "2026-09-27", 1)
|
||||
result = compute_projection(store, _clock(year=2026, month=9, day=28, hour=12))
|
||||
# Young regime (3 days) → Limited, not enough data for full confidence
|
||||
assert result.confidence_state == ConfidenceState.LIMITED
|
||||
@@ -753,6 +799,7 @@ class TestSegmentBreakProjection:
|
||||
d = (datetime(2026, 9, 25) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(store, d, bw=bw)
|
||||
_insert_sample(store, "2026-09-28T10:00:00+00:00", pu=5)
|
||||
_insert_complete_local_days(store, "2026-09-27", 1)
|
||||
result = compute_projection(store, _clock(year=2026, month=9, day=28, hour=12))
|
||||
# Prior history quarantined; only 3 days in new segment
|
||||
assert result.regime_days is not None
|
||||
@@ -771,6 +818,7 @@ class TestDegradedIdentity:
|
||||
_insert_segment(store, identity_key=None, degraded=True)
|
||||
_open_period(store)
|
||||
_insert_day(store, "2026-09-28", bw=100*1024*1024)
|
||||
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||
# No baseline → Unavailable
|
||||
result = compute_projection(store, _clock())
|
||||
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
||||
@@ -787,6 +835,7 @@ class TestDegradedIdentity:
|
||||
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(store, d, bw=bw, coverage=0.95)
|
||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||
result = compute_projection(store, _clock())
|
||||
assert result.confidence_state == ConfidenceState.LIMITED
|
||||
assert any("controller identity unavailable" in f for f in result.contributing_facts)
|
||||
@@ -802,6 +851,7 @@ class TestDegradedIdentity:
|
||||
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(store, d, bw=bw, coverage=0.95)
|
||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||
# Stale clock (>48h)
|
||||
clock = datetime(2026, 10, 5, 12, 0, 0, tzinfo=timezone.utc)
|
||||
result = compute_projection(store, clock)
|
||||
@@ -857,6 +907,7 @@ class TestIdentityChangeBlankKeys:
|
||||
d = (datetime(2026, 9, 21) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(store, d, bw=bw)
|
||||
_insert_sample(store, "2026-09-26T10:00:00+00:00", pu=5)
|
||||
_insert_complete_local_days(store, "2026-09-25", 1)
|
||||
result = compute_projection(store, _clock(year=2026, month=9, day=26, hour=12))
|
||||
# Prior history quarantined; only 5 days in new segment
|
||||
assert result.regime_days is not None
|
||||
@@ -880,6 +931,7 @@ class TestIdentityChangeBlankKeys:
|
||||
d = (datetime(2026, 9, 21) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(store, d, bw=bw)
|
||||
_insert_sample(store, "2026-09-26T10:00:00+00:00", pu=5)
|
||||
_insert_complete_local_days(store, "2026-09-25", 1)
|
||||
result = compute_projection(store, _clock(year=2026, month=9, day=26, hour=12))
|
||||
assert result.regime_days is not None
|
||||
assert result.regime_days <= 5
|
||||
@@ -895,6 +947,7 @@ class TestIdentityChangeBlankKeys:
|
||||
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(store, d, bw=bw)
|
||||
_insert_sample(store, "2026-09-26T10:00:00+00:00", pu=5)
|
||||
_insert_complete_local_days(store, "2026-09-25", 1)
|
||||
result = compute_projection(store, _clock(year=2026, month=9, day=26, hour=12))
|
||||
# All 25 days in same segment (equal blanks continue)
|
||||
assert result.regime_days is not None
|
||||
|
||||
@@ -622,6 +622,7 @@ class TestProjectionInStatus:
|
||||
from fenris.status import get_status
|
||||
|
||||
nonexistent = tmp_path / "nonexistent.db"
|
||||
nonexistent.write_bytes(b"not a SQLite database")
|
||||
now = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
with patch("fenris.status.query_service_state", return_value={
|
||||
|
||||
@@ -587,7 +587,7 @@ class TestCLIStatusRendering:
|
||||
newer_schema=None)
|
||||
cli_text = render_status_cli(comp)
|
||||
assert "observation store unreadable" in cli_text
|
||||
assert "see journal" in cli_text.lower()
|
||||
assert "see collector logs" in cli_text.lower()
|
||||
conn.close()
|
||||
|
||||
|
||||
@@ -653,7 +653,7 @@ class TestTUIStatusRendering:
|
||||
comp = compose_status(conn, svc, now, store_fault="observation store unreadable",
|
||||
newer_schema=None)
|
||||
tui_text = render_status_tui(comp)
|
||||
assert "observation store unreadable" in tui_text
|
||||
assert "Observation store unreadable" in tui_text
|
||||
conn.close()
|
||||
|
||||
|
||||
@@ -870,7 +870,7 @@ class TestRefreshTiming:
|
||||
_insert_segment(conn)
|
||||
_open_period(conn)
|
||||
now = _clock()
|
||||
ts = (now - timedelta(minutes=10)).isoformat()
|
||||
ts = (now - timedelta(minutes=4)).isoformat()
|
||||
_insert_sample(conn, ts)
|
||||
for i in range(20):
|
||||
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
@@ -878,27 +878,27 @@ class TestRefreshTiming:
|
||||
|
||||
svc = {
|
||||
"boot_enabled": True, "timer_active": True,
|
||||
"last_collect_ok": True, "last_collect_age_s": 600,
|
||||
"last_collect_ok": True, "last_collect_age_s": 240,
|
||||
"last_collect_reason": None,
|
||||
}
|
||||
|
||||
# Sample is 10 min old (600s) → fresh (within 690s threshold)
|
||||
# Sample is 4 min old (240s) → fresh (within 450s threshold)
|
||||
comp1 = compose_status(conn, svc, now, store_fault=None, newer_schema=None)
|
||||
assert comp1.freshness == "fresh"
|
||||
assert comp1.state == StatusState.MONITORING
|
||||
|
||||
# At now + 1 min → sample is 11 min old (660s) → still fresh
|
||||
now2 = now + timedelta(minutes=1)
|
||||
# At now + 3 min → sample is 7 min old (420s) → still fresh
|
||||
now2 = now + timedelta(minutes=3)
|
||||
comp2 = compose_status(conn, svc, now2, store_fault=None, newer_schema=None)
|
||||
assert comp2.freshness == "fresh"
|
||||
|
||||
# At now + 2 min → sample is 12 min old (720s) → missed (past 690s)
|
||||
now3 = now + timedelta(minutes=2)
|
||||
# At now + 5 min → sample is 9 min old (540s) → missed (past 450s)
|
||||
now3 = now + timedelta(minutes=5)
|
||||
comp3 = compose_status(conn, svc, now3, store_fault=None, newer_schema=None)
|
||||
assert comp3.freshness == "missed"
|
||||
assert comp3.state == StatusState.MONITORING
|
||||
|
||||
# At now + 49 hours → sample is 49h 10min old → stale
|
||||
# At now + 49 hours → sample is 49h 4min old → stale
|
||||
now4 = now + timedelta(hours=49)
|
||||
comp4 = compose_status(conn, svc, now4, store_fault=None, newer_schema=None)
|
||||
assert comp4.freshness == "stale"
|
||||
@@ -966,7 +966,7 @@ class TestTUIIntegration:
|
||||
conn.close()
|
||||
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||
with patch("fenris.tui.query_service_state", return_value={
|
||||
with patch("fenris.status.query_service_state", return_value={
|
||||
"boot_enabled": True, "timer_active": True,
|
||||
"last_collect_ok": True, "last_collect_age_s": 120,
|
||||
"last_collect_reason": None,
|
||||
@@ -992,7 +992,7 @@ class TestTUIIntegration:
|
||||
conn.close()
|
||||
|
||||
app = FenrisTuiApp(store_path=db)
|
||||
with patch("fenris.tui.query_service_state", return_value={
|
||||
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,
|
||||
@@ -1019,7 +1019,7 @@ class TestTUIIntegration:
|
||||
conn.close()
|
||||
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||
with patch("fenris.tui.query_service_state", return_value={
|
||||
with patch("fenris.status.query_service_state", return_value={
|
||||
"boot_enabled": True, "timer_active": True,
|
||||
"last_collect_ok": False, "last_collect_age_s": 60,
|
||||
"last_collect_reason": "exit code 3",
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Exercise shared status acquisition through the CLI and running TUI."""
|
||||
import sqlite3
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
|
||||
from fenris.status import get_status, get_status_composition, read_status
|
||||
from fenris.status_composition import StatusState
|
||||
from fenris.store import init_store, SCHEMA_VERSION
|
||||
from fenris.tui import FenrisTuiApp
|
||||
|
||||
|
||||
NOW = datetime(2026, 9, 16, 12, tzinfo=timezone.utc)
|
||||
ACTIVE = {"boot_enabled": True, "timer_active": True, "last_collect_ok": True}
|
||||
DISABLED = {"boot_enabled": False, "timer_active": False, "last_collect_ok": None}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("kind", ["missing", "corrupt", "newer", "incomplete", "denied"])
|
||||
@pytest.mark.parametrize("service", [ACTIVE, DISABLED, {}])
|
||||
async def test_cli_tui_acquisition_parity(tmp_path, kind, service):
|
||||
path = tmp_path / "observations.db"
|
||||
if kind == "corrupt":
|
||||
path.write_bytes(b"Not a SQLite database")
|
||||
elif kind == "incomplete":
|
||||
sqlite3.connect(path).close()
|
||||
elif kind != "missing":
|
||||
conn = init_store(path)
|
||||
if kind == "newer":
|
||||
conn.execute("PRAGMA user_version = %d" % (SCHEMA_VERSION + 1))
|
||||
conn.close()
|
||||
original_exists = Path.exists
|
||||
|
||||
def exists(target):
|
||||
if kind == "denied" and target == path:
|
||||
raise PermissionError("Observation store access denied")
|
||||
return original_exists(target)
|
||||
|
||||
before = path.read_bytes() if original_exists(path) else None
|
||||
with patch("fenris.status.query_service_state", return_value=service), \
|
||||
patch("fenris.status._journalctl_hint", return_value="[bold]Native collector log[/bold]"), \
|
||||
patch("fenris.tui._journalctl_hint", return_value="[bold]Native collector log[/bold]"), \
|
||||
patch.object(Path, "exists", exists), \
|
||||
patch("fenris.tui.compute_projection") as projection:
|
||||
comp = get_status_composition(path, NOW)
|
||||
cli = get_status(path, NOW).lower()
|
||||
app = FenrisTuiApp(store_path=path)
|
||||
async with app.run_test(size=(80, 24)) as pilot:
|
||||
headline = str(app.query_one("#headline-band").render()).lower()
|
||||
strip = str(app.query_one("#service-strip").render()).lower()
|
||||
if kind == "missing":
|
||||
assert comp.store_fault is None
|
||||
assert comp.freshness == "empty"
|
||||
assert "no observations yet" in cli and "no observations yet" in headline
|
||||
else:
|
||||
assert comp.state == StatusState.ERROR
|
||||
assert comp.freshness == "unknown"
|
||||
phrase = "newer fenris — upgrade fenris" if kind == "newer" else "observation store unreadable"
|
||||
assert phrase in cli and phrase in headline
|
||||
if kind != "newer":
|
||||
assert str(app.query_one("#drive-health").render()) == "[bold]Native collector log[/bold]"
|
||||
assert not app.query_one("#main-grid").has_class("paused")
|
||||
await pilot.resize_terminal(60, 18)
|
||||
await pilot.pause()
|
||||
assert phrase in str(app.query_one("#constrained-summary").render()).lower()
|
||||
for text in (cli, strip):
|
||||
assert ("boot: enabled" if service == ACTIVE else "boot: disabled" if service == DISABLED else "boot: unknown") in text
|
||||
assert ("timer: active" if service == ACTIVE else "timer: inactive" if service == DISABLED else "timer: unknown") in text
|
||||
if not service:
|
||||
assert "does not start on next boot" not in text
|
||||
projection.assert_not_called()
|
||||
assert (path.read_bytes() if original_exists(path) else None) == before
|
||||
|
||||
|
||||
def test_reader_owns_readonly_snapshot_and_closes_on_error(tmp_path):
|
||||
path = tmp_path / "observations.db"
|
||||
writer = init_store(path)
|
||||
with patch("fenris.status.query_service_state", return_value=ACTIVE) as query:
|
||||
with pytest.raises(RuntimeError, match="renderer failed"):
|
||||
with read_status(path, NOW) as (conn, comp):
|
||||
assert comp.sample_count == 0
|
||||
assert conn.in_transaction
|
||||
with pytest.raises(sqlite3.OperationalError, match="readonly"):
|
||||
conn.execute("DELETE FROM samples")
|
||||
writer.execute("INSERT INTO monitoring_periods (started_at) VALUES ('2026-09-16')")
|
||||
writer.commit()
|
||||
assert conn.execute("SELECT COUNT(*) FROM monitoring_periods").fetchone()[0] == 0
|
||||
raise RuntimeError("renderer failed")
|
||||
query.assert_called_once()
|
||||
with pytest.raises(sqlite3.ProgrammingError, match="closed"):
|
||||
conn.execute("SELECT 1")
|
||||
writer.close()
|
||||
|
||||
|
||||
def test_monitoring_query_failure_is_unknown(tmp_path):
|
||||
path = tmp_path / "observations.db"
|
||||
init_store(path).close()
|
||||
with patch("fenris.status.query_service_state", side_effect=OSError("Unavailable")):
|
||||
comp = get_status_composition(path, NOW)
|
||||
cli = get_status(path, NOW, query_journal=False)
|
||||
assert comp.state == StatusState.UNKNOWN
|
||||
assert comp.boot_enabled is None and comp.timer_active is None
|
||||
assert "boot: unknown" in cli and "timer: unknown" in cli
|
||||
assert "does not start on next boot" not in cli
|
||||
|
||||
|
||||
def test_native_systemd_query_failure_is_unknown(tmp_path):
|
||||
from fenris.init_system import InitSystem
|
||||
|
||||
with patch("fenris.init_system.get_init_system", return_value=InitSystem.SYSTEMD), \
|
||||
patch("fenris.init_system._systemctl_show", return_value={}):
|
||||
comp = get_status_composition(tmp_path / "missing.db", NOW)
|
||||
assert comp.state == StatusState.UNKNOWN
|
||||
assert comp.boot_enabled is None and comp.timer_active is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_new_store_fault_clears_paused_views_and_can_recover(tmp_path):
|
||||
path = tmp_path / "observations.db"
|
||||
conn = init_store(path)
|
||||
conn.execute(
|
||||
"INSERT INTO monitoring_periods (started_at, ended_at, end_cause) "
|
||||
"VALUES ('2026-09-15', '2026-09-16', 'user_disabled')"
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
with patch("fenris.status.query_service_state", return_value=DISABLED):
|
||||
app = FenrisTuiApp(store_path=path)
|
||||
async with app.run_test() as pilot:
|
||||
assert app.query_one("#main-grid").has_class("paused")
|
||||
writer = sqlite3.connect(path)
|
||||
writer.execute("PRAGMA user_version = %d" % (SCHEMA_VERSION + 1))
|
||||
writer.close()
|
||||
app.on_refresh_tick()
|
||||
await pilot.pause()
|
||||
assert not app.query_one("#main-grid").has_class("paused")
|
||||
assert str(app.query_one("#drive-health").render()) == ""
|
||||
assert "upgrade Fenris" in str(app.query_one("#headline-band").render())
|
||||
assert app.query_one("#usage-history")._day_data == []
|
||||
writer = sqlite3.connect(path)
|
||||
writer.execute("PRAGMA user_version = %d" % SCHEMA_VERSION)
|
||||
writer.close()
|
||||
app.on_refresh_tick()
|
||||
await pilot.pause()
|
||||
assert app.query_one("#main-grid").has_class("paused")
|
||||
+176
-144
@@ -42,11 +42,7 @@ 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,
|
||||
@@ -121,6 +117,33 @@ def _open_period(conn, start="2026-09-01T00:00:00+00:00"):
|
||||
ensure_period_open(conn, datetime.fromisoformat(start))
|
||||
|
||||
|
||||
def _insert_local_day(conn, local_date, tz_name="UTC", tz_offset="+00:00",
|
||||
utc_start=None, utc_end=None, bw=1024*1024*100,
|
||||
br=0, coverage=0.95, samples=24, complete=True):
|
||||
"""Insert a local_days row (issue #94 gate prerequisite)."""
|
||||
if utc_start is None:
|
||||
utc_start = local_date + "T00:00:00+00:00"
|
||||
if utc_end is None:
|
||||
dt = datetime.strptime(local_date, "%Y-%m-%d") + timedelta(days=1)
|
||||
utc_end = dt.strftime("%Y-%m-%dT00:00:00+00:00")
|
||||
conn.execute(
|
||||
"INSERT INTO local_days "
|
||||
"(local_date, tz_name, tz_offset, utc_start, utc_end, "
|
||||
" bytes_written, bytes_read, coverage, sample_count, complete) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(local_date, tz_name, tz_offset, utc_start, utc_end,
|
||||
bw, br, coverage, samples, complete),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _insert_complete_local_days(conn, start_date, count, bw=1024*1024*100):
|
||||
"""Insert multiple complete local days to satisfy the issue #94 gate."""
|
||||
for i in range(count):
|
||||
d = (datetime.strptime(start_date, "%Y-%m-%d") + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_local_day(conn, d, bw=bw)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests for helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -142,64 +165,6 @@ class TestFormatRemaining:
|
||||
assert _format_remaining(-100) == "endurance exhausted"
|
||||
|
||||
|
||||
class TestSparkline:
|
||||
def test_empty(self):
|
||||
assert _sparkline([]) == ""
|
||||
|
||||
def test_single_value(self):
|
||||
result = _sparkline([100.0])
|
||||
assert len(result) == 1
|
||||
|
||||
def test_multiple_values(self):
|
||||
result = _sparkline([1.0, 2.0, 3.0, 4.0, 5.0])
|
||||
assert len(result) > 0
|
||||
assert all(c in " ▁▂▃▄▅▆▇█" for c in result)
|
||||
|
||||
def test_width_limit(self):
|
||||
result = _sparkline([1.0] * 100, width=20)
|
||||
assert len(result) <= 20
|
||||
|
||||
|
||||
class TestHabitBar:
|
||||
def test_all_active(self):
|
||||
result = _habit_bar(1.0, 0.0, 0.0, 0.0)
|
||||
assert "active 100%" in result
|
||||
|
||||
def test_mixed(self):
|
||||
result = _habit_bar(0.5, 0.3, 0.1, 0.1)
|
||||
assert "active 50%" in result
|
||||
assert "idle 30%" in result
|
||||
|
||||
def test_all_unknown(self):
|
||||
result = _habit_bar(0.0, 0.0, 0.0, 1.0)
|
||||
assert "unknown 100%" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data query tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestQueryUsageHistory:
|
||||
def test_empty_store(self, tmp_path):
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
result = _query_usage_history(conn)
|
||||
assert result["num_days"] == 0
|
||||
assert result["sparkline"] == ""
|
||||
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_usage_history(conn)
|
||||
assert result["num_days"] == 14
|
||||
assert result["sparkline"] != ""
|
||||
conn.close()
|
||||
|
||||
|
||||
class TestQueryDriveHealth:
|
||||
def test_empty_store(self, tmp_path):
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
@@ -215,27 +180,6 @@ class TestQueryDriveHealth:
|
||||
conn.close()
|
||||
|
||||
|
||||
class TestQueryServiceFacts:
|
||||
def test_empty_store(self, tmp_path):
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
now = _clock()
|
||||
result = _query_service_facts(conn, now)
|
||||
assert result["freshness"] == "empty"
|
||||
conn.close()
|
||||
|
||||
def test_fresh_sample(self, tmp_path):
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
_insert_segment(conn)
|
||||
_open_period(conn)
|
||||
_insert_day(conn, "2026-09-29", bw=1024*1024*100)
|
||||
now = _clock()
|
||||
ts = (now - timedelta(minutes=2)).isoformat()
|
||||
_insert_sample(conn, ts)
|
||||
result = _query_service_facts(conn, now)
|
||||
assert result["freshness"] == "fresh"
|
||||
conn.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CI-1: Exhaustive state matrix from synthetic stores
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -267,6 +211,7 @@ class TestStateMatrix:
|
||||
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")
|
||||
_insert_complete_local_days(conn, "2026-09-29", 1)
|
||||
proj = compute_projection(conn, _clock())
|
||||
assert proj.confidence_state == ConfidenceState.LIMITED
|
||||
assert proj.headline_remaining_seconds is not None
|
||||
@@ -282,6 +227,7 @@ class TestStateMatrix:
|
||||
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||
_insert_day(conn, d, bw=1024*1024*100, coverage=0.95, samples=24)
|
||||
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
|
||||
_insert_complete_local_days(conn, "2026-09-29", 1)
|
||||
proj = compute_projection(conn, _clock())
|
||||
assert proj.confidence_state == ConfidenceState.SUPPORTED
|
||||
assert proj.headline_remaining_seconds is not None
|
||||
@@ -397,10 +343,10 @@ class TestDenseScreen:
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||
async with app.run_test() as pilot:
|
||||
strip = str(app.query_one("#service-strip").render())
|
||||
assert "boot:" in strip
|
||||
assert "timer:" in strip
|
||||
assert "last collect:" in strip
|
||||
assert "freshness:" in strip
|
||||
assert "Boot:" in strip
|
||||
assert "Timer:" in strip
|
||||
assert "Last collect:" in strip
|
||||
assert "Freshness:" in strip
|
||||
# Maker credit now in titlebox only (issue #79)
|
||||
assert "by Bongbetic" not in strip
|
||||
|
||||
@@ -412,7 +358,7 @@ class TestDenseScreen:
|
||||
conn.close()
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||
|
||||
with patch("fenris.tui.query_service_state", return_value={
|
||||
with patch("fenris.status.query_service_state", return_value={
|
||||
"boot_enabled": True, "timer_active": True,
|
||||
"last_collect_ok": True, "last_collect_age_s": 60,
|
||||
"last_collect_reason": None,
|
||||
@@ -427,7 +373,7 @@ class TestDenseScreen:
|
||||
assert "monitoring: active in background · persists across reboots" in strip
|
||||
assert "r resume — enable monitoring and future boots" in strip
|
||||
assert "q quit" not in strip
|
||||
assert rail == "q QUIT TUI"
|
||||
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
|
||||
@@ -446,11 +392,11 @@ class TestDenseScreen:
|
||||
conn.close()
|
||||
app = FenrisTuiApp(store_path=db)
|
||||
|
||||
with patch("fenris.tui.query_service_state", return_value={
|
||||
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,
|
||||
}), patch("fenris.tui.subprocess.run") as subprocess_run, patch.object(
|
||||
}), patch("fenris.control.subprocess.run") as subprocess_run, patch.object(
|
||||
app, "_run_helper"
|
||||
) as run_helper:
|
||||
async with app.run_test(size=(80, 24)) as pilot:
|
||||
@@ -459,7 +405,7 @@ class TestDenseScreen:
|
||||
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
|
||||
assert len(main_grid.styles.grid_rows) == 6
|
||||
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
|
||||
@@ -472,7 +418,7 @@ class TestDenseScreen:
|
||||
visible_text = " ".join(
|
||||
"".join(ElementTree.fromstring(screenshot).itertext()).split()
|
||||
)
|
||||
assert "paused time is excluded from your usage habit" in visible_text
|
||||
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()
|
||||
@@ -487,7 +433,7 @@ class TestDenseScreen:
|
||||
ElementTree.fromstring(app.export_screenshot()).itertext()
|
||||
).split()
|
||||
)
|
||||
assert "q QUIT TUI" in footer_text
|
||||
assert "q Quit TUI" in footer_text
|
||||
await pilot.press("q")
|
||||
assert not app.is_running
|
||||
subprocess_run.assert_not_called()
|
||||
@@ -509,11 +455,11 @@ class TestDenseScreen:
|
||||
conn.close()
|
||||
app = FenrisTuiApp(store_path=db)
|
||||
|
||||
with patch("fenris.tui.query_service_state", return_value={
|
||||
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,
|
||||
}), patch("fenris.tui.subprocess.run") as subprocess_run, patch.object(
|
||||
}), patch("fenris.control.subprocess.run") as subprocess_run, patch.object(
|
||||
app, "_run_helper"
|
||||
) as run_helper:
|
||||
async with app.run_test() as pilot:
|
||||
@@ -539,7 +485,7 @@ class TestDenseScreen:
|
||||
store_path=tmp_path / "nonexistent.db",
|
||||
refresh_interval_s=0.2,
|
||||
)
|
||||
auth_notice = "privileged actions will prompt for authentication (polkit)"
|
||||
auth_notice = "Open with fenris (no sudo). Actions authenticate via polkit. ? Help"
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
headline = str(app.query_one("#headline-band").render())
|
||||
@@ -595,12 +541,47 @@ class TestDisclosuresAndGreeting:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFirstRun:
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("size", [(80, 24), (60, 18)])
|
||||
async def test_sudo_help_remains_available_after_launch(self, tmp_path, size):
|
||||
app = FenrisTuiApp(store_path=tmp_path / "missing.db")
|
||||
with patch.object(app, "_run_helper") as run_helper:
|
||||
async with app.run_test(size=size) as pilot:
|
||||
app.on_refresh_tick()
|
||||
await pilot.press("?")
|
||||
help_text = str(app.screen.query_one("#help-text").render())
|
||||
assert "The dashboard does not need sudo" in help_text
|
||||
assert "sudo fenris monitor resume" in help_text
|
||||
assert "sudo fenris monitor pause" in help_text
|
||||
assert "sudo fenris sample" in help_text
|
||||
assert 'sudo usermod -aG fenris "$USER"' in help_text
|
||||
assert "log out and back in" in help_text
|
||||
await pilot.press("end")
|
||||
visible = " ".join("".join(
|
||||
ElementTree.fromstring(app.export_screenshot()).itertext()
|
||||
).split())
|
||||
assert "Esc Close" in visible
|
||||
await pilot.press("escape")
|
||||
assert len(app.screen_stack) == 1
|
||||
run_helper.assert_not_called()
|
||||
|
||||
def test_missing_polkit_identifies_command_and_points_to_help(self, tmp_path):
|
||||
app = FenrisTuiApp(store_path=tmp_path / "missing.db")
|
||||
with patch("fenris.control.os.geteuid", return_value=1000), \
|
||||
patch("fenris.control.subprocess.run", side_effect=FileNotFoundError(2, "Missing", "pkexec")), \
|
||||
patch.object(app, "suspend"), patch.object(app, "_refresh"), \
|
||||
patch.object(app, "notify") as notify:
|
||||
app.action_resume()
|
||||
message = notify.call_args.args[0]
|
||||
assert "Command not found: pkexec" in message
|
||||
assert "sudo /usr/libexec/fenris/fenris-monitor enable --now" in message
|
||||
|
||||
def test_unprivileged_resume_uses_polkit(self, tmp_path):
|
||||
"""TUI controls use the same authenticated path as the CLI."""
|
||||
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||
|
||||
with patch("fenris.tui.os.geteuid", return_value=1000), \
|
||||
patch("fenris.tui.subprocess.run") as run, \
|
||||
with patch("fenris.control.os.geteuid", return_value=1000), \
|
||||
patch("fenris.control.subprocess.run") as run, \
|
||||
patch.object(app, "suspend"), \
|
||||
patch.object(app, "_refresh"):
|
||||
run.return_value.returncode = 0
|
||||
@@ -608,7 +589,6 @@ class TestFirstRun:
|
||||
|
||||
run.assert_called_once_with(
|
||||
["pkexec", "/usr/libexec/fenris/fenris-monitor", "enable", "--now"],
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -820,12 +800,29 @@ class TestQueryDailyGraphData:
|
||||
assert result[0]["is_gap"] is True
|
||||
conn.close()
|
||||
|
||||
def test_window_is_consecutive_utc_days_with_explicit_gaps(self, tmp_path):
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
_insert_day(conn, "2026-09-14", bw=500)
|
||||
result = _query_daily_graph_data(
|
||||
conn, datetime(2026, 9, 16, 12, tzinfo=timezone.utc),
|
||||
)
|
||||
assert len(result) == 90
|
||||
assert result[-3]["day"] == "2026-09-14"
|
||||
assert result[-2]["day"] == "2026-09-15"
|
||||
assert result[-2]["is_gap"] is True
|
||||
assert result[-1]["day"] == "2026-09-16"
|
||||
assert result[-1]["is_partial"] 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 == []
|
||||
result = _query_hourly_graph_data(
|
||||
conn, "2026-09-20", datetime(2026, 9, 21, tzinfo=timezone.utc),
|
||||
)
|
||||
assert len(result) == 24
|
||||
assert all(item["is_gap"] for item in result)
|
||||
conn.close()
|
||||
|
||||
def test_with_hours(self, tmp_path):
|
||||
@@ -844,9 +841,25 @@ class TestQueryHourlyGraphData:
|
||||
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"
|
||||
result = _query_hourly_graph_data(
|
||||
conn, "2026-09-20", datetime(2026, 9, 21, tzinfo=timezone.utc),
|
||||
)
|
||||
assert len(result) == 24
|
||||
assert result[12]["local_label"] == "12"
|
||||
assert result[12]["bytes_written"] == 1000
|
||||
conn.close()
|
||||
|
||||
def test_current_hour_is_partial_and_future_hours_are_not_gaps(self, tmp_path):
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
result = _query_hourly_graph_data(
|
||||
conn, "2026-09-20",
|
||||
datetime(2026, 9, 20, 14, 30, tzinfo=timezone.utc),
|
||||
)
|
||||
assert result[13]["is_gap"] is True
|
||||
assert result[14]["is_partial"] is True
|
||||
assert result[14]["is_gap"] is True
|
||||
assert result[15]["is_future"] is True
|
||||
assert result[15]["is_gap"] is False
|
||||
conn.close()
|
||||
|
||||
|
||||
@@ -897,6 +910,17 @@ class TestDailyBarGraph:
|
||||
graph._trim_to_range()
|
||||
assert len(graph._day_data) == 7
|
||||
|
||||
def test_scale_includes_allocated_and_unallocated_writes(self):
|
||||
graph = DailyBarGraph()
|
||||
graph._all_day_data = [{
|
||||
"day": "2026-09-16",
|
||||
"total_bytes": 800,
|
||||
"allocated_bytes": 500,
|
||||
"unallocated_bytes": 300,
|
||||
}]
|
||||
graph._trim_to_range()
|
||||
assert graph._max_bytes == 800
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bar graph headless TUI tests (issue #75)
|
||||
@@ -924,8 +948,15 @@ class TestBarGraphTUI:
|
||||
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
|
||||
assert "Alloc" in legend
|
||||
assert "Zero" in legend
|
||||
range_label = str(app.query_one("#bar-range").render())
|
||||
assert "14 days" in range_label
|
||||
assert "UTC" in range_label
|
||||
plotted = str(app.query_one("#bar-render").render())
|
||||
assert "Writes (" in plotted
|
||||
assert "Day UTC" in plotted
|
||||
assert "→" in plotted
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_arrow_selection(self, tmp_path):
|
||||
@@ -946,20 +977,16 @@ class TestBarGraphTUI:
|
||||
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
|
||||
# The newest day is selected by default; left moves backward.
|
||||
initial = graph.selected_index
|
||||
await pilot.press("left")
|
||||
await pilot.pause()
|
||||
assert graph.selected_index == 0
|
||||
assert graph.selected_index == initial - 1
|
||||
|
||||
# Right returns to the newest day.
|
||||
await pilot.press("right")
|
||||
await pilot.pause()
|
||||
assert graph.selected_index == initial
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_range_switching(self, tmp_path):
|
||||
@@ -990,17 +1017,17 @@ class TestBarGraphTUI:
|
||||
assert graph.range_days == 7
|
||||
assert len(graph._day_data) == 7
|
||||
|
||||
# Press 3 for 28-day range
|
||||
# Press 3 for 30-day range
|
||||
await pilot.press("3")
|
||||
await pilot.pause()
|
||||
assert graph.range_days == 28
|
||||
assert len(graph._day_data) == 28
|
||||
assert graph.range_days == 30
|
||||
assert len(graph._day_data) == 30
|
||||
|
||||
# Press 4 for 90-day range (only 30 days available)
|
||||
# Exact windows retain gap slots instead of compressing time.
|
||||
await pilot.press("4")
|
||||
await pilot.pause()
|
||||
assert graph.range_days == 90
|
||||
assert len(graph._day_data) == 30
|
||||
assert len(graph._day_data) == 90
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_readout_updates_on_selection(self, tmp_path):
|
||||
@@ -1021,12 +1048,13 @@ class TestBarGraphTUI:
|
||||
await pilot.pause()
|
||||
await pilot.pause()
|
||||
|
||||
# No selection initially
|
||||
# Newest day is selected initially.
|
||||
readout = str(app.query_one("#bar-readout").render())
|
||||
assert "select" in readout.lower()
|
||||
assert "UTC" in readout
|
||||
assert "GB" in readout
|
||||
|
||||
# Select first bar
|
||||
await pilot.press("right")
|
||||
# Moving left updates the selected-day readout.
|
||||
await pilot.press("left")
|
||||
await pilot.pause()
|
||||
readout = str(app.query_one("#bar-readout").render())
|
||||
assert "2026-09" in readout
|
||||
@@ -1056,10 +1084,8 @@ class TestBarGraphTUI:
|
||||
await pilot.pause()
|
||||
await pilot.pause()
|
||||
|
||||
# Select a day
|
||||
await pilot.press("right")
|
||||
await pilot.pause()
|
||||
assert graph.selected_index == 0
|
||||
# The newest day is selected automatically.
|
||||
assert graph.selected_index == len(graph._day_data) - 1
|
||||
assert graph.view_mode == "daily"
|
||||
|
||||
# Enter drill-down
|
||||
@@ -1069,6 +1095,14 @@ class TestBarGraphTUI:
|
||||
assert graph.drill_day is not None
|
||||
assert len(graph._hour_data) == 24
|
||||
|
||||
# Five-minute data refreshes retain the selected hour.
|
||||
graph._hourly_selected = 12
|
||||
selected_hour = graph._hour_data[12]["hour"]
|
||||
app._refresh()
|
||||
await pilot.pause()
|
||||
assert graph.view_mode == "hourly"
|
||||
assert graph._hour_data[graph._hourly_selected]["hour"] == selected_hour
|
||||
|
||||
# Esc returns to daily
|
||||
await pilot.press("escape")
|
||||
await pilot.pause()
|
||||
@@ -1091,7 +1125,7 @@ class TestBarGraphTUI:
|
||||
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"
|
||||
assert graph.border_title == "Usage history"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_glyphs_in_legend(self, tmp_path):
|
||||
@@ -1172,9 +1206,9 @@ class TestConstrainedLayout:
|
||||
# Constrained summary should be visible
|
||||
summary = app.query_one("#constrained-summary")
|
||||
summary_text = str(summary.render())
|
||||
assert "graph needs ≥80×24" in summary_text
|
||||
assert "Graph needs ≥80×24" in summary_text
|
||||
assert "days" in summary_text
|
||||
assert "GB total" in summary_text
|
||||
assert "GB" in summary_text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_constrained_below_24_height(self, tmp_path):
|
||||
@@ -1196,7 +1230,7 @@ class TestConstrainedLayout:
|
||||
# Constrained summary should be visible
|
||||
summary = app.query_one("#constrained-summary")
|
||||
summary_text = str(summary.render())
|
||||
assert "graph needs ≥80×24" in summary_text
|
||||
assert "Graph needs ≥80×24" in summary_text
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resize_from_constrained_to_normal(self, tmp_path):
|
||||
@@ -1257,9 +1291,7 @@ class TestConstrainedLayout:
|
||||
# Focus and select a day
|
||||
app.set_focus(graph)
|
||||
await pilot.pause()
|
||||
await pilot.press("right")
|
||||
await pilot.pause()
|
||||
assert graph.selected_index == 0
|
||||
assert graph.selected_index == len(graph._day_data) - 1
|
||||
|
||||
# Resize to constrained — pilot.resize_terminal changes terminal size
|
||||
await pilot.resize_terminal(79, 24)
|
||||
@@ -1273,7 +1305,7 @@ class TestConstrainedLayout:
|
||||
# Constrained summary should show selected day context
|
||||
summary = app.query_one("#constrained-summary")
|
||||
summary_text = str(summary.render())
|
||||
assert "graph needs ≥80×24" in summary_text
|
||||
assert "Graph needs ≥80×24" in summary_text
|
||||
# Selected day context should survive
|
||||
assert "2026-09" in summary_text
|
||||
|
||||
@@ -1285,7 +1317,7 @@ class TestConstrainedLayout:
|
||||
assert app._is_constrained_mode
|
||||
summary = app.query_one("#constrained-summary")
|
||||
summary_text = str(summary.render())
|
||||
assert "graph needs ≥80×24" in summary_text
|
||||
assert "Graph needs ≥80×24" in summary_text
|
||||
assert "awaiting" in summary_text.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -1315,13 +1347,13 @@ class TestConstrainedLayout:
|
||||
|
||||
# Service strip should have actions
|
||||
strip = str(app.query_one("#service-strip").render())
|
||||
assert "p pause" in strip
|
||||
assert "r resume" in strip
|
||||
assert "p Pause" in strip
|
||||
assert "r Resume" in strip
|
||||
assert "q quit" not in strip # Quit is in quit-rail
|
||||
|
||||
# Quit rail should be visible
|
||||
rail = str(app.query_one("#quit-rail").render())
|
||||
assert "QUIT" in rail
|
||||
assert "Quit" in rail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_constrained_long_reasons_visible(self, tmp_path):
|
||||
|
||||
@@ -4,7 +4,7 @@ Documentation=https://git.bongbetic.com/xavierk/Fenris
|
||||
|
||||
[Timer]
|
||||
OnBootSec=2min
|
||||
OnUnitInactiveSec=5min
|
||||
OnUnitInactiveSec=3min
|
||||
AccuracySec=30s
|
||||
Persistent=no
|
||||
|
||||
|
||||
@@ -5,5 +5,8 @@
|
||||
# The logger writes to runit's log directory for diagnostics.
|
||||
#
|
||||
# Spec: §8.8 (actionable native diagnostics)
|
||||
exec chpst -u fenris:fenris \
|
||||
# The package creates the fenris group for shared diagnostics access; it does
|
||||
# not create a service user. Use Void's standard unprivileged account while
|
||||
# giving the logger the declared group identity.
|
||||
exec chpst -u nobody:fenris \
|
||||
svlogd -tt /var/log/fenris-collect/
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
#
|
||||
# Guarantees (must match systemd timer semantics):
|
||||
# - Initial 2-minute boot delay (sleep 120 before first collect)
|
||||
# - Completion-relative 5-minute cadence (sleep 300 after collect)
|
||||
# - Completion-relative 3-minute cadence (sleep 180 after collect)
|
||||
# - Bounded execution (timeout 90s on each collect)
|
||||
# - No catch-up (fixed sleep interval, no Persistent=)
|
||||
# - Serialized runs (runsv does not restart until exit)
|
||||
@@ -20,7 +20,7 @@ set -eu
|
||||
|
||||
COLLECT_TIMEOUT=90
|
||||
BOOT_DELAY=120
|
||||
CADENCE=300
|
||||
CADENCE=180
|
||||
LOCK_FILE=/var/lib/fenris/fenris-collect.lock
|
||||
|
||||
# Ensure lock directory exists
|
||||
|
||||
Reference in New Issue
Block a user