Compare commits
79
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c07f37c78 | ||
|
|
b098132595 | ||
|
|
1dbe372714 | ||
|
|
1063fa4dae | ||
|
|
3f2dd6a5a1 | ||
|
|
a5b84f7566 | ||
|
|
917c94fd65 | ||
|
|
a49808a247 | ||
|
|
87c5dcdac6 | ||
|
|
511905f519 | ||
|
|
e22b99442e | ||
|
|
d045e88043 | ||
|
|
c4524cf654 | ||
|
|
f780212a02 | ||
|
|
3e1ecc90e4 | ||
|
|
59d2dd2634 | ||
|
|
fd4db45ae1 | ||
|
|
5aeee7b6f1 | ||
|
|
d69753690a | ||
|
|
017a562566 | ||
|
|
7e392c4ea2 | ||
|
|
8a5ef05188 | ||
|
|
847bde1be0 | ||
|
|
0d45f263d2 | ||
|
|
7c21b044ba | ||
|
|
437ea6be73 | ||
|
|
4550dd5111 | ||
|
|
e49aac8569 | ||
|
|
8bac26836a | ||
|
|
88672e12bf | ||
|
|
cfd71a053b | ||
|
|
b76067104b | ||
|
|
95cca2e115 | ||
|
|
5df8a12339 | ||
|
|
e12f4a574c | ||
|
|
4f884b4b73 | ||
|
|
366c2f55b7 | ||
|
|
54cd56e4ec | ||
|
|
69e08d9d3a | ||
|
|
714e69be52 | ||
|
|
ba16413363 | ||
|
|
dfe6a6a2d0 | ||
|
|
44c57b70dd | ||
|
|
f06424f3b8 | ||
|
|
fae72bb07b | ||
|
|
9d22525403 | ||
|
|
a3e6cc3b3c | ||
|
|
34bfc70bb8 | ||
|
|
8b6d4b2447 | ||
|
|
72dacab03b | ||
|
|
cca6804964 | ||
|
|
dea2186d6b | ||
|
|
967ba6964f | ||
|
|
efcfd266b7 | ||
|
|
1113532c9a | ||
|
|
95c75badd5 | ||
|
|
70dbae65fb | ||
|
|
d119a09b1f | ||
|
|
fca0724fb4 | ||
|
|
1c3037c2f8 | ||
|
|
2d4cb16a00 | ||
|
|
d93238b0f3 | ||
|
|
a279c56be5 | ||
|
|
e27052d09a | ||
|
|
37a0ed7030 | ||
|
|
985efed906 | ||
|
|
ed61c4e1ec | ||
|
|
197e8ed02d | ||
|
|
79478653fa | ||
|
|
30122c5e6d | ||
|
|
01240ec8f0 | ||
|
|
7e270150bc | ||
|
|
3d71ebbc88 | ||
|
|
607dc83e55 | ||
|
|
d6fa94001c | ||
|
|
4f2f30abac | ||
|
|
5d916ee97f | ||
|
|
6917a658cb | ||
|
|
d790ff84c5 |
+155
-13
@@ -8,6 +8,12 @@ on:
|
|||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
publish_xbps:
|
||||||
|
description: 'Publish XBPS package to distribution repository (requires host acceptance)'
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
type: boolean
|
||||||
|
|
||||||
# Built-in Gitea token needs write access for release assets and package registry.
|
# Built-in Gitea token needs write access for release assets and package registry.
|
||||||
permissions:
|
permissions:
|
||||||
@@ -58,10 +64,27 @@ jobs:
|
|||||||
-o /tmp/nfpm.tar.gz
|
-o /tmp/nfpm.tar.gz
|
||||||
sudo tar -xzf /tmp/nfpm.tar.gz -C /usr/local/bin nfpm
|
sudo tar -xzf /tmp/nfpm.tar.gz -C /usr/local/bin nfpm
|
||||||
nfpm --version
|
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
|
- name: Build packages
|
||||||
run: make package
|
run: make package
|
||||||
|
|
||||||
|
- name: Build XBPS package
|
||||||
|
run: make package-xbps
|
||||||
|
|
||||||
- name: Import packaging key
|
- name: Import packaging key
|
||||||
env:
|
env:
|
||||||
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
|
GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }}
|
||||||
@@ -87,6 +110,26 @@ jobs:
|
|||||||
- name: Sign RPM payload
|
- name: Sign RPM payload
|
||||||
run: make sign-rpm
|
run: make sign-rpm
|
||||||
|
|
||||||
|
- name: Import XBPS signing key
|
||||||
|
id: import-xbps-key
|
||||||
|
env:
|
||||||
|
XBPS_SIGNING_KEY: ${{ secrets.XBPS_SIGNING_KEY }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -z "${XBPS_SIGNING_KEY}" ]; then
|
||||||
|
echo "::warning::XBPS_SIGNING_KEY secret not configured; XBPS signing skipped"
|
||||||
|
echo "xbps_signed=false" >> "$GITHUB_OUTPUT"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
printf '%s\n' "${XBPS_SIGNING_KEY}" > ~/.ssh/id_xbps
|
||||||
|
chmod 600 ~/.ssh/id_xbps
|
||||||
|
echo "xbps_signed=true" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Sign XBPS package
|
||||||
|
if: steps.import-xbps-key.outputs.xbps_signed == 'true'
|
||||||
|
run: make sign-xbps
|
||||||
|
|
||||||
- name: Generate and clearsign SHA256SUMS
|
- name: Generate and clearsign SHA256SUMS
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@@ -159,6 +202,67 @@ jobs:
|
|||||||
*) echo "::error::RPM upload failed with HTTP ${STATUS}"; exit 1 ;;
|
*) echo "::error::RPM upload failed with HTTP ${STATUS}"; exit 1 ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
- name: Publish XBPS to distribution repository
|
||||||
|
id: publish-xbps
|
||||||
|
if: ${{ github.event.inputs.publish_xbps == true || github.event.inputs.publish_xbps == 'true' }}
|
||||||
|
env:
|
||||||
|
GITEA_PUBLISH_TOKEN: ${{ secrets.GITEAPACKAGETOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ -z "${GITEA_PUBLISH_TOKEN}" ]; then
|
||||||
|
echo "::error::GITEAPACKAGETOKEN repository secret is not configured"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
git config user.name "xavierk"
|
||||||
|
git config user.email "xavierk@bongbetic.com"
|
||||||
|
export GIT_CONFIG_COUNT=1
|
||||||
|
export GIT_CONFIG_KEY_0='http.https://git.bongbetic.com/.extraheader'
|
||||||
|
export GIT_CONFIG_VALUE_0="Authorization: token ${GITEA_PUBLISH_TOKEN}"
|
||||||
|
VERSION=${{ steps.version.outputs.version }}
|
||||||
|
XBPS_FILE="fenris-${VERSION}_1.x86_64.xbps"
|
||||||
|
if [ ! -f "${XBPS_FILE}" ]; then
|
||||||
|
echo "::error::XBPS package not found: ${XBPS_FILE}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ ! -f "${XBPS_FILE}.sig2" ]; then
|
||||||
|
echo "::error::XBPS signature not found: ${XBPS_FILE}.sig2"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
bash scripts/xbps-publish.sh --publish
|
||||||
|
echo "xbps_published=true" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Track format availability
|
||||||
|
id: formats
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
VERSION=${{ steps.version.outputs.version }}
|
||||||
|
DEB_EXISTS=$([ -f "dist/fenris_${VERSION}_amd64.deb" ] && echo "true" || echo "false")
|
||||||
|
RPM_EXISTS=$([ -f "dist/fenris-${VERSION}-1.x86_64.rpm" ] && echo "true" || echo "false")
|
||||||
|
XBPS_EXISTS=$([ -f "fenris-${VERSION}_1.x86_64.xbps" ] && echo "true" || echo "false")
|
||||||
|
XBPS_PUBLISHED=$([ "${{ steps.publish-xbps.outputs.xbps_published }}" = "true" ] && echo "true" || echo "false")
|
||||||
|
echo "deb_available=${DEB_EXISTS}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "rpm_available=${RPM_EXISTS}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "xbps_available=${XBPS_EXISTS}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "xbps_published=${XBPS_PUBLISHED}" >> "$GITHUB_OUTPUT"
|
||||||
|
# Build format availability summary for release notes
|
||||||
|
AVAILABLE_FORMATS=""
|
||||||
|
WITHHELD_FORMATS=""
|
||||||
|
if [ "${DEB_EXISTS}" = "true" ]; then
|
||||||
|
AVAILABLE_FORMATS="${AVAILABLE_FORMATS}Debian/Ubuntu (deb), "
|
||||||
|
fi
|
||||||
|
if [ "${RPM_EXISTS}" = "true" ]; then
|
||||||
|
AVAILABLE_FORMATS="${AVAILABLE_FORMATS}Fedora/openSUSE (rpm), "
|
||||||
|
fi
|
||||||
|
if [ "${XBPS_EXISTS}" = "true" ] && [ "${XBPS_PUBLISHED}" = "true" ]; then
|
||||||
|
AVAILABLE_FORMATS="${AVAILABLE_FORMATS}Void Linux (xbps)"
|
||||||
|
elif [ "${XBPS_EXISTS}" = "true" ]; then
|
||||||
|
WITHHELD_FORMATS="Void Linux (xbps) — pending host acceptance"
|
||||||
|
fi
|
||||||
|
# Remove trailing comma and space
|
||||||
|
AVAILABLE_FORMATS=$(echo "${AVAILABLE_FORMATS}" | sed 's/, $//')
|
||||||
|
echo "available_formats=${AVAILABLE_FORMATS}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "withheld_formats=${WITHHELD_FORMATS}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Create Gitea release
|
- name: Create Gitea release
|
||||||
env:
|
env:
|
||||||
GITEA_PUBLISH_TOKEN: ${{ secrets.GITEAPACKAGETOKEN }}
|
GITEA_PUBLISH_TOKEN: ${{ secrets.GITEAPACKAGETOKEN }}
|
||||||
@@ -174,6 +278,18 @@ jobs:
|
|||||||
echo "::error::validated release body is missing or empty"
|
echo "::error::validated release body is missing or empty"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
# Append format availability to release notes
|
||||||
|
AVAILABLE_FORMATS="${{ steps.formats.outputs.available_formats }}"
|
||||||
|
WITHHELD_FORMATS="${{ steps.formats.outputs.withheld_formats }}"
|
||||||
|
RELEASE_BODY_WITH_FORMATS="${RUNNER_TEMP}/release-body-formats.md"
|
||||||
|
cp "${RELEASE_BODY}" "${RELEASE_BODY_WITH_FORMATS}"
|
||||||
|
echo "" >> "${RELEASE_BODY_WITH_FORMATS}"
|
||||||
|
echo "## Package formats" >> "${RELEASE_BODY_WITH_FORMATS}"
|
||||||
|
echo "" >> "${RELEASE_BODY_WITH_FORMATS}"
|
||||||
|
echo "Available: ${AVAILABLE_FORMATS}" >> "${RELEASE_BODY_WITH_FORMATS}"
|
||||||
|
if [ -n "${WITHHELD_FORMATS}" ]; then
|
||||||
|
echo "Withheld: ${WITHHELD_FORMATS}" >> "${RELEASE_BODY_WITH_FORMATS}"
|
||||||
|
fi
|
||||||
EXISTING_RELEASE="${RUNNER_TEMP}/existing-release.json"
|
EXISTING_RELEASE="${RUNNER_TEMP}/existing-release.json"
|
||||||
EXISTING=$(curl --silent --show-error -o "${EXISTING_RELEASE}" -w '%{http_code}' \
|
EXISTING=$(curl --silent --show-error -o "${EXISTING_RELEASE}" -w '%{http_code}' \
|
||||||
-H "Authorization: token ${GITEA_PUBLISH_TOKEN}" \
|
-H "Authorization: token ${GITEA_PUBLISH_TOKEN}" \
|
||||||
@@ -182,11 +298,11 @@ jobs:
|
|||||||
200)
|
200)
|
||||||
echo "Release v${VERSION} exists; resynchronizing its notes"
|
echo "Release v${VERSION} exists; resynchronizing its notes"
|
||||||
REQUEST="$(python3 scripts/release_request.py --version "${VERSION}" \
|
REQUEST="$(python3 scripts/release_request.py --version "${VERSION}" \
|
||||||
--body-file "${RELEASE_BODY}" --existing-release "${EXISTING_RELEASE}")"
|
--body-file "${RELEASE_BODY_WITH_FORMATS}" --existing-release "${EXISTING_RELEASE}")"
|
||||||
;;
|
;;
|
||||||
404)
|
404)
|
||||||
REQUEST="$(python3 scripts/release_request.py --version "${VERSION}" \
|
REQUEST="$(python3 scripts/release_request.py --version "${VERSION}" \
|
||||||
--body-file "${RELEASE_BODY}")"
|
--body-file "${RELEASE_BODY_WITH_FORMATS}")"
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "::error::release lookup failed with HTTP ${EXISTING}"
|
echo "::error::release lookup failed with HTTP ${EXISTING}"
|
||||||
@@ -214,17 +330,43 @@ jobs:
|
|||||||
"https://git.bongbetic.com/api/v1/repos/xavierk/Fenris/releases/tags/v${VERSION}")
|
"https://git.bongbetic.com/api/v1/repos/xavierk/Fenris/releases/tags/v${VERSION}")
|
||||||
RELEASE_ID=$(printf '%s' "${RELEASE_JSON}" \
|
RELEASE_ID=$(printf '%s' "${RELEASE_JSON}" \
|
||||||
| python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
|
| python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
|
||||||
# Attach deb, rpm, and clearsigned checksums once.
|
PUBLISH_XBPS="${{ steps.formats.outputs.xbps_published }}"
|
||||||
for FILE in "dist/fenris_${VERSION}_amd64.deb" \
|
# Attach package artifacts; refresh XBPS assets after publication.
|
||||||
"dist/fenris-${VERSION}-1.x86_64.rpm" \
|
ARTIFACTS=(
|
||||||
"dist/SHA256SUMS.asc"; do
|
"dist/fenris_${VERSION}_amd64.deb"
|
||||||
ASSET_NAME="${FILE##*/}"
|
"dist/fenris-${VERSION}-1.x86_64.rpm"
|
||||||
if python3 -c 'import json,sys; name=sys.argv[1]; sys.exit(0 if any(a.get("name") == name for a in json.load(sys.stdin).get("assets", [])) else 1)' "${ASSET_NAME}" <<<"${RELEASE_JSON}"; then
|
"dist/SHA256SUMS.asc"
|
||||||
echo "${ASSET_NAME}: already attached"
|
)
|
||||||
|
# Add XBPS artifacts if they exist
|
||||||
|
XBPS_FILE="fenris-${VERSION}_1.x86_64.xbps"
|
||||||
|
if [ -f "${XBPS_FILE}" ]; then
|
||||||
|
if [ -f "${XBPS_FILE}.sig2" ]; then
|
||||||
|
ARTIFACTS+=("${XBPS_FILE}")
|
||||||
|
ARTIFACTS+=("${XBPS_FILE}.sig2")
|
||||||
else
|
else
|
||||||
curl --fail --silent --show-error -X POST \
|
echo "::warning::Unsigned XBPS artifact omitted from release assets"
|
||||||
-H "Authorization: token ${GITEA_PUBLISH_TOKEN}" \
|
|
||||||
-F "attachment=@${FILE}" \
|
|
||||||
"https://git.bongbetic.com/api/v1/repos/xavierk/Fenris/releases/${RELEASE_ID}/assets"
|
|
||||||
fi
|
fi
|
||||||
|
fi
|
||||||
|
for FILE in "${ARTIFACTS[@]}"; do
|
||||||
|
ASSET_NAME="${FILE##*/}"
|
||||||
|
EXISTING_ASSET_ID=$(python3 -c 'import json,sys; name=sys.argv[1]; print(next((a["id"] for a in json.load(sys.stdin).get("assets", []) if a.get("name") == name), ""))' \
|
||||||
|
"${ASSET_NAME}" <<<"${RELEASE_JSON}")
|
||||||
|
if [ -n "${EXISTING_ASSET_ID}" ]; then
|
||||||
|
if [ "${PUBLISH_XBPS}" = "true" ] && [[ "${ASSET_NAME}" = "${XBPS_FILE}" || "${ASSET_NAME}" = "${XBPS_FILE}.sig2" ]]; then
|
||||||
|
curl --fail --silent --show-error -X DELETE \
|
||||||
|
-H "Authorization: token ${GITEA_PUBLISH_TOKEN}" \
|
||||||
|
"https://git.bongbetic.com/api/v1/repos/xavierk/Fenris/releases/${RELEASE_ID}/assets/${EXISTING_ASSET_ID}"
|
||||||
|
else
|
||||||
|
echo "${ASSET_NAME}: already attached"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
curl --fail --silent --show-error -X POST \
|
||||||
|
-H "Authorization: token ${GITEA_PUBLISH_TOKEN}" \
|
||||||
|
-F "attachment=@${FILE}" \
|
||||||
|
"https://git.bongbetic.com/api/v1/repos/xavierk/Fenris/releases/${RELEASE_ID}/assets"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
- name: Remove XBPS signing key
|
||||||
|
if: always()
|
||||||
|
run: rm -f ~/.ssh/id_xbps
|
||||||
|
|||||||
@@ -18,3 +18,5 @@ json
|
|||||||
src/fenris.egg-info/
|
src/fenris.egg-info/
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
.venv/
|
.venv/
|
||||||
|
MagicMock*
|
||||||
|
<MagicMock*
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
|
## Session style
|
||||||
|
|
||||||
|
After the first user message in each session, load the global `caveman` skill and activate `ultra` mode. Keep `ultra` mode active until the user changes or stops it.
|
||||||
|
|
||||||
## Agent skills
|
## Agent skills
|
||||||
|
|
||||||
|
## Commit messages
|
||||||
|
|
||||||
|
Do not add `Co-authored-by: CommandCodeBot <noreply@commandcode.ai>` or other
|
||||||
|
CommandCodeBot attribution trailers to commits.
|
||||||
|
|
||||||
### Issue tracker
|
### Issue tracker
|
||||||
|
|
||||||
Issues are tracked in Gitea using the authenticated `tea` CLI. See `docs/agents/issue-tracker.md`.
|
Issues are tracked in Gitea using the authenticated `tea` CLI. See `docs/agents/issue-tracker.md`.
|
||||||
|
|||||||
@@ -9,6 +9,81 @@ backfill releases from before this changelog.
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.6.0] - 2026-09-29
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Preserve local-day read and write evidence separately, including ambiguous midnight-spanning volume as shared evidence instead of assigning it to either day.
|
||||||
|
- Keep valid observations pending when publication fails, and recover them without exposing partial history or counting activity twice.
|
||||||
|
- Preserve live-point inspection and historical activity selection through graph refresh.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Prune old detail only after durable summaries and required boundary evidence are published.
|
||||||
|
- Require a complete local day to fit within one monitoring period before it can open the endurance outlook.
|
||||||
|
|
||||||
|
## [0.5.0] - 2026-09-19
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Make drive activity the focus of a Chalktone dashboard with Live, Day, and History tabs, panel zoom, and fixed monitoring controls.
|
||||||
|
- Replace block bars with labelled dotted volume plots that fit the terminal and preserve gaps, partial evidence, and point inspection.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Preserve historical selections and store-fault messages through graph refresh and resize.
|
||||||
|
- Show hourly drill-down results without overwriting them with a loading placeholder.
|
||||||
|
- Keep known unallocated daily volume visible across coverage gaps, and distinguish missing hourly evidence from zero on small terminals.
|
||||||
|
- Stage package directories with consistent public permissions, avoiding openSUSE RPM conflicts and inaccessible runtime paths when built with a restrictive umask.
|
||||||
|
|
||||||
|
## [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
|
||||||
|
|
||||||
|
- Show trustworthy first-use history, qualifying-day progress, and confidence in the dashboard.
|
||||||
|
- Add an interactive daily-writes graph with hourly drill-down and evidence-anchored scenario windows.
|
||||||
|
- Repair retained observation history safely and keep its retention state visible.
|
||||||
|
- Present a unified monitoring status, drive-health context, and Fenris identity in the dashboard.
|
||||||
|
- Remember accessible colour and motion preferences and keep the dashboard usable at constrained terminal sizes.
|
||||||
|
|
||||||
## [0.3.4] - 2026-09-10
|
## [0.3.4] - 2026-09-10
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+26
-22
@@ -28,29 +28,41 @@ _Avoid_: Daemon uptime, calibration window
|
|||||||
The single SQLite database at `/var/lib/fenris/observations.db` that persists the observation history, monitoring periods, hour observations, day aggregates, and endurance baseline.
|
The single SQLite database at `/var/lib/fenris/observations.db` that persists the observation history, monitoring periods, hour observations, day aggregates, and endurance baseline.
|
||||||
_Avoid_: Data directory, history.jsonl, the database (generic)
|
_Avoid_: Data directory, history.jsonl, the database (generic)
|
||||||
|
|
||||||
|
**Pending publication**:
|
||||||
|
The condition where valid acquired observations are retained for recovery but their dependent evidence has not yet been published consistently. Those observations are not part of the reader-visible observation history until publication succeeds; readers retain the last consistent evidence with the pending condition made explicit.
|
||||||
|
_Avoid_: Successful collection, fresh published evidence
|
||||||
|
|
||||||
**Store fault**:
|
**Store fault**:
|
||||||
The condition where the observation store is present but cannot be read or trusted — unreadable, corrupt, or written by a newer Fenris — degrading every view that depends on it rather than crashing or guessing.
|
The condition where the observation store is present but cannot be read or trusted — unreadable, corrupt, or written by a newer Fenris — degrading every view that depends on it rather than crashing or guessing.
|
||||||
_Avoid_: Database error, corruption, broken data
|
_Avoid_: Database error, corruption, broken data
|
||||||
|
|
||||||
**Usage interval**:
|
|
||||||
The elapsed span between two compatible counter observations, with a measured usage total whose distribution within that span may be unknown.
|
|
||||||
_Avoid_: Estimated hourly usage, interpolated sample
|
|
||||||
|
|
||||||
**Unallocated usage**:
|
|
||||||
Measured usage whose share in a particular hour, calendar day, or monitoring period cannot be established from the available evidence.
|
|
||||||
_Avoid_: Zero usage, evenly distributed writes
|
|
||||||
|
|
||||||
**Hour observation**:
|
**Hour observation**:
|
||||||
The usage-habit evidence for a UTC hour's represented elapsed span: active, idle, powered-off, and unknown time, measured usage, thermal evidence, and coverage. A partial hour does not describe future time.
|
One row per UTC hour in the observation store, recording that hour's usage-habit split into active, idle, powered-off, and unknown seconds, plus write/read deltas, thermal evidence, and coverage.
|
||||||
_Avoid_: Hourly record, hourly.jsonl entry
|
_Avoid_: Hourly record, hourly.jsonl entry
|
||||||
|
|
||||||
**Day aggregate**:
|
**Day aggregate**:
|
||||||
The UTC-day summary at which usage-habit evidence is judged; distinct from a local display day.
|
One row per UTC day derived from hour observations; the grain at which usage-habit evidence is judged.
|
||||||
_Avoid_: Local daily total, daily stats
|
_Avoid_: Daily summary, daily stats
|
||||||
|
|
||||||
**Local display day**:
|
**Local-day evidence**:
|
||||||
A calendar day in the user's current system timezone, used to browse observation history; its elapsed length can vary with timezone transitions.
|
Measured read and write activity attributable to a local calendar day with its recorded timezone and midnight boundaries. Known volumes remain incomplete when gaps or shared local-day evidence prevent an exact total; they are not estimates of the missing activity.
|
||||||
_Avoid_: UTC evidence day, fixed 24-hour day
|
_Avoid_: Estimated daily total, localised UTC day aggregate
|
||||||
|
|
||||||
|
**Shared local-day evidence**:
|
||||||
|
Measured read or write volume spanning a local midnight that cannot honestly be allocated to either adjacent day. It is retained once, separately from either day's known volume, rather than prorated or counted in both days.
|
||||||
|
_Avoid_: Missing bytes, estimated midnight split
|
||||||
|
|
||||||
|
**Activity selection**:
|
||||||
|
The read/write measurement, date and point being inspected in live or historical drive activity. Live activity follows the newest point until deliberate inspection pins a point; historical selection survives refresh, while an expired live pin is explained before following resumes.
|
||||||
|
_Avoid_: Projection window, current usage habit
|
||||||
|
|
||||||
|
**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**:
|
**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.
|
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.
|
||||||
@@ -88,14 +100,6 @@ _Avoid_: Confidence interval, error bar
|
|||||||
The share of wall-clock seconds inside monitoring periods whose usage-habit classification is known rather than unknown.
|
The share of wall-clock seconds inside monitoring periods whose usage-habit classification is known rather than unknown.
|
||||||
_Avoid_: Uptime, sample count
|
_Avoid_: Uptime, sample count
|
||||||
|
|
||||||
**Byte-allocation completeness**:
|
|
||||||
Whether the available evidence establishes all monitored writes attributable to a specified span, without missing counter evidence or unknown boundary shares; distinct from usage-habit classification coverage.
|
|
||||||
_Avoid_: Coverage, estimated allocation
|
|
||||||
|
|
||||||
**Qualifying day**:
|
|
||||||
A UTC date whose represented monitored time meets the coverage requirement for projection evidence. Qualification is provisional while the date is in progress and does not establish byte-allocation completeness.
|
|
||||||
_Avoid_: Completed day, supported day, calibration day
|
|
||||||
|
|
||||||
**Collection run**:
|
**Collection run**:
|
||||||
One scheduled or on-demand execution of the collector that interrogates the drive and extends the observation history.
|
One scheduled or on-demand execution of the collector that interrogates the drive and extends the observation history.
|
||||||
_Avoid_: Poll, daemon tick
|
_Avoid_: Poll, daemon tick
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -18,7 +18,7 @@ MANIFEST := $(DATA_DIR)/manifest.txt
|
|||||||
# Legacy history path (IN-4)
|
# Legacy history path (IN-4)
|
||||||
LEGACY_HISTORY := ./data/history.jsonl
|
LEGACY_HISTORY := ./data/history.jsonl
|
||||||
|
|
||||||
.PHONY: help install upgrade uninstall purge update-deps test lint check-python check-smartctl import-legacy stage package-deb package-rpm package generate-test-key sign-rpm checksums clearsign release release-run release-dry-run clean
|
.PHONY: help install upgrade uninstall purge update-deps test lint check-python check-smartctl import-legacy stage package-deb package-rpm package-xbps package generate-test-key sign-rpm sign-xbps checksums clearsign release release-run release-dry-run xbps-publish xbps-publish-dry-run clean
|
||||||
|
|
||||||
help:
|
help:
|
||||||
@echo "Fenris NVMe endurance monitor"
|
@echo "Fenris NVMe endurance monitor"
|
||||||
@@ -35,6 +35,8 @@ help:
|
|||||||
@echo " package - Build deb + rpm packages"
|
@echo " package - Build deb + rpm packages"
|
||||||
@echo " package-deb - Build deb package only"
|
@echo " package-deb - Build deb package only"
|
||||||
@echo " package-rpm - Build rpm package only"
|
@echo " package-rpm - Build rpm package only"
|
||||||
|
@echo " package-xbps - Build XBPS package only"
|
||||||
|
@echo " sign-xbps - Sign XBPS package with signing key"
|
||||||
@echo " generate-test-key - Create throwaway GPG key for CI/testing"
|
@echo " generate-test-key - Create throwaway GPG key for CI/testing"
|
||||||
@echo " sign-rpm - Sign RPM payload with packaging key"
|
@echo " sign-rpm - Sign RPM payload with packaging key"
|
||||||
@echo " checksums - Generate SHA256SUMS manifest"
|
@echo " checksums - Generate SHA256SUMS manifest"
|
||||||
@@ -42,6 +44,8 @@ help:
|
|||||||
@echo " release - Full release (build, sign, checksum, print upload steps)"
|
@echo " release - Full release (build, sign, checksum, print upload steps)"
|
||||||
@echo " release-run - Execute the full release flow via scripts/release.sh"
|
@echo " release-run - Execute the full release flow via scripts/release.sh"
|
||||||
@echo " release-dry-run - Dry-run of the release flow (prints commands only)"
|
@echo " release-dry-run - Dry-run of the release flow (prints commands only)"
|
||||||
|
@echo " xbps-publish - Publish XBPS package to distribution repository"
|
||||||
|
@echo " xbps-publish-dry-run - Dry-run of XBPS publication (prints commands only)"
|
||||||
@echo " clean - Remove build artifacts"
|
@echo " clean - Remove build artifacts"
|
||||||
|
|
||||||
# ─── Pre-install gates ──────────────────────────────────────────────────────
|
# ─── Pre-install gates ──────────────────────────────────────────────────────
|
||||||
@@ -70,7 +74,7 @@ install: check-python check-smartctl dist/fenris-*.whl
|
|||||||
|
|
||||||
@echo "=== Creating data directory (root-written, group-read) ==="
|
@echo "=== Creating data directory (root-written, group-read) ==="
|
||||||
@sudo groupadd -f fenris
|
@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 ==="
|
@echo "=== Installing version-neutral runtime packages ==="
|
||||||
@sudo rm -rf $(VENV_DIR)
|
@sudo rm -rf $(VENV_DIR)
|
||||||
@@ -89,6 +93,13 @@ install: check-python check-smartctl dist/fenris-*.whl
|
|||||||
@sudo install -m 0644 units/fenris-collect.service $(UNIT_DIR)/
|
@sudo install -m 0644 units/fenris-collect.service $(UNIT_DIR)/
|
||||||
@sudo systemctl daemon-reload
|
@sudo systemctl daemon-reload
|
||||||
|
|
||||||
|
@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
|
||||||
|
|
||||||
@echo "=== Installing polkit policy ==="
|
@echo "=== Installing polkit policy ==="
|
||||||
@sudo install -m 0644 polkit/com.bongbetic.fenris.monitor.policy $(POLKIT_DIR)/
|
@sudo install -m 0644 polkit/com.bongbetic.fenris.monitor.policy $(POLKIT_DIR)/
|
||||||
|
|
||||||
@@ -101,10 +112,14 @@ install: check-python check-smartctl dist/fenris-*.whl
|
|||||||
@echo "$(LIBEXEC_DIR)/fenris-collect" | sudo tee -a $(MANIFEST) > /dev/null
|
@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.timer" | sudo tee -a $(MANIFEST) > /dev/null
|
||||||
@echo "$(UNIT_DIR)/fenris-collect.service" | 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 "$(POLKIT_DIR)/com.bongbetic.fenris.monitor.policy" | sudo tee -a $(MANIFEST) > /dev/null
|
||||||
@echo "$(VENV_DIR)" | 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 "$(DATA_DIR)" | sudo tee -a $(MANIFEST) > /dev/null
|
||||||
@echo "$(CONF_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 "$(MANIFEST)" | sudo tee -a $(MANIFEST) > /dev/null
|
||||||
|
|
||||||
@echo "=== Install complete ==="
|
@echo "=== Install complete ==="
|
||||||
@@ -139,6 +154,11 @@ upgrade: dist/fenris-*.whl
|
|||||||
@echo "=== Syncing units against manifest ==="
|
@echo "=== Syncing units against manifest ==="
|
||||||
@sudo install -m 0644 units/fenris-collect.timer $(UNIT_DIR)/
|
@sudo install -m 0644 units/fenris-collect.timer $(UNIT_DIR)/
|
||||||
@sudo install -m 0644 units/fenris-collect.service $(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)/
|
@sudo install -m 0644 polkit/com.bongbetic.fenris.monitor.policy $(POLKIT_DIR)/
|
||||||
@sudo install -m 0755 scripts/fenris $(BIN_DIR)/fenris
|
@sudo install -m 0755 scripts/fenris $(BIN_DIR)/fenris
|
||||||
@sudo install -m 0755 src/fenris/monitor.py $(LIBEXEC_DIR)/fenris-monitor
|
@sudo install -m 0755 src/fenris/monitor.py $(LIBEXEC_DIR)/fenris-monitor
|
||||||
@@ -154,10 +174,14 @@ upgrade: dist/fenris-*.whl
|
|||||||
@echo "$(LIBEXEC_DIR)/fenris-collect" | sudo tee -a $(MANIFEST) > /dev/null
|
@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.timer" | sudo tee -a $(MANIFEST) > /dev/null
|
||||||
@echo "$(UNIT_DIR)/fenris-collect.service" | 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 "$(POLKIT_DIR)/com.bongbetic.fenris.monitor.policy" | sudo tee -a $(MANIFEST) > /dev/null
|
||||||
@echo "$(VENV_DIR)" | 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 "$(DATA_DIR)" | sudo tee -a $(MANIFEST) > /dev/null
|
||||||
@echo "$(CONF_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 "$(MANIFEST)" | sudo tee -a $(MANIFEST) > /dev/null
|
||||||
|
|
||||||
@echo "=== Restarting timer only if contents changed and active (IN-5) ==="
|
@echo "=== Restarting timer only if contents changed and active (IN-5) ==="
|
||||||
@@ -192,6 +216,9 @@ uninstall:
|
|||||||
@sudo systemctl stop fenris-collect.timer 2>/dev/null || true
|
@sudo systemctl stop fenris-collect.timer 2>/dev/null || true
|
||||||
@sudo systemctl disable fenris-collect.timer 2>/dev/null || true
|
@sudo systemctl disable fenris-collect.timer 2>/dev/null || true
|
||||||
@sudo systemctl daemon-reload
|
@sudo systemctl daemon-reload
|
||||||
|
@-sudo rm -f /var/service/fenris-collect 2>/dev/null || true
|
||||||
|
@-sudo rm -rf /etc/sv/fenris-collect 2>/dev/null || true
|
||||||
|
@-sudo rm -rf /var/log/fenris-collect 2>/dev/null || true
|
||||||
|
|
||||||
@echo "=== Removing installed files (preserving config and store) ==="
|
@echo "=== Removing installed files (preserving config and store) ==="
|
||||||
@-rm -f $(BIN_DIR)/fenris
|
@-rm -f $(BIN_DIR)/fenris
|
||||||
@@ -257,6 +284,42 @@ package-rpm: stage
|
|||||||
package: package-deb package-rpm
|
package: package-deb package-rpm
|
||||||
@echo "=== Both packages built in dist/ ==="
|
@echo "=== Both packages built in dist/ ==="
|
||||||
|
|
||||||
|
# ─── XBPS packaging (ADR 0008) ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
# 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)_$(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>=0" \
|
||||||
|
-F "/etc/fenris/fenris.conf" \
|
||||||
|
build/stage
|
||||||
|
@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)_$(XBPS_REVISION).x86_64.xbps
|
||||||
|
@echo "=== XBPS package signed ==="
|
||||||
|
|
||||||
|
xbps-publish:
|
||||||
|
bash scripts/xbps-publish.sh --publish
|
||||||
|
|
||||||
|
xbps-publish-dry-run:
|
||||||
|
bash scripts/xbps-publish.sh --dry-run
|
||||||
|
|
||||||
# ─── GPG key management ─────────────────────────────────────────────────────
|
# ─── GPG key management ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
generate-test-key:
|
generate-test-key:
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
*Observes an NVMe drive's real-world use and translates that history into an understandable endurance outlook.*
|
*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)
|
- **Python ≥ 3.10** (verified at install time)
|
||||||
- **smartmontools** (`smartctl` — 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).
|
No other OS packages or Python dependencies beyond [Textual](https://textual.textualize.io/) (pinned in the lockfile).
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ The Gitea instance Debian registry signs metadata with its own key. Verify the
|
|||||||
instance key fingerprint (TOFU hardening):
|
instance key fingerprint (TOFU hardening):
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Fingerprint: <print after first release — paste beside the curl one-liner>
|
Fingerprint: F937E81D2FB0736B15BC611884BEBD586DFAC010
|
||||||
```
|
```
|
||||||
|
|
||||||
Add the instance key and repository:
|
Add the instance key and repository:
|
||||||
@@ -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
|
from the raw URL in `gpgkey`) and `repo_gpgcheck=0` (metadata check left to
|
||||||
TLS).
|
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
|
### Package signature verification
|
||||||
|
|
||||||
The RPM payload is signed with the Fenris packaging key (RSA 3072).
|
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
|
### Dormant install
|
||||||
|
|
||||||
A fresh package install is fully dormant. Units are present but disabled;
|
A fresh package install is fully dormant. Its native scheduler is present but
|
||||||
nothing runs. The only opt-in is the sanctioned toggle:
|
disabled; nothing runs. The only opt-in is the sanctioned toggle:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
fenris monitor resume # enable timer + open first monitoring period
|
fenris monitor resume # enable scheduling + open first monitoring period
|
||||||
fenris monitor pause # close the period, disable timer
|
fenris monitor pause # close the period, disable scheduling
|
||||||
```
|
```
|
||||||
|
|
||||||
## Development install (make install)
|
## Development install (make install)
|
||||||
@@ -117,6 +157,7 @@ make purge # also removes /etc/fenris and /var/lib/fenris
|
|||||||
```bash
|
```bash
|
||||||
sudo apt update && sudo apt upgrade fenris # Debian/Ubuntu
|
sudo apt update && sudo apt upgrade fenris # Debian/Ubuntu
|
||||||
sudo dnf upgrade fenris # Fedora
|
sudo dnf upgrade fenris # Fedora
|
||||||
|
sudo xbps-install -Syu # Void Linux
|
||||||
```
|
```
|
||||||
|
|
||||||
### Development upgrade
|
### 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).
|
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`.
|
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
|
## 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 remove fenris # preserves config and store
|
||||||
sudo apt purge fenris # also removes config and store
|
sudo apt purge fenris # also removes config and store
|
||||||
sudo dnf remove fenris # preserves config and store
|
sudo dnf remove fenris # preserves config and store
|
||||||
|
sudo xbps-remove fenris # preserves config and store
|
||||||
```
|
```
|
||||||
|
|
||||||
### Development removal
|
### Development removal
|
||||||
@@ -163,7 +211,7 @@ Uninstall performs the sanctioned disable first (`fenris-monitor disable --now`)
|
|||||||
|
|
||||||
## Cadence drop-ins
|
## 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
|
```bash
|
||||||
sudo systemctl edit fenris-collect.timer
|
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.
|
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
|
## CLI reference
|
||||||
|
|
||||||
| Command | Behavior |
|
| 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` | 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 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 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 pause` | Sanctioned disable — asks for confirmation, then disables native scheduling and closes the monitoring period. |
|
||||||
| `fenris monitor resume` | Sanctioned enable — enables the timer and opens a monitoring period. No confirmation. |
|
| `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 set <json>` | CLI-side validation, then polkit-guarded persistence. |
|
||||||
| `fenris baseline clear` | Remove the endurance baseline. |
|
| `fenris baseline clear` | Remove the endurance baseline. |
|
||||||
| `fenris import <path>` | Idempotent single-transaction legacy import. |
|
| `fenris import <path>` | Idempotent single-transaction legacy import. |
|
||||||
@@ -191,11 +252,62 @@ No interval key exists in `/etc/fenris/fenris.conf`. Cadence is a systemd concer
|
|||||||
|
|
||||||
## Reading the dashboard
|
## Reading the dashboard
|
||||||
|
|
||||||
`fenris` opens the TUI dashboard.
|

|
||||||
|
|
||||||
|
Preview uses synthetic observations, not measurements from a real drive.
|
||||||
|
|
||||||
|
The Chalktone dashboard opens with a large **Live** activity plot. **Day** shows
|
||||||
|
hourly evidence for a selected date, labelled UTC, and **History** shows daily
|
||||||
|
evidence. Local-day totals keep their recorded timezone. Dotted traces show
|
||||||
|
measured read/write volumes, not transfer speed; missing evidence breaks the
|
||||||
|
trace. `?` marks a gap, `~` a partial total, and `u` unallocated daily volume.
|
||||||
|
Use the selected-point readout for exact values and evidence state.
|
||||||
|
|
||||||
|
Local-day totals use measured intervals inside each recorded local date. An
|
||||||
|
interval that crosses local midnight appears once as shared evidence and stays
|
||||||
|
outside both known totals. The dashboard labels current totals as so far and
|
||||||
|
shows incomplete or unavailable dates without treating them as zero. Older
|
||||||
|
UTC-only summaries cannot establish exact local-day totals.
|
||||||
|
|
||||||
|
- `v` cycles Live / Day / History; the tabs are also clickable.
|
||||||
|
- `←` / `→` inspect points; `w` switches read/write volume in every view.
|
||||||
|
- `[` / `]` browse dates, `g` enters a date, and `t` returns to today/live.
|
||||||
|
- `Tab` / `Shift+Tab` move focus; `z` expands the focused panel, and `z` or
|
||||||
|
`Esc` restores it. Monitoring status and controls remain visible.
|
||||||
|
- `s` cycles Chalktone, Amber, Nord, and High Contrast; saved theme preferences
|
||||||
|
survive upgrades. `m` toggles reduced motion.
|
||||||
|
|
||||||
|
On smaller terminals, textual summaries and scrollable panels keep evidence
|
||||||
|
accessible. Pause, resume, collect, disclosures, help, and quit remain available
|
||||||
|
in the fixed control row.
|
||||||
|
|
||||||
|
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`.
|
- **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.
|
- **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.
|
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.
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 296 KiB |
@@ -13,7 +13,7 @@ Fenris's current single process combines daemonization, a PID file, an HTTP dash
|
|||||||
## Decision
|
## 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).
|
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.
|
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.
|
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.
|
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.
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# 8. Native Void Linux support and XBPS delivery
|
||||||
|
|
||||||
|
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 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. 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.
|
||||||
|
|
||||||
|
Hosting evidence: [Gitea generic registry](https://docs.gitea.com/usage/packages/generic), [raw download routing](https://github.com/go-gitea/gitea/blob/main/routers/web/web.go), [download handler](https://github.com/go-gitea/gitea/blob/main/routers/web/repo/download.go), and [Void repository signing](https://docs.voidlinux.org/xbps/repositories/signing.html). Source inspection and an existing raw-file GET establish feasibility, not end-to-end XBPS validation.
|
||||||
|
|
||||||
|
The first supported Void target is x86_64 with glibc, matching the inspected development machine. Release validation must cover installation, real collection, pause/resume, reboot persistence, upgrade, and removal on that machine, preserving existing observation history. Debian/RPM compatibility remains part of the acceptance scope. Additional architectures and musl support are outside this first release.
|
||||||
|
|
||||||
|
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. 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.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# 10. Preserve local-day activity history with its recorded timezone
|
||||||
|
|
||||||
|
Status: Accepted; legacy-history migration and repair implemented in issue #99.
|
||||||
|
|
||||||
|
Fenris will retain local-day read/write summaries and the boundary evidence needed to interpret them in the existing observation store before three-minute detail expires after 14 days. Each historical summary retains its recorded timezone and day boundaries; this extends [ADR 0001](0001-observation-store-sqlite.md) while preserving the UTC hour/day evidence used for endurance projections. Collection derives new measured intervals. Ordered schema migration and explicit repair rebuild legacy summaries from surviving evidence; TUI and CLI readers remain read-only.
|
||||||
|
|
||||||
|
UTC hourly summaries alone cannot recover a local day whose midnight falls inside a UTC hour. Preserving labelled local summaries trades arbitrary future timezone reinterpretation for bounded detailed-history retention; retaining all fine-grained intervals indefinitely or estimating a split from UTC summaries would violate the agreed retention or evidence semantics.
|
||||||
|
|
||||||
|
Only surviving evidence may be used to derive older local summaries. Dates without enough evidence remain incomplete or unavailable; a measured interval crossing midnight is retained once as shared boundary evidence, never prorated or counted in full on both days. A later timezone change does not silently rewrite historical day boundaries.
|
||||||
|
|
||||||
|
The agreed presentation and validation requirements are in the [live drive activity specification](../spec/live-drive-activity.md).
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# 11. Preserve valid unpublished observations without exposing partial history
|
||||||
|
|
||||||
|
Status: Accepted; implemented on `main` for the planned v0.6.0 release (commit `e22b994`).
|
||||||
|
|
||||||
|
A non-invariant derivation failure must not discard valid acquired observations or report a successful collection: retain the observations as pending publication in the existing observation store and retry through the normal scheduled collection path. Readers continue to see the last consistent published evidence, with an explicit pending-publication explanation, rather than combining newly acquired counters with older derived totals. This trades recovery bookkeeping for preservation of measured evidence and consistent read views; neither discarding every valid acquisition on derivation failure nor exposing partially derived history satisfies both requirements.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- The publication distinction applies to every dependent reader, including activity, freshness, controller-segment interpretation and projection inputs, not just the graph. ADR 0001's freshest-sample signal means the freshest published sample; an unpublished observation must not make published evidence appear fresh.
|
||||||
|
- Pending-work bookkeeping qualifies ADR 0005's recovery-without-new-state wording: it records unfinished evidence publication, not a new health grade or escalation mechanism. Last-collection outcomes still come from the existing native monitoring and logging paths.
|
||||||
|
- Invariant violations retain [ADR 0005](0005-failure-detection-and-recovery.md)'s write-nothing rule; retaining recoverable work is not permission to persist invalid observations. Actual store faults retain their existing refusal and degradation behavior.
|
||||||
|
- Recovery and retention remain collector-owned. Repeated recovery must not duplicate measured volume, and required source evidence cannot be pruned before trustworthy derived evidence is durable.
|
||||||
|
- This extends [ADR 0001](0001-observation-store-sqlite.md)'s consistent read model and [ADR 0010](0010-local-day-activity-history.md)'s preservation rule without another observation store, sampler, background process or retry cadence. Pending-work metadata is not a persisted projection or a replacement for journalled collection outcomes.
|
||||||
|
- Pending observations use private staging separate from published samples and derived evidence inside the same observation store. This makes exclusion from ordinary reader and projection queries structural, rather than requiring each query to remember a publication filter; the exact table layout remains an implementation detail.
|
||||||
|
|
||||||
|
## Bounded pending work
|
||||||
|
|
||||||
|
Pending publication has a fixed initial admission capacity of 6,720 observations, equivalent to 14 days at the default three-minute cadence. This is a count limit, not an expiry rule: older pending observations are never discarded merely to admit newer ones.
|
||||||
|
|
||||||
|
The collection module attempts recovery and checks capacity before invoking acquisition. If capacity remains exhausted, the collection is unsuccessful and visibly explains why no new observation was acquired; normal scheduled recovery attempts continue. This deliberately accepts a gap in new observations rather than unlimited pending growth or loss of already retained evidence. It is not a deliberate disable, changes no monitoring intent, and never permits fabricated activity across the gap.
|
||||||
@@ -12,7 +12,7 @@ and destruction.
|
|||||||
| UID | `Fenris Packaging <packaging@bongbetic.com>` |
|
| UID | `Fenris Packaging <packaging@bongbetic.com>` |
|
||||||
| Expiry | 2 years from creation |
|
| Expiry | 2 years from creation |
|
||||||
| Hierarchy | Single key — no master/subkey split (single maintainer, manual builds) |
|
| Hierarchy | Single key — no master/subkey split (single maintainer, manual builds) |
|
||||||
| Private key storage | Password manager only |
|
| Private key storage | Gitea repository Actions secret `GPG_PRIVATE_KEY` |
|
||||||
| Public key storage | `packaging/keys/fenris-packaging.asc` in-repo, release notes, docs |
|
| Public key storage | `packaging/keys/fenris-packaging.asc` in-repo, release notes, docs |
|
||||||
| Keyservers | Never — TOFU-over-TLS via raw URL |
|
| Keyservers | Never — TOFU-over-TLS via raw URL |
|
||||||
|
|
||||||
@@ -37,71 +37,72 @@ gpg --armor --export packaging@bongbetic.com > packaging/keys/fenris-packaging.a
|
|||||||
gpg --fingerprint packaging@bongbetic.com
|
gpg --fingerprint packaging@bongbetic.com
|
||||||
```
|
```
|
||||||
|
|
||||||
Save the **private key** to the password manager immediately:
|
Provision the **private key** as the repository Actions secret `GPG_PRIVATE_KEY`.
|
||||||
|
Run the export on the trusted key-generation machine, then enter its output in
|
||||||
|
the Gitea repository's Actions secret settings. Do not save it in the checkout,
|
||||||
|
logs, or a runner directory. The release workflow checks its fingerprint
|
||||||
|
against the committed public key before signing.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
gpg --armor --export-secret-keys packaging@bongbetic.com
|
gpg --armor --export-secret-keys packaging@bongbetic.com
|
||||||
```
|
```
|
||||||
|
|
||||||
Then **delete the private key from the local keyring** — it must never persist
|
After provisioning the secret, delete the private key from the key-generation
|
||||||
on any build host:
|
keyring:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
gpg --delete-secret-keys packaging@bongbetic.com
|
gpg --batch --yes --delete-secret-keys packaging@bongbetic.com
|
||||||
gpg --delete-keys packaging@bongbetic.com
|
gpg --batch --yes --delete-keys packaging@bongbetic.com
|
||||||
```
|
```
|
||||||
|
|
||||||
The committed `fenris-packaging.asc` must contain the real public key (replace
|
The committed `fenris-packaging.asc` must contain the real public key (replace
|
||||||
the placeholder comments).
|
the placeholder comments).
|
||||||
|
|
||||||
|
## XBPS signing key
|
||||||
|
|
||||||
|
XBPS uses a separate RSA 3072 key. Its private half is stored as the Gitea
|
||||||
|
repository Actions secret `XBPS_SIGNING_KEY`. The corresponding public key is
|
||||||
|
published at
|
||||||
|
`https://git.bongbetic.com/xavierk/Fenris-xbps/raw/branch/stable/keys/fenris-xbps-signing.pub`,
|
||||||
|
with fingerprint `SHA256:AvPMRlKMikPg75u0iKr8AUkxlfU/Ad4k/S4o2M9W4/w`.
|
||||||
|
The secret must match that public key.
|
||||||
|
|
||||||
|
The release workflow writes the key to `~/.ssh/id_xbps` to sign the XBPS
|
||||||
|
package. A requested XBPS publication also uses the key to sign repository
|
||||||
|
metadata. A final `always()` cleanup removes the runner copy after publication
|
||||||
|
and release asset upload, including when an earlier step fails.
|
||||||
|
|
||||||
## Per-release signing flow
|
## Per-release signing flow
|
||||||
|
|
||||||
Each release performs: **import → sign → delete**. The private key is never
|
Each tagged release performs: **import → verify → sign → delete** on the
|
||||||
stored on disk longer than the release takes.
|
repository-scoped Gitea Actions runner. The Gitea secret remains configured;
|
||||||
|
the runner's keyring copy is removed after the job.
|
||||||
|
|
||||||
### Step 1: Import the private key
|
### Step 1: Push the release tag
|
||||||
|
|
||||||
Retrieve the private key from the password manager and import it:
|
After updating the version and dated changelog section, push the matching tag:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
gpg --import /tmp/packaging-key-private.asc
|
git push origin v<version>
|
||||||
rm /f /tmp/packaging-key-private.asc # Shred if possible
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step 2: Build and sign packages
|
### Step 2: Build, verify, and sign packages
|
||||||
|
|
||||||
The Makefile target `make release` handles signing automatically when the
|
The release workflow imports `GPG_PRIVATE_KEY`, checks it against
|
||||||
key is in the keyring:
|
`packaging/keys/fenris-packaging.asc`, builds packages, signs the RPM and
|
||||||
|
clearsigned checksum manifest, validates both, and publishes the release. The
|
||||||
|
workflow imports `XBPS_SIGNING_KEY` separately and signs the XBPS package.
|
||||||
|
XBPS publication is optional and also signs repository metadata; it requires
|
||||||
|
host acceptance and explicit selection during workflow dispatch.
|
||||||
|
|
||||||
```bash
|
### Step 3: Verify runner cleanup
|
||||||
make release # builds, signs RPM, clearsigns SHA256SUMS, prints upload steps
|
|
||||||
```
|
|
||||||
|
|
||||||
Under the hood:
|
The workflow's `always()` cleanup removes the GPG key from the runner's keyring
|
||||||
|
and deletes `~/.ssh/id_xbps`, including after a failed job. Confirm no signing
|
||||||
|
key remains on the runner after the release job.
|
||||||
|
|
||||||
1. `rpmsign --addsign` signs the RPM payload with the packaging key
|
The Gitea Actions secret remains the approved signing source. Do not copy it to
|
||||||
(invoked by `make sign-rpm`).
|
the runner or repository outside the workflow.
|
||||||
2. `sha256sum` generates the checksum manifest.
|
|
||||||
3. `gpg --clearsign` produces `SHA256SUMS.asc` with the packaging key.
|
|
||||||
|
|
||||||
### Step 3: Delete the private key
|
|
||||||
|
|
||||||
Immediately after signing:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
gpg --delete-secret-keys packaging@bongbetic.com
|
|
||||||
gpg --delete-keys packaging@bongbetic.com
|
|
||||||
```
|
|
||||||
|
|
||||||
Verify the key is gone:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
gpg --list-keys packaging@bongbetic.com
|
|
||||||
# Should produce: gpg: keyblock resource ...: No such file or directory
|
|
||||||
```
|
|
||||||
|
|
||||||
The entire import → sign → delete cycle should take minutes. The private key
|
|
||||||
must never be left in any keyring between releases.
|
|
||||||
|
|
||||||
## Key rotation (outline)
|
## Key rotation (outline)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Glint dashboard design adoption
|
||||||
|
|
||||||
|
Research date: 2026-09-19. Status: feasibility findings with interview decisions in progress. This note does not authorize implementation or change Fenris's accepted behavior.
|
||||||
|
|
||||||
|
## Finding
|
||||||
|
|
||||||
|
Fenris can adopt a Glint-inspired terminal dashboard through an independently authored redesign of its existing Textual presentation layer. Glint is a Rust terminal application, not an HTML/CSS dashboard or a drop-in Textual component library. Its most relevant ideas are a compact pane grid, consistent title rows and metadata, obvious focus, restrained colors, and enlarging a focused pane. A wholesale Glint integration would introduce a different UI runtime, unrelated application infrastructure, and a licensing decision without being necessary to achieve this visual direction.
|
||||||
|
|
||||||
|
The user has selected **Chalktone / screenshot 3**, **a large activity chart as the primary panel**, and **panel styling plus keyboard focus and zoom**. The activity panel has **Live / Day / History tabs**; enlarging a panel retains a **fixed monitoring status, freshness, and control strip**. A general dashboard builder is outside the selected scope. The developing product specification is [Glint dashboard design](../spec/glint-dashboard-design.md).
|
||||||
|
|
||||||
|
## Primary-source snapshot
|
||||||
|
|
||||||
|
Inspected Glint's default branch at commit [`c1d73d3e8ead2f4069630b2a237306af8f6e69c8`](https://github.com/ntrospect0/glint/tree/c1d73d3e8ead2f4069630b2a237306af8f6e69c8), committed 2026-07-19. A shallow reference checkout was created outside Fenris at `/tmp/fenris-glint-reference`.
|
||||||
|
|
||||||
|
- [README](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/README.md) contains three dashboard screenshots, a setup screenshot, and a live-capture link. The first two dashboard screenshots use `tokyonight`; the third uses `chalktone`. They show example compositions, not one mandatory layout.
|
||||||
|
- [Screenshot 1](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/docs/screenshots/glint-demo1.png), [screenshot 2](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/docs/screenshots/glint-demo2.png), [screenshot 3](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/docs/screenshots/glint-demo3.png), and [setup screenshot](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/docs/screenshots/glint-setup.png) are versioned in the repository. This source investigation does not substitute for visual inspection of those images.
|
||||||
|
- [Cargo.toml](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/Cargo.toml) declares Rust edition 2021, package version 0.5.0, Ratatui 0.28, Crossterm 0.28, and a `glint` binary. It also brings Tokio, HTTP clients, configuration/watch infrastructure, and optional widget dependencies.
|
||||||
|
- [GitHub repository API](https://api.github.com/repos/ntrospect0/glint), checked on the research date, reports an unarchived repository created 2026-05-27. [GitHub releases API](https://api.github.com/repos/ntrospect0/glint/releases) returned no releases. The [changelog](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/CHANGELOG.md) labels 0.5.0 and 0.4.0 unreleased, and README installation is from source. This is evidence of a young project and its distribution state, not proof that it is abandoned.
|
||||||
|
- [CI configuration](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/.github/workflows/ci.yml) runs Cargo tests and Clippy on Ubuntu; Clippy warnings do not fail CI. Source review alone does not establish that current CI passes. No Glint build or test run was performed for this assessment.
|
||||||
|
|
||||||
|
## What can transfer
|
||||||
|
|
||||||
|
| Glint pattern and source | Fit for Fenris | Scope implication |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Pane grid with row/column spans: [layout model](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/src/config/layout.rs), [default configuration](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/src/config/defaults/config.toml). | Fenris already uses a Textual grid and rounded bordered panes. A considered re-layout can use that existing ownership. | Decide which Fenris facts deserve persistent space. A fixed layout is much smaller than Glint's user-composable dashboard system. |
|
||||||
|
| Title integrated into the border, right-aligned metadata, focus treatment, shortcut indication: [UI title helpers](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/src/ui/mod.rs). | Useful for clearly named activity, history, drive, and monitoring panes. Date/range/freshness can become concise pane metadata where legible. | Independently implement the visible behavior in Fenris; do not translate or copy these GPL helpers. Ensure essential evidence is not merely truncated away. |
|
||||||
|
| Semantic theme roles separating border, title, metadata, and text: [theme model](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/src/theme/mod.rs), [bundled schemes](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/src/config/defaults/colorschemes.toml). | Fenris already owns themes in `src/fenris/themes.py`. Extend that role system for a chosen visual direction while keeping semantic status colors and text. | Select the preferred screenshot/palette before making the default. High Contrast and reduced-motion preferences already exist and must remain usable. |
|
||||||
|
| Tab/click focus, keyboard shortcuts, and focused-pane enlargement: [README controls](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/README.md), [app focus/zoom ownership](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/src/app.rs). | Particularly useful for graphs when a small terminal limits detail. | Zoom is new interaction behavior, not just styling. Selection/date/focus must survive entry, exit, resize, and refresh; essential fault/pause state needs a visibility decision. |
|
||||||
|
| Multiple widgets stacked in one cell, cycled with `.`/`,`: [stack implementation](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/src/widgets/stack.rs). | A possible way to expose secondary details without permanent screen cost. | Hiding monitoring state, confidence, or evidence behind inactive tabs would weaken Fenris's current contract. Choose deliberately; not assumed needed. |
|
||||||
|
| Responsive views exposing more detail at larger sizes: [view tiers](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/src/widgets/view_tier.rs), [widget author guide](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/docs/widget-sdk.md). | Useful principle, but Glint's breakpoints describe its content and are not Fenris requirements. | Preserve Fenris's constrained-terminal facts and controls, then design larger views. Do not simply reuse Glint's threshold values. |
|
||||||
|
| Compact bottom status bar: [status bar](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/src/ui/status_bar.rs). | Supports a quieter action/focus rail. | Fenris has real monitoring continuity, freshness, collection outcome, authentication, and quit semantics. Its footer cannot be reduced to Glint's version/clock/theme bar. |
|
||||||
|
|
||||||
|
Glint's calendar, stocks, news, email, weather, notes, galleries, external credentials, and profile/setup machinery are outside the stated Fenris redesign. Its widget SDK adds widgets inside the Rust application via a trait and registry; it is not an existing Python integration seam ([widget SDK](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/docs/widget-sdk.md), [widget interface](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/src/widgets/mod.rs)).
|
||||||
|
|
||||||
|
## Fenris ownership and constraints
|
||||||
|
|
||||||
|
The present application is Python with Textual, pinned to Textual 8.2.8 in [`requirements.txt`](../../requirements.txt). [`FenrisTuiApp`](../../src/fenris/tui.py) already owns the grid, header, bordered panes, compact layout, help, action bindings, and theme registration; `DailyBarGraph` and `LiveActivityGraph` already own graph selection/rendering. [`themes.py`](../../src/fenris/themes.py) owns Amber, Nord, High Contrast, and status color precedence. These are the natural extension points; a new backend or service is unnecessary for a visual redesign.
|
||||||
|
|
||||||
|
The design must preserve these existing responsibilities and evidence semantics:
|
||||||
|
|
||||||
|
- The collector owns privileged drive acquisition and observation-store writes; the TUI is an unprivileged reader and dispatches administrative actions through the sanctioned helper. A visual redesign does not imply a second sampler or store writer. Sources: [`CONTEXT.md`](../../CONTEXT.md), [redesign spec](../spec/fenris-redesign.md), [`tui.py`](../../src/fenris/tui.py).
|
||||||
|
- Usage-adjusted theoretical lifespan remains a write-endurance projection, accompanied by projection confidence and supporting facts. It must not become a physical failure countdown. Unknown, unavailable, warming, stale, and store-fault states require meaningful presentation. Sources: [`CONTEXT.md`](../../CONTEXT.md), [projection ADR](../adr/0002-projection-model-sustained-regime.md), [live activity specification](../spec/live-drive-activity.md).
|
||||||
|
- Gaps must not become zeros, and unavailable measurements must not become decorative smooth curves. Local-day labels, read/write units, incomplete evidence, selected dates, and timezone context matter. Sources: [local-day history ADR](../adr/0010-local-day-activity-history.md), [live activity specification](../spec/live-drive-activity.md).
|
||||||
|
- Pause is a deliberate disable; quitting only leaves the TUI. Monitoring continuity, last collection outcome, and freshness must remain understandable. Sources: [service lifecycle ADR](../adr/0003-service-lifecycle-and-sanctioned-toggle.md), [dashboard clarity specification](../spec/dashboard-clarity.md), [`status_composition.py`](../../src/fenris/status_composition.py).
|
||||||
|
- The current source switches to constrained presentation below 80 columns or 24 rows; the accepted live-activity specification requires daily totals, dates, evidence labels, confidence, and controls to remain accessible on narrow terminals. A new design must be reviewed against real target dimensions. Sources: [`tui.py`](../../src/fenris/tui.py), [live activity specification](../spec/live-drive-activity.md).
|
||||||
|
|
||||||
|
The fixed Panes information architecture is normative in the older [redesign specification, section 7](../spec/fenris-redesign.md), and [dashboard clarity](../spec/dashboard-clarity.md) says it preserves that architecture and prescribes a separate quit rail. A significantly different pane hierarchy or footer should explicitly amend those presentation decisions when the user selects the new design, rather than silently treating old contracts as irrelevant. The underlying evidence and action semantics can be retained.
|
||||||
|
|
||||||
|
## License boundary
|
||||||
|
|
||||||
|
Glint declares **GPL-3.0-or-later** in [Cargo.toml](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/Cargo.toml) and source SPDX headers. Its [README license section](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/README.md) says distributed modified versions must remain GPL-licensed with copyright notices, and its [LICENSE](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/LICENSE) specifies the distribution obligations. Fenris deliberately uses MIT across source and packages under [ADR 0009](../adr/0009-mit-license.md).
|
||||||
|
|
||||||
|
Copying or translating Glint implementation into an integrated distributed Fenris would therefore raise GPL compliance and licensing choices that conflict with keeping the combined implementation solely MIT. It is not an adoption route to take implicitly. The recommended route is independently authored Fenris UI code using common layout/interaction ideas observed in Glint, without copying Glint code or assets. If direct implementation reuse becomes a requirement, resolve licensing or separate permission first; this research does not establish that permission.
|
||||||
|
|
||||||
|
## Interview state
|
||||||
|
|
||||||
|
The selected direction is an independently implemented Chalktone-inspired dashboard with a fixed Fenris layout, an activity-first hierarchy, keyboard focus and enlargement, one activity panel with Live / Day / History tabs, and a persistent monitoring status/freshness/control strip during enlargement. Existing 80×24 support and constrained reflow are retained rather than reopened as a new minimum-size decision. The [developing design specification](../spec/glint-dashboard-design.md) owns the precise requirements and any remaining decisions; this research note is supporting evidence.
|
||||||
|
|
||||||
|
No product code, glossary, ADR, or existing acceptance criteria were changed by this investigation. Keyboard focus/zoom feasibility does not establish that exact interaction behavior has been implemented or runtime-validated in Fenris.
|
||||||
|
|
||||||
|
## Validation scope
|
||||||
|
|
||||||
|
Evidence comes from the pinned Glint source, its checked-in documentation/screenshots, GitHub's first-party API, and Fenris's current source and accepted documents. The main design investigation visually inspected screenshots 1 and 3 and separately verified Textual grid documentation.
|
||||||
|
|
||||||
|
The main investigation also reviewed synthetic renders of current Fenris at 140×44 and 80×24, after refresh cleared the launch authentication banner, using installed Textual 8.2.7 (the repository lock is 8.2.8). The captures are `/tmp/fenris-current-capture-3xdv46jm/fenris-current-140x44.png` and `/tmp/fenris-current-capture-3xdv46jm/fenris-current-80x24.png`. They showed a large blank vertical area; at 80×24 the drive, monitoring status, and actions fell below the initial viewport. This supports rearranging the panels and keeping the status/control strip visible, but is not a diagnosis of the deployed drive or proof across every state.
|
||||||
|
|
||||||
|
No Glint runtime was executed, and no Glint asset was copied into the product. No accessibility audit was conducted. This research establishes architectural feasibility and records decisions; it does not claim pixel fidelity, a working integration, an upstream test result, or a measured performance result.
|
||||||
@@ -49,7 +49,7 @@ Status: Accepted — resolves [Define cross-cutting acceptance criteria](https:/
|
|||||||
## Collector lifecycle and privilege boundaries (ADR 0003)
|
## 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-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-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-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.
|
- **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.
|
||||||
@@ -144,19 +144,3 @@ Decided in [Write the dashboard clarity acceptance criteria](https://git.bongbet
|
|||||||
- **DC-6** (A) CHANGELOG.md shape (Keep a Changelog 1.1): `## [Unreleased]` always present at top, even empty; version headings `## [X.Y.Z] - YYYY-MM-DD` with strict ISO date; categories Added/Changed/Fixed only, security folding into Fixed; entries are single `- ` bullets, imperative mood, user-facing, no commit hashes or issue numbers.
|
- **DC-6** (A) CHANGELOG.md shape (Keep a Changelog 1.1): `## [Unreleased]` always present at top, even empty; version headings `## [X.Y.Z] - YYYY-MM-DD` with strict ISO date; categories Added/Changed/Fixed only, security folding into Fixed; entries are single `- ` bullets, imperative mood, user-facing, no commit hashes or issue numbers.
|
||||||
- **DC-7** (A) Extraction fails closed: `scripts/extract_changelog.py` slices the requested version's section verbatim and never reads `[Unreleased]`; a missing or empty section or a malformed date produces `::error::` and a nonzero exit; the release workflow fails when the pushed tag ≠ `v{version from pyproject.toml}` (guard skipped on `workflow_dispatch`).
|
- **DC-7** (A) Extraction fails closed: `scripts/extract_changelog.py` slices the requested version's section verbatim and never reads `[Unreleased]`; a missing or empty section or a malformed date produces `::error::` and a nonzero exit; the release workflow fails when the pushed tag ≠ `v{version from pyproject.toml}` (guard skipped on `workflow_dispatch`).
|
||||||
- **DC-8** (A/P) Release body: the body is the extracted section verbatim plus the standing footer from `packaging/release-footer.md`; a re-run against an existing release PATCHes the body (re-sync is a feature) while uploaded assets skip idempotently. A covers assembly/PATCH-logic unit tests; P is one scripted `workflow_dispatch` verification of body assembly.
|
- **DC-8** (A/P) Release body: the body is the extracted section verbatim plus the standing footer from `packaging/release-footer.md`; a re-run against an existing release PATCHes the body (re-sync is a feature) while uploaded assets skip idempotently. A covers assembly/PATCH-logic unit tests; P is one scripted `workflow_dispatch` verification of body assembly.
|
||||||
|
|
||||||
## TUI polish and hourly history ([Fenris TUI polish and hourly history](https://git.bongbetic.com/xavierk/Fenris/issues/65))
|
|
||||||
|
|
||||||
Proposed by [Approve the Fenris TUI polish specification and handoff](https://git.bongbetic.com/xavierk/Fenris/issues/70), from the companion specification [`fenris-tui-polish-hourly-history.md`](fenris-tui-polish-hourly-history.md). This section amends the frozen redesign and prior dashboard-clarity criteria without editing their historical source specs. Where these criteria conflict with older TUI/header/history criteria, these newer criteria win. In particular, **TPH-1** supersedes **DC-1**'s header/credit placement; **TPH-2** and **TPH-3** refine **CI-2**, **LC-10**, and **TUI-4** status rendering; **TPH-4** through **TPH-7** refine **ST-5**, **PR-2** through **PR-9**, and **FL-1** through **FL-4** with the approved hourly-history and UTC-accounting contracts.
|
|
||||||
|
|
||||||
- **TPH-1** (A) *Titlebox and maker credit*: the TUI renders a top titlebox exactly `🐺 Fenris by Bongbetic`, falling back exactly to `Fenris by Bongbetic` when the wolf glyph is unsupported or width-unstable; no replacement-box glyph is shown; the old service-strip `by Bongbetic` credit is absent; `fenris status` renders no titlebox. Continuity, paused, quit, auth, parity, and release-notes behavior from **DC-2** through **DC-8** remains unchanged.
|
|
||||||
- **TPH-2** (A) *Status lattice and precedence*: fixture-driven TUI status rendering covers Monitoring, Collecting, Paused, Waiting, Interrupted, Error, Stale, and Unknown with the approved glyphs/text, semantic colours, and reason lines; only Monitoring's dot blinks, never text; reduced motion makes it steady; precedence is Error > Interrupted > Paused > Stale > Waiting > Monitoring > Unknown, with Collecting as an overlay except over store fault.
|
|
||||||
- **TPH-3** (A/P) *CLI/status parity and timing*: `fenris status` renders the same status vocabulary, glyphs, precedence, and reason lines statically; freshness, last outcome, boot enablement, and collection activity remain separate facts; a lightweight 5 s unit-state poll can move cached freshness boundaries without a store read, while new samples appear only after the normal store refresh; store faults render `observation store unreadable — see journal` and suppress store-dependent views.
|
|
||||||
- **TPH-4** (A) *Warm-up and withheld estimates*: projection warm-up shows `Building evidence — N of 14 days observed · Q qualifying` plus `First lifespan estimate after 12 qualifying days`; the gate is 14 represented UTC dates in the current controller segment with at least 12 qualifying, while Supported separately requires 14 qualifying dates and all existing prerequisites. Missing baseline, unsupported write counters, warm-up, stale evidence, paused days, and unavailable numerators render explicit reason lines, never blank or misleading zero; first graph-data availability is independent of lifespan-estimate availability.
|
|
||||||
- **TPH-5** (A) *Collector-owned history publication and first data*: collection publishes validated sample → usage interval → hour observation/day aggregate results consistently before reporting success; the TUI remains read-only. Zero samples show awaiting-first-sample; one sample shows `Awaiting another sample`; the first compatible sample pair can show measured partial-hour `so far`; measured zero is `0 B`; missing, unsupported, invalid, or unavailable evidence is never converted to zero.
|
|
||||||
- **TPH-6** (A) *Local display days, attribution, gaps, pauses, and repair*: history browsing groups retained evidence by the current system timezone with the timezone label visible, including DST and fractional-offset cases; timestamped usage intervals are retained indefinitely alongside hour/day summaries, while raw samples keep the 14-day policy except needed boundary anchors. Measured interval totals are preserved once; cross-boundary shares render as unallocated usage rather than interpolation or endpoint assignment; gaps remain distinct from zero; future time is not counted; deliberate-disable time is excluded; pause-crossing bytes are not counted as monitored totals; repair is transactional/idempotent and cannot overwrite valid older history with incomplete reconstruction.
|
|
||||||
- **TPH-7** (A) *UTC projection accounting and evaluability*: 7/28/90-day scenario windows end at the latest published usage-evidence endpoint `T` and start exactly 7/28/90 × 86,400 seconds earlier; denominators are monitored wall-clock seconds in the same span; rates are withheld when the monitored numerator cannot be established. Byte-allocation completeness and coverage are independent. Current partial UTC dates can qualify provisionally using elapsed monitored time; habit changes require completed consecutive UTC days with evaluable totals; unknown daily totals block burst/habit checks and Supported confidence; resets/replacements and legacy summaries obey the approved segment and actual-precision rules without changing lifespan math or numeric thresholds.
|
|
||||||
- **TPH-8** (A/M) *Writes-only graph and drill-down*: the TUI renders a writes-only daily bar graph, default 14 days, selectable 7/14/28/90 days, labelled `usage history · Local · UTC±HH:MM · <tz name>`; graph focus supports `←`/`→`, `Enter`, `Esc`/`Backspace`, and `1`/`2`/`3`/`4`, with mouse equivalents for select/drill/back where Textual support is available. Daily bars drill into hourly bars and back. The legend distinguishes allocated `█`, unallocated `▒`, gap `░`, measured zero `·`, partial `┄`, and selection `▼`; selected readout states totals, evidenced hours, unallocated usage, coverage, and partial elapsed facts. Reads graphing is out of scope.
|
|
||||||
- **TPH-9** (A) *Terminal size and graph implementation*: at 80×24 the default range graph and hourly drill-down fit; below 80×24 the graph region hides and shows a one-line textual history summary plus exactly `graph needs ≥80×24`, while titlebox, status reason, drive health, service facts, quit rail/action affordances, and selected-day context survive. The graph uses a custom block-glyph renderable; no new plotting dependency is added for this graph.
|
|
||||||
- **TPH-10** (A/M) *Colour presets, persistence, and reduced motion*: Amber, Nord, and High Contrast presets are available; Amber is the default and keeps the graph amber by default; status semantic colours/glyphs/text outrank theme styling. Preset and reduced-motion choices persist per unprivileged user at `${XDG_CONFIG_HOME:-~/.config}/fenris/tui.json`, not in `/etc/fenris/fenris.conf`, the observation store, helper state, package config, or collector/device configuration; missing preferences default to Amber and normal motion; `t preset` and `m motion` controls plus accessible clickable equivalents are available; preferences never affect collection, projection, history evidence, or `fenris status`.
|
|
||||||
- **TPH-11** (A) *Drive health and settings grouping*: vendor wear renders under Drive health with temperature, spare, media errors, unsafe shutdowns, power-on hours, cycles, capacity, and written-total context, and remains context rather than a second projection. Settings is read-only and limited to device selector, endurance baseline/provenance, retention facts, and TUI display preferences; no custom colour editor exists.
|
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# Fenris dashboard clarity specification
|
# Fenris dashboard clarity specification
|
||||||
|
|
||||||
|
**Presentation amendment (2026-09-19):** The accepted
|
||||||
|
[Glint-inspired dashboard design](glint-dashboard-design.md) supersedes the
|
||||||
|
preserved Panes arrangement and standalone heavy quit rail. Identity, continuity,
|
||||||
|
paused-state explanations, and the distinction between quitting and pausing
|
||||||
|
remain required; quit now has an explicit entry in the fixed controls.
|
||||||
|
|
||||||
**Status: decision-complete.** Assembled by [Assemble the dashboard clarity specification and close the map](https://git.bongbetic.com/xavierk/Fenris/issues/60) from the closed tickets of the Wayfinder map [Chart Fenris dashboard clarity](https://git.bongbetic.com/xavierk/Fenris/issues/55). This document is normative for the follow-up **execution effort**; nothing here is implemented by the map.
|
**Status: decision-complete.** Assembled by [Assemble the dashboard clarity specification and close the map](https://git.bongbetic.com/xavierk/Fenris/issues/60) from the closed tickets of the Wayfinder map [Chart Fenris dashboard clarity](https://git.bongbetic.com/xavierk/Fenris/issues/55). This document is normative for the follow-up **execution effort**; nothing here is implemented by the map.
|
||||||
|
|
||||||
**Canonical roles.** The [redesign specification](fenris-redesign.md) (frozen) and [ADRs 0001–0007](../adr/) remain authoritative and untouched — this is a companion spec covering five dashboard clarity additions plus the changelog-driven release-notes mechanism. The [criteria register](acceptance-criteria.md) carries the testable statements: **DC-1–DC-8**, appended by this assembly, with **TUI-4's binding list amended** (§4). Terminology follows the glossary in [`CONTEXT.md`](../../CONTEXT.md), including *Deliberate disable* and *Release*.
|
**Canonical roles.** The [redesign specification](fenris-redesign.md) (frozen) and [ADRs 0001–0007](../adr/) remain authoritative and untouched — this is a companion spec covering five dashboard clarity additions plus the changelog-driven release-notes mechanism. The [criteria register](acceptance-criteria.md) carries the testable statements: **DC-1–DC-8**, appended by this assembly, with **TUI-4's binding list amended** (§4). Terminology follows the glossary in [`CONTEXT.md`](../../CONTEXT.md), including *Deliberate disable* and *Release*.
|
||||||
@@ -119,4 +125,4 @@ This resolves the map's README-wording fog: the wording is decided here; the act
|
|||||||
|
|
||||||
## 9. Out of scope
|
## 9. Out of scope
|
||||||
|
|
||||||
Executing any of this — code, tests, releases — and any TUI layout or information-architecture redesign beyond the five additions named above. Execution is a fresh effort after handoff.
|
Executing any of this — code, tests, releases — and any TUI layout or information-architecture redesign beyond the five additions named above. Execution is a fresh effort after handoff.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ Every constant is defined once, in the section named below; other sections cite,
|
|||||||
|
|
||||||
| Constant | Value | Defined in |
|
| 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 |
|
| First-boot delay | 2 min (`OnBootSec`) | §8.2 |
|
||||||
| Timer accuracy window | 30 s (`AccuracySec`) | §8.2 |
|
| Timer accuracy window | 30 s (`AccuracySec`) | §8.2 |
|
||||||
| Collection-run timeout | 90 s (`TimeoutStartSec`) | §8.2 |
|
| Collection-run timeout | 90 s (`TimeoutStartSec`) | §8.2 |
|
||||||
@@ -362,6 +362,12 @@ Never "82 % confidence" or "95 % accurate".
|
|||||||
|
|
||||||
## 7. Panes TUI
|
## 7. Panes TUI
|
||||||
|
|
||||||
|
**Presentation amendment (2026-09-19):** The accepted
|
||||||
|
[Glint-inspired dashboard design](glint-dashboard-design.md) supersedes this
|
||||||
|
section's panel arrangement and graph appearance with an activity-first layout,
|
||||||
|
dotted volume plots, Live / Day / History tabs, and focused-panel zoom. Shared
|
||||||
|
evidence, projection, authentication, and monitoring-action contracts still apply.
|
||||||
|
|
||||||
**Decisions:** [Prototype the TUI information architecture](https://git.bongbetic.com/xavierk/Fenris/issues/3) (Variant A adopted), [Evaluate Python TUI frameworks](https://git.bongbetic.com/xavierk/Fenris/issues/6) (Textual). **ADRs:** [0003](../adr/0003-service-lifecycle-and-sanctioned-toggle.md) §§8, 10; [0004](../adr/0004-install-upgrade-removal-lifecycle.md) §10. **Criteria:** TUI-1–TUI-4, CI-1, CI-2, CI-4. The [prototype](https://git.bongbetic.com/xavierk/Fenris/src/branch/prototype/tui-information-architecture/prototype/tui-ia) is visual reference only; this section is normative.
|
**Decisions:** [Prototype the TUI information architecture](https://git.bongbetic.com/xavierk/Fenris/issues/3) (Variant A adopted), [Evaluate Python TUI frameworks](https://git.bongbetic.com/xavierk/Fenris/issues/6) (Textual). **ADRs:** [0003](../adr/0003-service-lifecycle-and-sanctioned-toggle.md) §§8, 10; [0004](../adr/0004-install-upgrade-removal-lifecycle.md) §10. **Criteria:** TUI-1–TUI-4, CI-1, CI-2, CI-4. The [prototype](https://git.bongbetic.com/xavierk/Fenris/src/branch/prototype/tui-information-architecture/prototype/tui-ia) is visual reference only; this section is normative.
|
||||||
|
|
||||||
### 7.1 Framework and floor
|
### 7.1 Framework and floor
|
||||||
@@ -479,7 +485,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:
|
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);
|
- **missed** — between that and 48 h (a contributing fact);
|
||||||
- **stale** — ≥ 48 h, matching the §6.7 evidence gate;
|
- **stale** — ≥ 48 h, matching the §6.7 evidence gate;
|
||||||
- **empty store** — *"no observations yet"* with an enable hint.
|
- **empty store** — *"no observations yet"* with an enable hint.
|
||||||
|
|||||||
@@ -1,351 +0,0 @@
|
|||||||
# Fenris TUI polish and hourly history companion specification
|
|
||||||
|
|
||||||
**Status:** approval-ready draft for [Approve the Fenris TUI polish specification and handoff](https://git.bongbetic.com/xavierk/Fenris/issues/70). It becomes implementation-ready only when that ticket records human approval. This is a planning asset: no production code, release gate, package, installation change, or runtime diagnosis is made here.
|
|
||||||
|
|
||||||
**Canonical sources.** This companion integrates the closed decisions on [Fenris TUI polish and hourly history](https://git.bongbetic.com/xavierk/Fenris/issues/65): [Define trustworthy hourly history and first-data availability](https://git.bongbetic.com/xavierk/Fenris/issues/66), [Choose daily graph encoding and hourly drill-down](https://git.bongbetic.com/xavierk/Fenris/issues/68), [Reconcile unallocated usage with UTC projection evidence](https://git.bongbetic.com/xavierk/Fenris/issues/71), [Define monitoring signals and honest warm-up estimates](https://git.bongbetic.com/xavierk/Fenris/issues/67), and [Approve titlebox, health layout and colour presets](https://git.bongbetic.com/xavierk/Fenris/issues/69). Terminology follows [`CONTEXT.md`](../../CONTEXT.md), including *Usage interval*, *Unallocated usage*, *Local display day*, *Byte-allocation completeness*, and *Qualifying day*.
|
|
||||||
|
|
||||||
**Relationship to existing specs.** [`fenris-redesign.md`](fenris-redesign.md) stays frozen. This companion amends it without editing it. [`dashboard-clarity.md`](dashboard-clarity.md) remains binding except where this document explicitly supersedes its header/credit placement. In the tracker, [Implement dashboard clarity and release notes](https://git.bongbetic.com/xavierk/Fenris/issues/61) is currently closed; this companion is a new follow-on handoff, not a rewrite or reopening of that shipped umbrella.
|
|
||||||
|
|
||||||
**Binding language.** *Must*, *exactly*, and *never* are normative.
|
|
||||||
|
|
||||||
## 1. Supersession and preserved requirements
|
|
||||||
|
|
||||||
This companion supersedes only these dashboard-clarity identity placements:
|
|
||||||
|
|
||||||
- the old header `Fenris — NVMe endurance monitor`;
|
|
||||||
- the old dimmed `by Bongbetic` credit in the service strip.
|
|
||||||
|
|
||||||
The new identity contract is the top titlebox in §2. The titlebox is the sole maker-credit surface.
|
|
||||||
|
|
||||||
Everything else from [Chart Fenris dashboard clarity](https://git.bongbetic.com/xavierk/Fenris/issues/55) remains intact unless a more specific clause below amends its placement: the continuity wording, paused block and Deliberate-disable semantics, `q QUIT TUI` rail, footer action ownership, polkit-accurate auth banner, TUI/CLI wording parity where binding, changelog-driven release notes, and the quit-versus-pause distinction. Polkit wording remains polkit-accurate; no new user-facing TUI/status string says "sudo".
|
|
||||||
|
|
||||||
## 2. Product identity, health layout, and settings
|
|
||||||
|
|
||||||
### 2.1 Titlebox
|
|
||||||
|
|
||||||
The TUI's top titlebox must read exactly:
|
|
||||||
|
|
||||||
```text
|
|
||||||
🐺 Fenris by Bongbetic
|
|
||||||
```
|
|
||||||
|
|
||||||
If the wolf glyph is unsupported or would disturb titlebox width, the fallback is exactly:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Fenris by Bongbetic
|
|
||||||
```
|
|
||||||
|
|
||||||
Do not render tofu, replacement boxes, or an unstable emoji-width layout. The lifespan headline remains a drive/projection data surface, not the application title. `fenris status` does not render this titlebox.
|
|
||||||
|
|
||||||
### 2.2 Maker credit
|
|
||||||
|
|
||||||
Remove the duplicate `by Bongbetic` service-strip credit in the polished layout. The titlebox is the only maker-credit surface. This is the explicit amendment to the prior dashboard-clarity header/credit placement; it does not weaken the preserved continuity, pause, quit, auth, parity, or release-notes requirements.
|
|
||||||
|
|
||||||
### 2.3 Drive health and settings grouping
|
|
||||||
|
|
||||||
Move vendor wear under **Drive health**, alongside temperature, spare, media errors, unsafe shutdowns, power-on hours, cycles, capacity, and written-total context. Vendor wear remains context, never a second projection.
|
|
||||||
|
|
||||||
**Settings** remains read-only and limited to the device selector, endurance baseline/provenance, retention facts, and TUI display preferences. No custom colour editor is added.
|
|
||||||
|
|
||||||
## 3. Monitoring status, collection activity, and warm-up messaging
|
|
||||||
|
|
||||||
### 3.1 Status lattice
|
|
||||||
|
|
||||||
Every status line carries a glyph and text label. Colour is never the sole carrier. Text never blinks.
|
|
||||||
|
|
||||||
| State | Colour | Glyph | Motion | Exact status / explanation contract |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| Monitoring | green | `●` | blink 750 ms on / 750 ms off, status dot only | `● Monitoring` |
|
|
||||||
| Collecting | green | `◐` | steady | `◐ Collecting` — run in flight, bounded by the 90 s collection timeout |
|
|
||||||
| Paused | amber | `‖` | steady | `‖ Paused` — `monitoring paused — paused time excluded from your usage habit` |
|
|
||||||
| Waiting | amber | `○` | steady | `○ Waiting` — `last sample X ago`, `awaiting first sample`, or `awaiting another sample` |
|
|
||||||
| Interrupted | red | `⊘` | steady | `⊘ Interrupted` — `collection stopped outside Fenris — monitoring period still open` |
|
|
||||||
| Error | red | `✖` | steady | `✖ Error` — `last run failed (exit N)` plus `last good sample X ago` when data is still fresh, or `observation store unreadable — see journal` |
|
|
||||||
| Stale | red | `◌` | steady | `◌ Stale` — `last sample X days ago` when the timer is active and no failure is recorded |
|
|
||||||
| Unknown | grey | `?` | steady | `? Unknown` — `service state unavailable` |
|
|
||||||
|
|
||||||
Reduced motion, either from Textual's reduced-motion signal or the user's preference, renders Monitoring as a steady `● Monitoring`. The CLI form is always steady.
|
|
||||||
|
|
||||||
### 3.2 Precedence
|
|
||||||
|
|
||||||
Base-state precedence is:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Error > Interrupted > Paused > Stale > Waiting > Monitoring > Unknown
|
|
||||||
```
|
|
||||||
|
|
||||||
Additional rules:
|
|
||||||
|
|
||||||
- Paused outranks Stale. A drive paused for 49 days is amber Paused with `last sample 49 days ago` as a fact line, not a stale alarm.
|
|
||||||
- Error outranks Interrupted; the external stop rides in the explanation line when both facts exist.
|
|
||||||
- Unknown is used only when the service query fails and no store-derived fact, such as an open monitoring period, freshness, or deliberate-pause row, places the state higher.
|
|
||||||
- Collecting is an overlay, not a base rung. While the oneshot is in flight it overrides every base state except a store fault. While overlaying Paused, Interrupted, or retry-after-failure, the explanation line names the underlying state: for example, `run in flight — paused` or `run in flight — retry`. It reverts within the 90 s collection timeout to whatever state the outcome earns.
|
|
||||||
|
|
||||||
### 3.3 Separate facts
|
|
||||||
|
|
||||||
Do not fold these facts into the status word:
|
|
||||||
|
|
||||||
- `Last sample: X ago` freshness;
|
|
||||||
- last outcome: ok, failed with exit code, or none;
|
|
||||||
- `Start at boot: on/off` boot enablement;
|
|
||||||
- collection activity, which is represented by the Collecting overlay.
|
|
||||||
|
|
||||||
Blink means exactly "monitoring enabled and data fresh". It never means that collection just succeeded; collection activity is explicit Collecting.
|
|
||||||
|
|
||||||
### 3.4 Timing and refresh
|
|
||||||
|
|
||||||
A lightweight 5 s `systemctl show` poll of the timer and service units drives the status strip. The full store refresh remains at the 5-minute cadence. Status transitions are re-evaluated every poll tick from cached newest-sample timestamp plus current clock, so freshness aging boundaries cross within roughly 5 s without a store read. A new sample requires the normal store refresh.
|
|
||||||
|
|
||||||
The existing constants remain unchanged: fresh is newest sample within 2 × cadence + `AccuracySec` + 60 s, stale is at 48 h, collection timeout is 90 s, and default cadence is 5 min.
|
|
||||||
|
|
||||||
### 3.5 Canonical transitions
|
|
||||||
|
|
||||||
The implementation must make these states directly observable:
|
|
||||||
|
|
||||||
- failed run, data 4 min old: `✖ Error` — `last run failed (exit 3) · last good sample 4 min ago`;
|
|
||||||
- next successful run: `● Monitoring` after the status poll/refresh clears the failure;
|
|
||||||
- failed run with retry in flight: `◐ Collecting` — `run in flight — retry`, then Error or Monitoring;
|
|
||||||
- suspend for 3 h, wake, timer fires: `○ Waiting` → `◐ Collecting` → `● Monitoring`;
|
|
||||||
- external stop with fresh data: `⊘ Interrupted` — `collection stopped outside Fenris`;
|
|
||||||
- external stop 3 days later: still `⊘ Interrupted`, with `last sample 3 days ago` visible;
|
|
||||||
- fresh install with zero samples: `○ Waiting` — `awaiting first sample`;
|
|
||||||
- one sample but no compatible pair: `○ Waiting` — `awaiting another sample`;
|
|
||||||
- unreadable observation store: `✖ Error` — `observation store unreadable — see journal`, with store-dependent views suppressed.
|
|
||||||
|
|
||||||
### 3.6 Warm-up and withheld estimates
|
|
||||||
|
|
||||||
Projection warm-up uses the UTC accounting rules in §5. The progress block during warm-up is:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Building evidence — N of 14 days observed · Q qualifying
|
|
||||||
First lifespan estimate after 12 qualifying days
|
|
||||||
```
|
|
||||||
|
|
||||||
`N` counts distinct represented UTC dates in the current controller segment, capped at 14 for the display. `Q` counts dates whose represented monitored span has at least 50% coverage. The gate for the first lifespan estimate is 14 represented UTC dates with at least 12 qualifying; Supported confidence still separately requires at least 14 qualifying dates plus every other confidence prerequisite.
|
|
||||||
|
|
||||||
Do not promise a countdown by hours. Days are the grain, and a provisional day can still fail qualification. Graph-data availability is independent: the graph can render from first usable evidence while the lifespan estimate remains withheld.
|
|
||||||
|
|
||||||
Withheld-estimate reason lines are explicit, never blank and never zero-filled:
|
|
||||||
|
|
||||||
- `No endurance baseline — set a rated TBW to see an estimate`;
|
|
||||||
- `Drive does not report write counters`;
|
|
||||||
- the warm-up progress block above;
|
|
||||||
- stale evidence: show the estimate frozen at the latest published usage-evidence endpoint with `estimate not updating — last sample X ago`;
|
|
||||||
- paused days are excluded from the day count, and the Paused status carries that fact.
|
|
||||||
|
|
||||||
Once warm-up clears, the existing lifespan line and Limited/Supported label from the frozen redesign specification render unchanged. This companion adds the progress block, reason lines, status precedence, and frozen-note behavior; it does not invent a new steady-state projection format.
|
|
||||||
|
|
||||||
### 3.7 CLI parity
|
|
||||||
|
|
||||||
`fenris status` adopts the same status vocabulary, precedence, glyphs, and reason lines, rendered statically. It shows Collecting only if a run is in flight at query time. Display preferences and TUI themes never affect CLI facts.
|
|
||||||
|
|
||||||
## 4. History evidence, local browsing, and publication
|
|
||||||
|
|
||||||
### 4.1 Ownership and publication
|
|
||||||
|
|
||||||
The collector owns sample → usage interval → hour observation/day aggregate derivation. It must publish a consistent validated result before reporting collection success. Readers must not see a new sample advertised as fully derived while dependent history is missing.
|
|
||||||
|
|
||||||
The TUI is read-only. Its next successful refresh sees whatever the collector has durably published, regardless of whether the TUI was running during collection. There is no hourly batch wait and no projection-confidence gate on usage history. Failure preserves prior valid history and remains explicit.
|
|
||||||
|
|
||||||
### 4.2 First visible data
|
|
||||||
|
|
||||||
One successful sample establishes counter, health, and freshness evidence, but not a usage delta; display `Awaiting another sample`. The first usable sample pair may display measured partial-hour usage labelled `so far` when attribution supports it. A usable pair has valid ordered timestamps and supported, nonnegative monotonic counters in the same controller segment; monitored totals additionally require an interval fully inside one monitoring period.
|
|
||||||
|
|
||||||
A cross-hour pair is a real usage interval total, not two invented hour values. Measured zero is visible `0 B`. Missing, unsupported, invalid, or absent evidence is never converted to zero.
|
|
||||||
|
|
||||||
### 4.3 Local display days
|
|
||||||
|
|
||||||
Storage timestamps and projection evidence days remain UTC. History browsing uses the user's current system timezone, visibly labelled. If the system timezone changes, the same retained evidence regroups into the new local display days. Use real calendar boundaries: DST days may be 23 or 25 hours, repeated local hours have distinct offsets, and fractional UTC offsets must work without synthetic splitting.
|
|
||||||
|
|
||||||
### 4.4 Retention and repair
|
|
||||||
|
|
||||||
Retain timestamped usage intervals indefinitely alongside hour observations and day aggregates. Full raw samples retain the 14-day policy, with a boundary-anchor exception: do not prune a raw sample that is still needed to durably derive an unfinished interval/hour/day representation.
|
|
||||||
|
|
||||||
Repair derives only what surviving raw evidence supports, transactionally and idempotently. It preserves original evidence and valid historical summaries. Incomplete reconstruction must not overwrite valid older history. Unsupported historical precision stays unavailable; it is not repaired by interpolation or waiting. A failed repair remains visible and retryable.
|
|
||||||
|
|
||||||
### 4.5 Attribution and gaps
|
|
||||||
|
|
||||||
Preserve measured usage interval totals. Never divide them proportionally across hours or calendar days, never assign them to an endpoint as if timing were observed, and never double-count interval totals and summaries derived from the same evidence.
|
|
||||||
|
|
||||||
An interval entirely inside a local display day and one monitoring period may contribute its total to that local-day total even if individual hour shares are unknown. Otherwise the total is shown separately as unallocated usage. An incomplete allocated subtotal must not be presented as a complete total.
|
|
||||||
|
|
||||||
Missing samples reduce usage-habit coverage where classification is unknown, but they do not erase a compatible measured gap total. Byte-allocation completeness and usage-habit classification coverage are distinct. Do not infer zero writes from missing samples.
|
|
||||||
|
|
||||||
Partial summaries represent elapsed time only. Future time is neither zero nor unknown. Deliberately disabled time is excluded, not an hour state. Intervals crossing a deliberate pause cannot distinguish monitored from paused writes: preserve the original evidence, exclude ambiguous bytes from monitored totals, and explain why. External service stops remain unexplained in-period gaps, not Deliberate disables.
|
|
||||||
|
|
||||||
### 4.6 Controller segments in browsing
|
|
||||||
|
|
||||||
The default history view is the current controller segment. Older segments remain browsable with explicit reset/replacement boundaries. Never form a delta across a controller-segment boundary or silently combine different drives.
|
|
||||||
|
|
||||||
## 5. UTC projection accounting and confidence amendments
|
|
||||||
|
|
||||||
This section amends the projection contract without changing lifespan mathematics, numeric thresholds, or confidence categories.
|
|
||||||
|
|
||||||
### 5.1 Measured totals and requested spans
|
|
||||||
|
|
||||||
For any requested span, count a compatible interval's total exactly once when its complete span is inside the requested span, inside one monitoring period, and has eligible controller provenance. It may supply a complete window total even when individual UTC-hour/day shares are unknown.
|
|
||||||
|
|
||||||
A positive interval crossing a requested boundary cannot supply that window's unknown share. Preserve its measured total separately as unallocated usage. Do not split proportionally, assign to the ending day, silently omit possible bytes, or label an incomplete subtotal as complete.
|
|
||||||
|
|
||||||
Withhold a rate whenever its monitored numerator cannot be established: unresolved boundary shares, missing initial/resume/reset counter support, and legacy eligibility ambiguity are not zero. Do not shorten the requested window or remove unknown monitored seconds merely to obtain a number. A compatible monotonic interval with zero counter delta proves zero writes throughout its represented span, including a requested subspan; that is direct counter evidence, not interpolation.
|
|
||||||
|
|
||||||
### 5.2 Projection endpoints and denominator
|
|
||||||
|
|
||||||
Let `T` be the latest published usage-evidence endpoint. The 7/28/90-day scenario windows end at `T` and start exactly 7/28/90 × 86,400 seconds earlier. Do not round starts to UTC midnight, and do not dilute rates as the TUI read clock advances without new published usage evidence. Evidence age remains a separate fact.
|
|
||||||
|
|
||||||
The default sustained regime is eligible observation history capped at 90 days, ending at `T`. A detected habit-change regime starts at the first divergence day's UTC midnight. Any unresolved share at those boundaries invokes the unavailable-rate rule; there is no silent fallback to a more convenient start.
|
|
||||||
|
|
||||||
For the chosen span, the rate denominator is wall-clock seconds inside monitoring periods, including powered-off and unknown time, excluding deliberate-disable time. Numerator and denominator describe the same requested span. Pause-crossing intervals cannot establish which writes were monitored and therefore cannot supply affected monitored totals. No bridge crosses controller-segment boundaries.
|
|
||||||
|
|
||||||
### 5.3 UTC evidence dates, warm-up, and confidence
|
|
||||||
|
|
||||||
Projection evidence remains UTC; local graph regrouping never changes projection eligibility. Coverage uses known-classified seconds divided by represented elapsed monitored seconds, excluding deliberately disabled and future time.
|
|
||||||
|
|
||||||
A qualifying day is a distinct UTC date whose represented monitored span has coverage at least 50%. A current partial date counts provisionally and can lose qualification as unknown time accumulates. Count a date once, not once per hour, interval, or controller fragment. Entirely paused dates have no represented monitored span and do not count.
|
|
||||||
|
|
||||||
Warm-up clears when the current controller segment has at least 14 distinct represented UTC dates, at least 12 of which qualify. Supported confidence still requires at least 14 qualifying dates plus every other existing condition. Thus 12 qualifying plus 2 poor dates clears warm-up but remains Limited. Same-day segment breaks use only the current segment's eligible portion for its new-segment warm-up; a shared UTC date does not import old-segment qualification.
|
|
||||||
|
|
||||||
Habit-change comparisons require completed, consecutive UTC days with evaluable daily write totals. Do not compress missing calendar dates into adjacent-row windows or treat missing/disabled time as zero. Unknown daily totals cannot establish habit change and cannot pass the burst/concentration guard.
|
|
||||||
|
|
||||||
An affected scenario rate is withheld while other independently computable rates remain visible. If the headline regime lacks a complete monitored numerator, no lifespan number renders; show the specific evidence-unavailable reason. If a complete positive headline rate exists while daily shares remain unknown, the lifespan may render as Limited, but Supported is blocked wherever a required confidence check cannot be established.
|
|
||||||
|
|
||||||
### 5.4 Segment and legacy evidence
|
|
||||||
|
|
||||||
Never compute a delta across a reset or replacement boundary, even within one UTC hour/day. Same-identity reset preserves prior eligible habit evidence, subject to the existing current-segment re-warm gate before a lifespan number can render. Re-warming does not reconstruct missing counter support.
|
|
||||||
|
|
||||||
A controller-identity change, including to or from a blank key, quarantines previous identity from every projection horizon. Returning to a previously seen key does not undo the intervening quarantine. History browsing may still show older segments explicitly.
|
|
||||||
|
|
||||||
Trusted legacy day-only summaries remain usable at their actual represented precision: only in a window fully containing their represented span and only when monitoring-period/controller eligibility is known. They cannot supply subday detail, local-midnight splits, or partial rolling-window totals. Mixed legacy summaries that cannot separate eligible from ineligible writes remain browsable but cannot supply affected projection totals.
|
|
||||||
|
|
||||||
### 5.5 Projection acceptance examples
|
|
||||||
|
|
||||||
Future implementation must make these cases observable without using fabricated history:
|
|
||||||
|
|
||||||
1. 100 MB from 23:55 to 00:05 UTC in one period/segment: retain 100 MB once. Neither UTC-day share is known. A containing window can consume 100 MB; a window starting at midnight cannot claim an exact share or rate.
|
|
||||||
2. Sparse three-day recovery interval with compatible counters: a measured 900 MB total remains real and can supply a containing window. There is no 300 MB/day allocation.
|
|
||||||
3. A 7-day boundary cuts a positive interval and the 28-day boundary does not: withhold the 7-day rate, preserve the independently computable 28-day rate, and do not omit the unresolved old-enough horizon to manufacture Supported confidence.
|
|
||||||
4. The same boundary with zero measured delta: exact zero contribution is permitted for the represented subspan; missing time outside it remains unknown.
|
|
||||||
5. `T` at UTC noon: a 7-day window spans exactly 604,800 wall-clock seconds before subtracting deliberate-disable time. Refreshing the TUI without new evidence does not change the endpoint.
|
|
||||||
6. First sample arrives after monitoring starts: preceding monitored time lacks a write total. Keep its time, do not invent zero bytes, and withhold affected rates.
|
|
||||||
7. Pause/resume crossed by one counter interval: preserve total as original evidence, but do not count ambiguous paused writes as monitored.
|
|
||||||
8. 12 qualifying dates plus 2 poor dates: warm-up clears, Supported still fails the 14-qualifying-date requirement.
|
|
||||||
9. Good coverage with unknown daily shares: day progress can qualify; burst and habit checks remain unknown, not passed or zero-filled.
|
|
||||||
10. Only 14 days of eligible history: do not require 28/90-day horizons yet, but evaluate the burst guard over observed eligible history and keep unknown daily totals blocking.
|
|
||||||
11. Reset/replacement at 10:30 UTC: no cross-break delta or whole-date shortcut. Same-key reset preserves eligible prior habit evidence but requires new-segment re-warm; replacement excludes prior identity from scenarios too.
|
|
||||||
12. Trusted old UTC-day summary with raw samples gone: usable only in a window fully containing its represented span and known eligibility, once only; no partial local-day/hour split.
|
|
||||||
|
|
||||||
## 6. Usage history graph and interaction
|
|
||||||
|
|
||||||
### 6.1 Encoding
|
|
||||||
|
|
||||||
Adopt a writes-only daily bar graph with hourly drill-down. Reads are out of scope. Candles are rejected: they bury the daily total, import price-chart semantics that usage data does not have, and distort gap/partial evidence. A rolling hourly strip is rejected as the default because it lacks day totals without reintroducing the day level.
|
|
||||||
|
|
||||||
The graph's value is bytes written. The daily range view shows one bar per local display day. Activating a day drills into hourly bars for that selected local display day. Back returns to the range view.
|
|
||||||
|
|
||||||
### 6.2 Ranges, labels, and readout
|
|
||||||
|
|
||||||
Default range: 14 days. Selectable ranges: 7, 14, 28, and 90 days. Ranges limit the viewport only; they never delete retained history or hide older controller segments from browsing.
|
|
||||||
|
|
||||||
The header line is:
|
|
||||||
|
|
||||||
```text
|
|
||||||
usage history · Local · UTC±HH:MM · <tz name>
|
|
||||||
```
|
|
||||||
|
|
||||||
The day row uses day-of-month labels. The selected-day readout uses `Wed 09 Sep` style. Hourly detail labels every third hour `00…21` plus `midnight → 23:00 local`. DST and repeated local hours follow the Local display day contract.
|
|
||||||
|
|
||||||
The selected-item readout states totals, evidenced hours, unallocated usage separately, coverage, and partial-state text such as `partial · N h elapsed · so far`.
|
|
||||||
|
|
||||||
### 6.3 Controls
|
|
||||||
|
|
||||||
The graph pane is focusable. Keyboard controls:
|
|
||||||
|
|
||||||
- `←` / `→` select day or hour;
|
|
||||||
- `Enter` drills into the selected day;
|
|
||||||
- `Esc` / `Backspace` returns from hourly detail;
|
|
||||||
- `1` / `2` / `3` / `4` switch 7 / 14 / 28 / 90 days.
|
|
||||||
|
|
||||||
Mouse controls use widget-local coordinates to select bars. Where Textual mouse support is available, clickable equivalents must cover select, drill, and back. Global `p` / `r` / `c` / `d` / `q` remain unchanged, and the footer shows graph keys while the graph is focused.
|
|
||||||
|
|
||||||
### 6.4 State rendering
|
|
||||||
|
|
||||||
The legend is always visible when the graph is visible. Distinct glyphs:
|
|
||||||
|
|
||||||
| Glyph | Meaning |
|
|
||||||
|---|---|
|
|
||||||
| `█` | allocated measured writes |
|
|
||||||
| `▒` | unallocated measured writes, stacked separately |
|
|
||||||
| `░` | gap / no evidence, never zero |
|
|
||||||
| `·` | measured zero bytes |
|
|
||||||
| `┄` | partial-day cap |
|
|
||||||
| `▼` | selection marker |
|
|
||||||
|
|
||||||
Unallocated usage is measured, not fabricated; it is never spread into hours to make the graph look complete. Gaps remain distinct from measured zero. Deliberate-disable annotations remain visible.
|
|
||||||
|
|
||||||
### 6.5 Minimum terminal
|
|
||||||
|
|
||||||
At 80×24, the range view must fit the default 14-day graph using 4 columns per day, and hourly drill-down must fit 24 single-column hourly bars. Below 80×24, hide the graph region and show a one-line textual history summary plus exactly:
|
|
||||||
|
|
||||||
```text
|
|
||||||
graph needs ≥80×24
|
|
||||||
```
|
|
||||||
|
|
||||||
This is not an error. The titlebox, status label/reason, drive health, service facts, quit rail/action affordances, and selected-day readout or equivalent textual context must survive. Resizing must not leave stale graph state visible.
|
|
||||||
|
|
||||||
### 6.6 Framework obligation
|
|
||||||
|
|
||||||
Current Textual facts verified during charting: Textual 8.2.8 has no BarChart widget, Sparkline is non-interactive, and `textual-plotext` is not an installed dependency. Implement the graph as a custom block-glyph renderable in the usage-history pane, with click mapping from widget-local coordinates. Do not add a plotting dependency for the adopted graph.
|
|
||||||
|
|
||||||
## 7. Colour presets, persistence, and accessibility
|
|
||||||
|
|
||||||
### 7.1 Presets
|
|
||||||
|
|
||||||
Approved presets: Amber, Nord, and High Contrast. Amber is the default and keeps the graph amber by default. Other presets may use theme-appropriate graph/accent colours.
|
|
||||||
|
|
||||||
Themes style chrome, graph, borders, accents, and muted text. They do not override status semantics. The status lattice keeps semantic colours: green Monitoring/Collecting, amber Paused/Waiting, red Interrupted/Error/Stale, grey Unknown, always with glyph and text.
|
|
||||||
|
|
||||||
Preset palettes must maintain readable contrast in normal and focused states. High Contrast is a first-class preset, not merely a lightened Amber.
|
|
||||||
|
|
||||||
### 7.2 Persistence scope
|
|
||||||
|
|
||||||
Preset and reduced-motion choices are user-scoped TUI display preferences. Persist them at:
|
|
||||||
|
|
||||||
```text
|
|
||||||
${XDG_CONFIG_HOME:-~/.config}/fenris/tui.json
|
|
||||||
```
|
|
||||||
|
|
||||||
Do not store these preferences in `/etc/fenris/fenris.conf`, the observation store, helper state, package config, or collector/device configuration. Missing preferences default to Amber and normal motion. This preference file never affects collection, projection, history evidence, or CLI `status` facts.
|
|
||||||
|
|
||||||
### 7.3 Controls and reduced motion
|
|
||||||
|
|
||||||
Add accessible TUI controls for `t preset` and `m motion`, with clickable equivalents where Textual mouse support is available. Focusable graph/settings panes are acceptable as long as status text and global actions remain reachable.
|
|
||||||
|
|
||||||
Normal motion: only the Monitoring status dot blinks at the approved 750 ms on / 750 ms off cadence. Text never blinks, and no other state animates. Reduced motion: Monitoring renders steady as `● Monitoring`. No animation may imply successful collection.
|
|
||||||
|
|
||||||
Long reasons, paused states, degraded/error states, missing data, and warm-up/withheld-estimate lines must remain text-visible. Do not collapse them into colour, blank space, or a misleading zero.
|
|
||||||
|
|
||||||
### 7.4 Framework facts
|
|
||||||
|
|
||||||
The charting prototype verified current Textual documentation: `App.register_theme(theme)` and `App.theme` support theme registration/activation; Textual theme variables plus `$text` / `color: auto` support legibility; mouse events provide screen/widget-relative coordinates and focusable widgets can be resolved/clicked for keyboard+mouse proof paths. The existing lockfile still pins Textual 8.2.8; prototype branches remain throwaway assets and add no runtime dependency.
|
|
||||||
|
|
||||||
## 8. Future implementation proof paths
|
|
||||||
|
|
||||||
These are direct observable probes the execution effort can derive tests from; they are not a build-only checklist.
|
|
||||||
|
|
||||||
- Synthetic first-run store with zero samples: TUI and `fenris status` show Waiting/awaiting-first-sample; graph has no zero-filled bars; estimate is withheld with the correct reason.
|
|
||||||
- One sample followed by a compatible pair inside one hour: first state says `Awaiting another sample`; after the pair, graph shows a partial `so far` value. If the counter is unchanged, the evidenced interval is visible `0 B`.
|
|
||||||
- Cross-hour and cross-local-midnight intervals: totals are retained once; hour/day shares remain unallocated unless evidence supports them; gaps and zeros use distinct glyphs.
|
|
||||||
- Pause/resume-crossing interval: paused time is excluded, ambiguous bytes do not enter monitored totals, Paused status explains the consequence, and quitting the TUI does not pause monitoring.
|
|
||||||
- Failed collection with fresh data, retry in flight, external stop, and store fault: status precedence matches §3 and store fault suppresses store-dependent views.
|
|
||||||
- UTC projection horizon cut by a positive interval: affected rate is unavailable with a specific reason while independent horizons remain; refreshing without new evidence does not shift `T`.
|
|
||||||
- Warm-up fixtures for 12 qualifying + 2 poor dates and 14 qualifying dates: the first clears the progress gate but remains Limited; the second can be Supported only if all other prerequisites pass.
|
|
||||||
- Graph keyboard and mouse path: range switch → selected day → hourly detail → back, with focus/footer behavior and no conflict with global actions.
|
|
||||||
- 80×24 and narrower terminal captures: 80×24 keeps the graph; below 80×24 shows the exact fallback and preserves status/health/action context.
|
|
||||||
- Theme fixture: Amber default, switch to Nord/High Contrast, restart TUI under the same unprivileged user and see preference persist; `fenris status` and collection behavior are unchanged.
|
|
||||||
|
|
||||||
## 9. Out of scope
|
|
||||||
|
|
||||||
- Production implementation, release gates, package publishing, installation changes, and closing or reopening prior implementation umbrellas.
|
|
||||||
- Changing lifespan mathematics, evidence/confidence thresholds, collector cadence, or controller-segment semantics except for the explicit accounting/evaluability amendments in §5.
|
|
||||||
- Fabricating, interpolating, proportionally splitting, or endpoint-assigning missing history.
|
|
||||||
- Read-throughput graph selector, custom colour editor, web GUI, notifications, alerting, unrelated release-workflow changes, or a general application redesign beyond the named TUI requirements.
|
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# Glint-inspired Fenris dashboard
|
||||||
|
|
||||||
|
Status: accepted on 2026-09-19. The user confirmed the complete design and
|
||||||
|
additionally requested Glint-style plotted graphs in place of block bars.
|
||||||
|
|
||||||
|
## Purpose and reference
|
||||||
|
|
||||||
|
Adopt the visual approach of [Glint](https://github.com/ntrospect0/glint) for
|
||||||
|
Fenris's terminal dashboard. The selected visual reference is the third README
|
||||||
|
screenshot, using Chalktone, at upstream commit
|
||||||
|
`c1d73d3e8ead2f4069630b2a237306af8f6e69c8`:
|
||||||
|
[reference screenshot](https://github.com/ntrospect0/glint/blob/c1d73d3e8ead2f4069630b2a237306af8f6e69c8/docs/screenshots/glint-demo3.png).
|
||||||
|
|
||||||
|
The source and architectural assessment is recorded in
|
||||||
|
[the research note](../research/glint-dashboard-adoption.md).
|
||||||
|
|
||||||
|
## Confirmed user choices
|
||||||
|
|
||||||
|
- Adopt panel styling, keyboard focus, and panel zoom. A freely configurable
|
||||||
|
dashboard builder is outside the selected scope.
|
||||||
|
- Give the live read/write activity chart the largest opening-screen area,
|
||||||
|
with endurance outlook and monitoring state visible alongside it.
|
||||||
|
- Use the muted Chalktone appearance from Glint's third screenshot as the
|
||||||
|
visual direction.
|
||||||
|
- Put Live / Day / History views inside one activity panel, keeping both
|
||||||
|
read/write totals and the selected date visible.
|
||||||
|
- Expand a focused panel within the dashboard while retaining a fixed strip
|
||||||
|
for monitoring state, observation freshness, and essential controls.
|
||||||
|
- Replace block bars with thin dotted time-series plots resembling Glint's
|
||||||
|
chart. Label volume and time axes, retain exact selected-point readouts,
|
||||||
|
and leave explicit breaks for missing or incompatible evidence. Joining
|
||||||
|
adjacent measured points is visual guidance, not additional observations.
|
||||||
|
|
||||||
|
## Implementation recommendation
|
||||||
|
|
||||||
|
Implement the selected visual and interaction patterns independently in
|
||||||
|
Fenris's existing Python/Textual presentation layer. Retain the collector,
|
||||||
|
observation store, shared status/projection contracts, and authenticated control
|
||||||
|
path. Glint is a Rust/Ratatui application under GPL-3.0-or-later; copying or
|
||||||
|
porting its implementation into Fenris would require a separate licensing
|
||||||
|
decision under [ADR 0009](../adr/0009-mit-license.md). Referencing general panel
|
||||||
|
and navigation patterns does not require adopting its application architecture.
|
||||||
|
|
||||||
|
Use thin borders, compact titles, an explicit focused-panel indicator, and
|
||||||
|
restrained cream/earth-tone accents. Preserve readable contrast and semantic
|
||||||
|
state labels; the reference's dim secondary text is not a readability target.
|
||||||
|
Use Fenris content and identity, without Glint's unrelated clock, weather,
|
||||||
|
finance, email, or gallery features.
|
||||||
|
|
||||||
|
## Existing behavior to preserve
|
||||||
|
|
||||||
|
- Activity is measured read/write volume, with both totals accessible and
|
||||||
|
writes selected initially. A visual stock-chart reference must not turn
|
||||||
|
interval volume into speed or imply continuity through unknown evidence.
|
||||||
|
- Preserve the latest-three-hour opening view, date selection, local-day and
|
||||||
|
timezone labels, available history precision, and historical selection across
|
||||||
|
refresh under [live drive activity](live-drive-activity.md).
|
||||||
|
- Preserve the usage-adjusted theoretical lifespan and categorical projection
|
||||||
|
confidence with contributing facts. Navigation and graph selection cannot
|
||||||
|
change the projection evidence window or endurance accounting.
|
||||||
|
- Preserve explicit zero, gap, incomplete, unallocated, unavailable, paused,
|
||||||
|
stale, collection-failure, and store-fault states.
|
||||||
|
- Keep boot enablement, runtime activity, collection outcome, and observation
|
||||||
|
freshness distinct. Quit leaves background monitoring running; pause remains
|
||||||
|
a deliberate disable through the existing control path.
|
||||||
|
- Preserve keyboard and mouse access, constrained-terminal text/reflow,
|
||||||
|
high-contrast availability, reduced motion, help, and disclosures.
|
||||||
|
|
||||||
|
## Concrete layout and interaction proposal
|
||||||
|
|
||||||
|
- A compact identity header above one dashboard workspace.
|
||||||
|
- A narrow supporting column for the endurance outlook and drive facts;
|
||||||
|
a wide activity panel receives the remaining workspace. On the normal
|
||||||
|
dashboard, confidence and monitoring state remain visible beside activity.
|
||||||
|
- Live starts with the latest three hours; Day exposes the selected local
|
||||||
|
day's available detail; History exposes the retained longer-term evidence.
|
||||||
|
Reuse existing measurements and ranges rather than introducing a new data
|
||||||
|
model. Show selected date/timezone, read/write totals, measurement, and
|
||||||
|
evidence state wherever applicable.
|
||||||
|
- Use visible, clickable tabs; Tab/Shift+Tab and mouse clicks move focus.
|
||||||
|
Use `z` to toggle focused-panel zoom and Escape to restore the dashboard
|
||||||
|
when an input/dialog is not consuming Escape. Preserve selection, date,
|
||||||
|
range, measurement, and focus across zoom and refresh.
|
||||||
|
- Keep the bottom status/control area outside the expanding workspace. It
|
||||||
|
may wrap when required: compactness cannot merge the four service facts or
|
||||||
|
hide a fault/pause state. Keep quit distinct from pause in wording and
|
||||||
|
behavior without spending three full rows on a separate heavy quit box.
|
||||||
|
- Use existing date-navigation and read/write shortcuts, with visible help
|
||||||
|
updated for tabs, focus, and zoom. Text entry must consume its own keys.
|
||||||
|
- Use Chalktone-inspired styling as the new default while retaining existing
|
||||||
|
selectable themes, High Contrast, and reduced motion. Preserve an explicitly
|
||||||
|
saved theme preference during upgrade.
|
||||||
|
- Design for 80×24 and larger, with existing text/reflow behavior below that
|
||||||
|
size. Essential facts and actions must remain accessible; the large-screen
|
||||||
|
reference does not require squeezing its entire density into small terminals.
|
||||||
|
|
||||||
|
These defaults implement the confirmed choices. Date entry and inspection
|
||||||
|
retain existing evidence precision: hourly/daily UTC evidence is labelled UTC,
|
||||||
|
while local-day totals keep their recorded timezone. This visual redesign does
|
||||||
|
not manufacture finer or local-hour precision from coarse UTC evidence.
|
||||||
|
|
||||||
|
## Smallest sufficient implementation proof
|
||||||
|
|
||||||
|
Render normal and zoomed views at a representative large terminal and 80×24,
|
||||||
|
plus a constrained terminal. Check focus/tab/zoom/date/read-write interactions
|
||||||
|
and selection persistence through refresh. Exercise paused, stale, store-fault,
|
||||||
|
missing-baseline, and incomplete-evidence displays using existing synthetic
|
||||||
|
stores and headless TUI patterns. Verify quit never invokes monitoring control
|
||||||
|
and existing action tests still cover the sanctioned helper. UI fixtures are
|
||||||
|
not evidence of the user's deployed drive state.
|
||||||
|
|
||||||
|
## Specification reconciliation
|
||||||
|
|
||||||
|
This accepted redesign supersedes conflicting presentation requirements in
|
||||||
|
[the original Panes specification](fenris-redesign.md#7-panes-tui) and
|
||||||
|
[dashboard clarity](dashboard-clarity.md): the full-width headline becomes a
|
||||||
|
supporting endurance panel, the history/live plots share a tabbed activity
|
||||||
|
panel, and a compact fixed control row replaces the heavy standalone quit
|
||||||
|
rail. The block-bar requirement is superseded by dotted volume plots. Their
|
||||||
|
behavioral requirements, including clear quit-versus-pause semantics, remain.
|
||||||
|
|
||||||
|
The live-drive-activity specification excludes unrelated redesign from that
|
||||||
|
earlier task. This is a separate design request, not permission to undo its
|
||||||
|
accepted data, date-navigation, evidence, or forecast behavior. Existing design
|
||||||
|
documents and source may differ in implementation status; this assessment is
|
||||||
|
not proof that every earlier acceptance criterion has shipped.
|
||||||
|
|
||||||
|
## Documentation scope
|
||||||
|
|
||||||
|
No new domain term has been resolved: panel, focus, zoom, and theme are general
|
||||||
|
interface concepts and do not belong in the domain glossary. No new ADR is
|
||||||
|
needed for a reversible presentation change that retains the existing stack,
|
||||||
|
license, data model, and privilege boundary. Record any later durable
|
||||||
|
architectural trade-off separately if one emerges.
|
||||||
|
|
||||||
|
## Implementation validation
|
||||||
|
|
||||||
|
Implemented in Fenris's existing presentation layer with Textual 8.2.8.
|
||||||
|
The full suite passed 801 tests; 43 packaging/signing checks were skipped for
|
||||||
|
missing package artifacts or signing tools. After the final incomplete-evidence
|
||||||
|
fixes, all 81 focused dashboard, history, and plot tests passed. New renderer
|
||||||
|
and regression files pass configured Ruff checks; affected production files
|
||||||
|
pass correctness lint and the diff passes whitespace checks. Existing broader
|
||||||
|
lint warnings were not part of this redesign.
|
||||||
|
|
||||||
|
Rendered normal and zoomed dashboards at 140×44, plus normal 80×24 and
|
||||||
|
constrained 70×20 views. The [saved preview](../../assets/dashboard-chalktone.png)
|
||||||
|
uses synthetic observations. No installation or release was performed.
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
Fenris users cannot readily understand how much data their monitored drive reads and writes each day, inspect recent activity as it arrives, or select historical dates from the keyboard. Existing graph shortcuts depend on focus, date navigation is limited, collection and screen refresh default to five minutes, and the graph reads hourly/daily summaries rather than three-minute activity. Source inspection also identified derivation and rendering paths that can leave displayed history incomplete or stale despite newly stored readings.
|
||||||
|
|
||||||
|
Users want daily data volume as the primary measurement because writes contribute to drive endurance. They also want an understandable end-of-life outlook even with limited history, without presenting an unsupported hardware-failure prediction or a misleading numerical confidence score.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
Show both local-day read and write totals, updating from background collection targeted every three minutes. Open on a live last-three-hours graph of written data volume, provide a read/write toggle, and support selected-day and longer-history views. Make date navigation discoverable through visible keyboard hints and clickable equivalents.
|
||||||
|
|
||||||
|
Retain three-minute detail for 14 days, followed by durable hourly/daily summaries. Preserve the timezone and boundaries of historical local-day summaries, identify incomplete evidence, and never invent missing activity or divide midnight-spanning measurements by assumption.
|
||||||
|
|
||||||
|
Show the usage-adjusted theoretical lifespan after one full local calendar day of observations, provided an applicable endurance baseline and usable write rate exist. Explain that this estimates remaining write endurance if observed habits continue, not a physical failure date. Present categorical projection confidence with contributing facts, including limited-history reasons.
|
||||||
|
|
||||||
|
## User Stories
|
||||||
|
|
||||||
|
1. As a drive owner, I want to see the amount of data written each local day, so that I can understand the activity that contributes to write endurance.
|
||||||
|
2. As a drive owner, I want to see the amount of data read each local day, so that I can understand the drive's broader activity.
|
||||||
|
3. As a drive owner, I want read and write totals shown separately, so that reads are not mistaken for writes consuming the endurance allowance.
|
||||||
|
4. As a TUI user, I want data-volume units rather than transfer speed as the primary graph measurement, so that the graph answers how much data was transferred.
|
||||||
|
5. As a TUI user, I want today's totals labelled as totals so far, so that a partial day is not presented as a completed day's usage.
|
||||||
|
6. As a TUI user, I want the opening graph to show written volume over the last three hours, so that recent activity is visible immediately.
|
||||||
|
7. As a TUI user, I want to switch the graph between writes and reads while both daily totals remain visible, so that I can inspect either measurement without losing context.
|
||||||
|
8. As a TUI user, I want each new activity point to include transfers between readings, so that brief bursts between collection runs contribute to the graph.
|
||||||
|
9. As a TUI user, I want new measurements approximately every three minutes, so that the recent-activity view stays useful while I work.
|
||||||
|
10. As a user who closes the TUI, I want collection to continue in the background, so that reopening it shows activity gathered while I was away.
|
||||||
|
11. As a user who reboots, I want observation history and the established monitoring lifecycle preserved, so that restarting does not erase activity or confuse monitoring state.
|
||||||
|
12. As a keyboard user, I want `[` and `]` to select the previous and next day, so that browsing nearby dates takes one action.
|
||||||
|
13. As a keyboard user, I want `g` to open a date field, so that I can jump directly to a historical date.
|
||||||
|
14. As a keyboard user, I want `t` to return to today and the live view, so that I can leave historical browsing immediately.
|
||||||
|
15. As a keyboard user, I want arrows to inspect graph points, so that I can read the volume, time, and evidence state behind a point.
|
||||||
|
16. As a new TUI user, I want visible shortcut hints that work from normal launch, so that I do not have to discover an invisible graph-focus prerequisite.
|
||||||
|
17. As a mouse user, I want clickable equivalents for navigation and the read/write toggle, so that I can use the same features without memorizing shortcuts.
|
||||||
|
18. As a user entering a date, I want typing and cancelling to remain within the date-entry workflow, so that graph or monitoring shortcuts do not fire accidentally.
|
||||||
|
19. As a user browsing history, I want background refresh to preserve my selected date and measurement, so that new activity does not interrupt inspection.
|
||||||
|
20. As a user inspecting daily totals, I want to switch between recent detail, a selected day, and longer history, so that I can understand both short bursts and daily habits.
|
||||||
|
21. As a user outside UTC, I want calendar navigation and timestamps expressed in labelled local time, so that the displayed day matches my calendar.
|
||||||
|
22. As a user in a timezone with a half-hour offset, I want local-day totals based on the correct midnight boundary, so that relabelled UTC totals do not misrepresent my day.
|
||||||
|
23. As a user experiencing a daylight-saving transition, I want real local-day boundaries respected, so that a 23-hour or 25-hour day remains understandable.
|
||||||
|
24. As a user who changes system timezone, I want historical summaries to retain their recorded timezone and boundaries, so that old totals do not silently change meaning.
|
||||||
|
25. As a user reviewing recent history, I want three-minute detail available for 14 days, so that I can investigate recent usage.
|
||||||
|
26. As a user reviewing older history, I want durable hourly/daily summaries after detailed readings expire, so that long-term activity remains available.
|
||||||
|
27. As a user with legacy history, I want dates that cannot be reconstructed at local-day precision labelled incomplete or unavailable, so that old summaries are not presented with invented precision.
|
||||||
|
28. As a user whose readings straddle midnight, I want the measured volume preserved once and its uncertain day allocation explained, so that it is neither lost nor counted twice.
|
||||||
|
29. As a new user with only one reading, I want an awaiting-another-reading state, so that a missing interval is not shown as zero activity.
|
||||||
|
30. As a user with a measured zero-activity interval, I want zero distinguished from missing evidence, so that a quiet drive is not confused with a collection failure.
|
||||||
|
31. As a user with missed collection runs, I want gaps, actual timestamps, and freshness facts, so that the graph does not imply measurements that never occurred.
|
||||||
|
32. As a user who deliberately pauses monitoring, I want paused time excluded according to monitoring-period rules, so that the pause does not distort the observed usage habit.
|
||||||
|
33. As a user whose controller resets or changes, I want counter and identity boundaries respected, so that unrelated readings do not create invalid activity or lifespan estimates.
|
||||||
|
34. As a user with a store fault, I want a clear explanation instead of guessed history, so that I know which information cannot be trusted.
|
||||||
|
35. As a drive owner, I want an estimate of the time until remaining write endurance is consumed, so that I can plan around my observed usage habit.
|
||||||
|
36. As a new user, I want that estimate withheld until one full local calendar day has been observed, so that a few minutes of activity do not immediately produce a lifespan number.
|
||||||
|
37. As a user who starts monitoring at noon, I want the partial first day excluded from the full-day gate, so that 24 hours since launch is not mistaken for a complete calendar day.
|
||||||
|
38. As a user with one complete day but limited history, I want an estimate when the other inputs support it, so that I do not have to wait for high confidence before seeing an outlook.
|
||||||
|
39. As a user with limited evidence, I want a confidence category and concrete reasons, so that I understand why an estimate may change without being shown an unjustified percentage.
|
||||||
|
40. As a user without an applicable endurance baseline or usable write rate, I want the missing prerequisite explained, so that unavailable evidence is not disguised as merely low confidence.
|
||||||
|
41. As a drive owner, I want the endurance estimate distinguished from a physical failure date, so that I do not treat a write-endurance projection as a hardware guarantee.
|
||||||
|
42. As a CLI user, I want status and the TUI to agree on forecast eligibility and confidence, so that choosing a different interface does not change the facts.
|
||||||
|
43. As a user on a narrow terminal, I want daily totals, dates, evidence labels, and controls to remain accessible, so that terminal size does not hide the information I need.
|
||||||
|
44. As a user of existing themes and reduced-motion settings, I want those features preserved when navigation shortcuts change, so that better date controls do not remove accessibility or preferences.
|
||||||
|
45. As a user upgrading Fenris, I want migration, repair, refresh, and pruning to preserve measured evidence without duplication, so that the new graph can be trusted across restarts and upgrades.
|
||||||
|
|
||||||
|
## Implementation Decisions
|
||||||
|
|
||||||
|
- Extend the existing collector, observation store, derivation, shared status/projection, service scheduling, and TUI responsibilities. The privileged collector remains the single device-acquisition and store-write owner; the TUI remains an unprivileged reader. Do not add another sampler, service, export layer, or generic plotting abstraction.
|
||||||
|
- Set the default background collection target to three minutes on both systemd and runit. Update shared cadence/freshness semantics, scheduler definitions, user documentation, and relevant acceptance contracts together. Use actual observation timestamps: delayed runs must not be represented as perfectly spaced measurements or synthetic catch-up points.
|
||||||
|
- Derive interval read/write volumes from compatible cumulative counters. A first reading is an anchor, not an interval. Preserve controller-segment and monitoring-period boundaries, measured zero, unknown time, deliberate disables, and unsupported evidence. Do not compute deltas across a controller identity or counter discontinuity.
|
||||||
|
- Make successful collection publish consistent data for dependent read views, using the existing transactional and migration patterns. Ensure raw samples, affected summaries, and retained boundary evidence cannot disagree through partial publication. Validate source-level derivation gaps before implementing fixes; this spec does not claim a runtime diagnosis.
|
||||||
|
- Keep daily read and write totals separate. The write graph is the default; a read/write toggle changes only the displayed measurement. Each live point represents measured interval volume, not instantaneous speed. Daily totals remain visible independently of graph mode, and graph selection must not redefine the projection's evidence window.
|
||||||
|
- Open on the latest three hours. Offer selected-day and longer-history views at the precision supported by retained evidence. Selected-point readouts expose time, labelled timezone, data-volume units, and incomplete/gap state. Do not imply that a partial current day or an incompletely allocated day is a known full-day total.
|
||||||
|
- Use `[` / `]` for previous/next day, `g` for date entry, `t` for today/live, and arrows for point inspection, with visible hints and clickable equivalents. Date entry must handle invalid or unavailable dates visibly and must not leak keystrokes into unrelated actions. Cancelling returns to the prior selection; refresh preserves historical selection until the user changes it.
|
||||||
|
- Resolve the existing `t` theme binding in favor of the accepted today/live action. Preserve theme selection through a discoverable non-conflicting control and update help consistently. Preserve pause, resume, collect-now, disclosures, quit, and reduced-motion behavior; quitting still does not pause monitoring. No exact replacement theme shortcut was selected in the interview.
|
||||||
|
- Preserve UTC timestamps and the established UTC hour/day evidence used by endurance calculations. Local-day activity is a separate presentation aggregate using the actual local midnight boundaries, including non-whole-hour offsets and daylight-saving transitions. Timestamp relabelling alone is insufficient.
|
||||||
|
- Extend the existing versioned observation store to retain local-day read/write summaries with their controller identity/segment context, recorded timezone, local date, UTC boundaries, evidence state, and the information needed to explain unallocated boundary volume. These are required semantics, not prescribed table names or a finalized column layout. The collector derives durable summaries before source evidence is eligible for pruning.
|
||||||
|
- Retain three-minute detail for 14 days and hourly/daily summaries indefinitely. Keep the boundary evidence and anchors necessary for honest derivation. Do not introduce unbounded retention of all fine-grained intervals solely to support arbitrary historical timezone reinterpretation, and do not delete existing durable evidence merely to simplify migration.
|
||||||
|
- Preserve the timezone and boundaries recorded for historical summaries when the system timezone changes. New observations follow the current local timezone without rewriting old days; labels must make historical timezone context explicit. Do not blend differently bounded summaries into an apparently exact daily total.
|
||||||
|
- Allocate a measured interval to a local day only when evidence supports that allocation. Preserve an ambiguous midnight-spanning delta once as shared/unallocated boundary evidence; neither prorate it nor copy its full value into both days. If only coarse legacy UTC summaries survive, show them at their actual precision and mark unreconstructable local-day totals incomplete/unavailable. Repeated derivation and repair must be idempotent.
|
||||||
|
- Use the existing usage-adjusted theoretical lifespan model and categorical projection confidence. Forecast remaining write endurance from an applicable endurance baseline and a usable observed write rate. Lifetime-written counters consume the endurance allowance; observation-window deltas determine the usage rate. Read volume is not included in endurance consumption.
|
||||||
|
- Add the agreed complete-observation-day gate to the shared projection contract. A completed local midnight-to-midnight day within a monitoring period with usable evidence is required; a partial first day, deliberately disabled span, or day with no usable evidence cannot independently qualify. Monday-noon setup can first qualify at Wednesday 00:00 after observing Tuesday. Real daylight-saving day boundaries apply; do not replace this with a fixed 24-hour timer.
|
||||||
|
- Before that gate, explain that Fenris is waiting for a full local observation day. Afterward, allow an estimate with Limited confidence when the existing baseline, rate, identity, and evidence-validity rules permit it; preserve the longer warm-up and Supported-confidence requirements. Missing evidence is still evaluated under the existing validity/coverage rules: completing a date does not turn unknown measurements into known data.
|
||||||
|
- Show confidence as a category plus contributing facts, never a numeric confidence percentage. Explain missing baseline, unusable/zero rate, stale evidence, and controller changes through their applicable states. Do not fabricate a baseline, suppress a missing prerequisite behind a generic confidence warning, or label the output as a predicted hardware-failure date. Preserve the existing baseline setup path and zero-rate explanation.
|
||||||
|
- Preserve visible freshness, last-collection outcome, paused state, and store-fault behavior. On constrained terminals retain the existing textual fallback/reflow approach so both daily totals, the selected date, evidence state, confidence, and navigation remain accessible; colour alone must not carry meaning.
|
||||||
|
- Migrate through the existing ordered, versioned, transactional store mechanism. Preserve observation history, refuse unknown newer schemas, establish new durable evidence before pruning, and keep readers consistent while the collector writes. Migration cannot manufacture local precision from historical data that lacks it.
|
||||||
|
- This design amends ADR 0003's five-minute default on both native service backends under ADR 0008, adds a first-full-local-day eligibility gate to ADR 0002 while retaining its forecasting and confidence model, and extends ADR 0001 with the local-history decision recorded as ADR 0010. ADR 0005's no-fabrication/store-fault contract remains authoritative. Existing historical design documents are not evidence that the current runtime already implements these amendments.
|
||||||
|
|
||||||
|
## Testing Decisions
|
||||||
|
|
||||||
|
- Prefer one primary acceptance path: controlled acquisition fixtures and an injected clock enter the existing public collection operation; it writes a real temporary observation store; the normal read path supplies the TUI and CLI. Assert resulting displayed volumes, evidence states, selections, forecast eligibility, and public outcomes. Do not mock the internal derivation or query results under test. A good test would continue passing after an internal refactor and fail when a user's observed result becomes incorrect.
|
||||||
|
- Reuse existing collector tracer and collector-history tracer fixtures, including SMART JSON, a temporary sysfs tree, the injected clock, and a real SQLite store. Compose these with the existing status reader, projection fixtures, and headless TUI driver rather than creating a new production testing interface. Fix defects that this path reproduces; do not make tests reproduce incorrect implementation arithmetic.
|
||||||
|
- Exercise normal launch, previous/next date, direct date entry, invalid input, cancel, today/live, arrow inspection, read/write switching, refresh during historical browsing, and hourly/day navigation through user actions. Assert visible graph/readout results, not only private selection indices or row counts. In particular, do not manually force graph focus to make the advertised shortcuts work. Existing headless graph, help, theme, motion, and constrained-terminal tests provide prior art.
|
||||||
|
- Drive known successive read/write counter changes through collection and verify exact interval volumes and aggregate totals. Cover first reading, first compatible pair, measured zero, repeated collection, repeated read/refresh, restart, same-hour accumulation, and a day-boundary crossing. Preserve each measured byte once: attributable totals plus separately retained unallocated evidence must account for the measured delta without duplication.
|
||||||
|
- Use the same public path for local midnight, Asia/Kolkata's half-hour boundary, a 23-hour local day, a 25-hour local day, repeated local clock labels, and a system-timezone change. Verify historical labels and boundaries remain stable and ambiguous intervals are not silently divided. Add direct derivation cases only when they prove an invariant that the primary path cannot isolate clearly.
|
||||||
|
- Advance controlled time beyond the 14-day detail window, invoke normal pruning, restart readers, and verify recent detail expiry with durable summary/boundary preservation. Use existing pruning, repair, legacy-migration, and store-migration tests for focused persistence cases that the main path cannot prove, including interruption, reruns, concurrent readers, unknown newer schema, and inability to reconstruct legacy local-day precision.
|
||||||
|
- Test the forecast gate at concrete local times: Monday noon setup; Tuesday noon with 24 elapsed hours but no complete observed calendar day; and Wednesday 00:00 after a usable Tuesday. Include partial/disabled/unknown days and daylight-saving dates. After eligibility, verify Limited confidence with reasons and a correct estimate where inputs support one; no baseline, invalid counters, zero rate, stale evidence, and segment changes must preserve the relevant unavailable/degraded behavior. Reuse existing projection and status-parity tests.
|
||||||
|
- Independently calculate an expected endurance result from known lifetime-written counters, a known applicable baseline, and known observed-window deltas. This guards against confusing lifetime consumption with writes inside the rate window. Rendering, browsing another day, and switching to reads must not change that accounting.
|
||||||
|
- Add focused checks at the existing native scheduler boundary for the three-minute defaults, serialization, failures, and freshness calculations. Reuse systemd/runit control and packaging tests; use only necessary controlled platform probes to verify actual scheduled/background behavior. No live-system scheduling, pause, install, or reboot action is authorized merely by writing this spec. Report any unavailable platform verification during implementation.
|
||||||
|
- Verify at 80×24 and below that visible totals, date entry, navigation, confidence, and evidence states remain accessible and that theme/reduced-motion controls still work. Avoid pixel-perfect snapshots and assertions about private widget layout when visible text and action results express the contract.
|
||||||
|
- The affected modules are collection/derivation, observation-store migration and retention, shared projection/status, native service scheduling, and the TUI. Most acceptance coverage should remain at the single collection-to-visible-result path; targeted persistence and scheduler checks are supporting boundaries, not a new layer of test-only architecture.
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- Adding SATA/HDD acquisition, another monitored device, or multi-drive management; the selected existing NVMe drive remains the source.
|
||||||
|
- Reporting occupied/free filesystem capacity, making transfer speed the primary measurement, or treating read bytes as write-endurance consumption.
|
||||||
|
- Forecasting today's final data volume or future daily activity; the selected forecast is the usage-adjusted theoretical lifespan.
|
||||||
|
- Predicting a physical hardware-failure date, adding numeric confidence percentages, fabricating endurance baselines, or introducing a new forecasting algorithm when the existing model fits.
|
||||||
|
- Interpolating missing samples, zero-filling unknown activity, prorating ambiguous midnight intervals, or retroactively rewriting historical timezone boundaries.
|
||||||
|
- Requiring all historical dates to retain three-minute detail forever or to support arbitrary later timezone regrouping.
|
||||||
|
- A new web UI, plotting service, generic graph framework, notifications, telemetry, theme redesign, or unrelated dashboard rework.
|
||||||
|
- Package publication, release creation, installation, host-monitoring changes, or runtime diagnosis of the user's deployed observation store as part of this specification task.
|
||||||
|
|
||||||
|
## Further Notes
|
||||||
|
|
||||||
|
- This spec synthesizes the completed design conversation. The user accepted daily volume, a live three-hour opening view, the specified keyboard date workflow, local dates, 14-day detail retention, a writes-first graph with read toggle and both totals, an endurance forecast, categorical confidence, one full local observation day before forecasting, and historically labelled timezone boundaries with honest incomplete evidence.
|
||||||
|
- Related prior issue: [Implement Fenris TUI polish and hourly history](https://git.bongbetic.com/xavierk/Fenris/issues/72). This newer accepted design supersedes its conflicting requirements for the opening graph, writes-only presentation, the `t` theme shortcut, unrestricted historical timezone regrouping, indefinite fine-grained interval retention, and the earlier projection-eligibility presentation. Preserve unrelated requirements for identity, status, themes, motion, accessibility, collector publication, evidence conservation, and CLI parity. Do not treat the older issue as a reason to undo these newly agreed choices.
|
||||||
|
- ADR 0010 records the deliberate trade-off: retain labelled local summaries and necessary boundary evidence before detailed readings expire, rather than retaining every fine-grained interval for arbitrary future timezone reinterpretation. The design adds durable local activity evidence while retaining UTC projection evidence and the existing collector/reader ownership boundary.
|
||||||
|
- Source inspection identified normal collection paths that do not consistently rebuild daily totals, existing-hour updates that omit read deltas, cross-day deltas added to both dates, and drill-down rendering overwritten by a loading placeholder. These are concrete investigation/regression targets, not claims of executed reproductions or deployed-store corruption.
|
||||||
|
- The user confirmed the existing collector-to-store-to-TUI/CLI test boundary, with focused migration/pruning and scheduler checks where needed. Publication is an implementation handoff; no application behavior or test result is claimed solely by creating this issue.
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Native Void Linux and XBPS distribution
|
||||||
|
|
||||||
|
Status: Approved and published as [Support native Void Linux and signed XBPS distribution through Gitea](https://git.bongbetic.com/xavierk/Fenris/issues/82).
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
Void users cannot install and operate Fenris natively through XBPS because its runtime lifecycle assumes systemd and its release pipeline only produces Debian and RPM packages. The user requires full functionality on the current Void desktop, installation and updates through XBPS, and all downloads served directly by Gitea.
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
Support Void x86_64 with glibc and runit alongside existing systemd distributions. Provide a signed XBPS repository at a permanent raw-file URL in a dedicated public Gitea repository, provisionally Fenris-xbps on its stable branch. Publish versioned assets and notes in the application's Gitea release. Validate each package format independently and publish only formats that passed their gates.
|
||||||
|
|
||||||
|
## User Stories
|
||||||
|
|
||||||
|
1. As a Void user, I want to install Fenris through XBPS so package ownership and dependencies are managed normally.
|
||||||
|
2. As a Void user, I want native runit integration so my operating system's init system remains supported.
|
||||||
|
3. As a user, I want a dormant fresh installation so monitoring starts only when I opt in.
|
||||||
|
4. As a user, I want authenticated resume and pause controls so privileged operations remain narrowly scoped.
|
||||||
|
5. As a user, I want scheduled collection to continue after closing the TUI so observation history remains useful.
|
||||||
|
6. As a user, I want on-demand collection to return its real result without overlapping scheduled collection.
|
||||||
|
7. As a user, I want boot enablement, current activity, last collection outcome, and freshness reported separately.
|
||||||
|
8. As a user, I want actionable native diagnostics when collection fails.
|
||||||
|
9. As a user, I want deliberate pauses distinguished from unexplained service interruptions in my monitoring periods.
|
||||||
|
10. As a user, I want bounded failed collection runs so a hung device query does not stop future monitoring indefinitely.
|
||||||
|
11. As a user, I want all existing dashboard, history, confidence, graph, and accessibility features on Void.
|
||||||
|
12. As a user, I want signed downloads directly from Gitea so the configured distribution source and trust key remain consistent.
|
||||||
|
13. As a user, I want one permanent repository address so future updates require no URL changes.
|
||||||
|
14. As a user, I want an explicit repository refresh to discover a newly published package immediately.
|
||||||
|
15. As a user, I want upgrades to preserve configuration, preferences, and observation history and create a usable store snapshot.
|
||||||
|
16. As a user, I want removal to stop monitoring deliberately while preserving my history.
|
||||||
|
17. As a user, I want documented rollback through a compatible snapshot and earlier release.
|
||||||
|
18. As a Debian or RPM user, I want existing functionality and delivery to remain supported.
|
||||||
|
19. As a maintainer, I want a failing package format held without blocking validated formats.
|
||||||
|
20. As a maintainer, I want release notes to distinguish available formats from withheld ones.
|
||||||
|
21. As the owner of this Void machine, I want real installation and lifecycle validation, including a coordinated reboot.
|
||||||
|
22. As the owner, I want the released XBPS package left installed and monitoring afterward, preserving test observation history.
|
||||||
|
|
||||||
|
## Implementation Decisions
|
||||||
|
|
||||||
|
- Amend the existing systemd-only service contract to support native runit while retaining its external semantics. Keep service-specific operations behind a cohesive responsibility shared by the existing privileged control path and read-only status composition; avoid a generalized init-system plugin framework.
|
||||||
|
- Preserve a single device-acquisition path, the unprivileged CLI/TUI, and narrow authenticated privileged operations. Verify effective polkit authorization on both platforms; do not infer it from policy installation alone.
|
||||||
|
- Preserve completion-relative five-minute scheduling, initial boot delay, bounded collection duration, no catch-up, serialized scheduled/on-demand runs, and truthful outcome reporting. Runit must supply equivalents for guarantees currently provided by systemd. Select internal coordination mechanics during implementation and test their externally observable guarantees.
|
||||||
|
- Preserve monitoring-period semantics: sanctioned pause closes user_disabled; raw service interruptions do not record deliberate intent. Preserve separate boot-enabled and runtime-active facts.
|
||||||
|
- Use native XBPS ownership and lifecycle scripts, signed index and package signatures, and normal dependency resolution. Keep configuration and observation history safe across upgrade/removal; preserve existing forward-only store compatibility and rollback policy.
|
||||||
|
- Host ordinary Git blobs without LFS in the dedicated Gitea repository. Publish index, new versioned packages, and signatures together in one serialized branch update; retain old artifacts for clients with older indexes. Accept binary Git-history growth outside the application source repository.
|
||||||
|
- Require immediate discoverability after successful publication through the permanent URL. Test clients that fetched the previous index first. Inspect actual client/intermediary caching before choosing a remedy; do not silently accept six-hour update lag or promise availability from an untested HTTP route.
|
||||||
|
- Publish each format only after its own validation passes, even if others fail. Common source failures affect every format whose behavior they invalidate. Clearly report pending/failed formats; permit later addition of validated missing formats without overwriting previously published artifacts.
|
||||||
|
- Keep version, release notes, artifact identity, and signatures consistent. Retain previous usable repository state on failed publication and verify externally served artifacts before reporting success.
|
||||||
|
- First Void target is x86_64/glibc. Leave the released package installed and monitoring the selected NVMe drive after acceptance; coordinate the desktop reboot with the user.
|
||||||
|
|
||||||
|
## Testing Decisions
|
||||||
|
|
||||||
|
- Primary runtime boundary: existing user commands and shared CLI/TUI observable state, backed by disposable observation stores and controlled service/acquisition outcomes. Test behavior, not a particular helper layout.
|
||||||
|
- Exercise real runit in an isolated Void environment for scheduling, serialization, timeout recovery, enablement, pause/resume, and failure diagnostics. Preserve meaningful systemd regression coverage.
|
||||||
|
- Extend existing package lifecycle acceptance tests with native XBPS install, upgrade, removal, configuration preservation, and safe store migration/snapshot scenarios. Use disposable stores for destructive removal/rollback cases.
|
||||||
|
- Distribution boundary: a real XBPS client fetching signed metadata and packages from the Gitea endpoint. Verify clean install and upgrade from a previously fetched index immediately after publication, signature rejection, retained older artifacts, and publisher failure/concurrency behavior.
|
||||||
|
- Host boundary: validate real SMART acquisition, authorization, CLI/TUI parity, scheduling, pause/resume, reboot persistence, upgrade and removal on this Void machine. Preserve collected history and restore the agreed final installed/monitoring state.
|
||||||
|
- Record actual outcomes, skips, and environment failures. Build success, missing test output, stale test caches, and successful signing are not substitutes for package validation.
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
Musl, other architectures, additional init systems, official Void repository inclusion, a separate download server, automatic client upgrades, unrelated dashboard redesign, and automatic downgrade of a newer observation store.
|
||||||
|
|
||||||
|
## Further Notes
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# Native Void implementation tickets
|
||||||
|
|
||||||
|
Status: Approved and published as Gitea issues 83–87 with native blocking edges and ready-for-agent labels. Parent specification: https://git.bongbetic.com/xavierk/Fenris/issues/82.
|
||||||
|
|
||||||
|
Each issue references the published native Void specification, which includes ADR 0008. Existing Debian/RPM behavior, observation-history preservation, narrowly scoped privilege, and independent publication gates apply throughout.
|
||||||
|
|
||||||
|
Published tickets: [1](https://git.bongbetic.com/xavierk/Fenris/issues/83), [2](https://git.bongbetic.com/xavierk/Fenris/issues/84), [3](https://git.bongbetic.com/xavierk/Fenris/issues/85), [4](https://git.bongbetic.com/xavierk/Fenris/issues/86), [5](https://git.bongbetic.com/xavierk/Fenris/issues/87).
|
||||||
|
|
||||||
|
## 1. Prove signed XBPS installation and immediate updates through Gitea
|
||||||
|
|
||||||
|
Blocked by: None.
|
||||||
|
|
||||||
|
Deliver a dedicated public Gitea distribution repository and a repeatable, isolated XBPS-client proof using clearly identified test artifacts, without representing them as a validated Fenris release.
|
||||||
|
|
||||||
|
- Verify permanent raw URL delivery of index, package and signature bytes without LFS indirection.
|
||||||
|
- Establish signing-key handling and trust verification without exposing private keys.
|
||||||
|
- Demonstrate install and update with a client that fetched the old index immediately before publication.
|
||||||
|
- Resolve actual cache behavior and verify binary size limits; escalate any hosting change outside the agreed Gitea scope.
|
||||||
|
- Publish index/artifacts together with serialized updates, preserve older downloadable artifacts, and demonstrate safe failure recovery.
|
||||||
|
- Record results and the usable publication mechanism for subsequent tickets.
|
||||||
|
|
||||||
|
## 2. Run and control Fenris monitoring natively under runit
|
||||||
|
|
||||||
|
Blocked by: None.
|
||||||
|
|
||||||
|
Deliver an end-to-end native monitoring path with existing CLI/TUI controls and truthful status in an isolated Void environment.
|
||||||
|
|
||||||
|
- Scheduled and on-demand collection use the same acquisition path with no overlap and bounded execution.
|
||||||
|
- Preserve cadence, initial boot delay, recovery after failures, and no catch-up semantics.
|
||||||
|
- Resume/pause preserve monitoring-period bookkeeping and boot/runtime distinctions; raw service stops do not record deliberate disable.
|
||||||
|
- Verify effective authentication and actionable native diagnostics, including missing-agent failures.
|
||||||
|
- Preserve systemd behavior and application feature parity through existing public behavior tests.
|
||||||
|
|
||||||
|
## 3. Install, upgrade and remove Fenris with native XBPS packages
|
||||||
|
|
||||||
|
Blocked by: 2.
|
||||||
|
|
||||||
|
Deliver buildable x86_64/glibc XBPS artifacts with dependency resolution and tested package lifecycle in an isolated Void environment.
|
||||||
|
|
||||||
|
- Fresh install remains dormant; native controls enable monitoring afterward.
|
||||||
|
- Package ownership, configuration preservation, permissions, and group access support real CLI/TUI reads and collector writes.
|
||||||
|
- Upgrade snapshots and migrates observation history safely without recording a deliberate pause.
|
||||||
|
- Removal performs sanctioned pause and retains history; reinstall and documented snapshot rollback behave correctly.
|
||||||
|
- Installation over incompatible unmanaged remnants fails with a useful migration path.
|
||||||
|
- Capture explicit lifecycle test results and verify Debian/RPM regressions relevant to changed packaging.
|
||||||
|
|
||||||
|
## 4. Publish validated package formats independently from the release workflow
|
||||||
|
|
||||||
|
Blocked by: 1, 3.
|
||||||
|
|
||||||
|
Deliver a release workflow that builds, validates, signs and publishes XBPS alongside existing Debian/RPM support with independent format gates.
|
||||||
|
|
||||||
|
- Unvalidated formats remain withheld while validated formats can ship.
|
||||||
|
- Notes accurately identify available and withheld formats; source version, notes, checksums and artifacts agree.
|
||||||
|
- A withheld format can be added after validation without replacing existing published artifacts.
|
||||||
|
- Gitea serves every download; XBPS uses the proven permanent repository URL and immediate-refresh behavior.
|
||||||
|
- Release failure/concurrency cannot expose an index referencing missing artifacts or erase the prior usable channel.
|
||||||
|
- Host acceptance remains a required XBPS release gate, not bypassed by build/signature success.
|
||||||
|
|
||||||
|
## 5. Validate and release on the user's Void machine
|
||||||
|
|
||||||
|
Blocked by: 4.
|
||||||
|
|
||||||
|
Deliver recorded host acceptance and the validated XBPS release, ending with Fenris installed and monitoring.
|
||||||
|
|
||||||
|
- Recheck host state, identify/configure the intended NVMe drive, and preserve pre-existing data before package lifecycle operations.
|
||||||
|
- Verify real acquisition, authenticated controls, scheduling, failure reporting, full dashboard behavior, and pause/resume semantics.
|
||||||
|
- Coordinate and verify reboot persistence; absence of the reboot test leaves that gate pending.
|
||||||
|
- Verify native upgrade/removal/reinstall with history preservation and immediate update discovery through Gitea.
|
||||||
|
- Publish only after the XBPS gate passes, then verify downloads and released-package installation.
|
||||||
|
- Preserve acceptance-test observation history and leave the released package monitoring; document installation, trust setup, diagnostics and rollback for Void users.
|
||||||
@@ -44,7 +44,8 @@
|
|||||||
- **RPM payload: signed.** rpmsign with the dedicated packaging key, invoked by `make sign-rpm` after the package is built. This is required, not optional: it is the only working dnf-native verification path.
|
- **RPM payload: signed.** rpmsign with the dedicated packaging key, invoked by `make sign-rpm` after the package is built. This is required, not optional: it is the only working dnf-native verification path.
|
||||||
- **deb: unsigned.** apt never verifies payload signatures; trust = instance-signed `InRelease` (signed-by keyring) + TLS + Acquire-By-Hash. Manual-download integrity is covered by SHA256SUMS.
|
- **deb: unsigned.** apt never verifies payload signatures; trust = instance-signed `InRelease` (signed-by keyring) + TLS + Acquire-By-Hash. Manual-download integrity is covered by SHA256SUMS.
|
||||||
- **SHA256SUMS: clearsigned** with the packaging key — the trust anchor for manually downloaded release assets, independent of TLS.
|
- **SHA256SUMS: clearsigned** with the packaging key — the trust anchor for manually downloaded release assets, independent of TLS.
|
||||||
- **Packaging key:** single dedicated key, RSA 3072, UID `Fenris Packaging <packaging@bongbetic.com>`, 2-year expiry, no master/subkey hierarchy (single maintainer, manual builds). Private key lives in the password manager only; each release does import → sign → delete — nothing permanent on any build host. The full ceremony is documented in `docs/install/signing-key-ceremony.md`.
|
- **Packaging key:** single dedicated key, RSA 3072, UID `Fenris Packaging <packaging@bongbetic.com>`, 2-year expiry, no master/subkey hierarchy. The private key is stored as the repository Actions secret `GPG_PRIVATE_KEY`. The release workflow imports it on the self-hosted runner, verifies it against the in-repo public key, signs the RPM and SHA256SUMS, then deletes the runner's keyring copy in an `always()` cleanup step. The full ceremony is documented in `docs/install/signing-key-ceremony.md`.
|
||||||
|
- **XBPS key:** separate RSA 3072 key stored as the repository Actions secret `XBPS_SIGNING_KEY`; its public key and fingerprint are published in `Fenris-xbps`. The release workflow uses it for the XBPS package and, when publication is explicitly requested, the repository index. A final `always()` cleanup deletes its runner copy after publication and release asset upload.
|
||||||
- **Public key publication:** in-repo `packaging/keys/fenris-packaging.asc` (raw URL doubles as the `.repo` gpgkey target), release notes, docs page. No keyservers — TOFU-over-TLS.
|
- **Public key publication:** in-repo `packaging/keys/fenris-packaging.asc` (raw URL doubles as the `.repo` gpgkey target), release notes, docs page. No keyservers — TOFU-over-TLS.
|
||||||
- **Rotation (outline):** new key published alongside old; rpm signed with the new key; `fenris.repo` gpgkey lists both URLs (dnf accepts multiple); old key dropped after one release cycle. Procedure details stay in map fog.
|
- **Rotation (outline):** new key published alongside old; rpm signed with the new key; `fenris.repo` gpgkey lists both URLs (dnf accepts multiple); old key dropped after one release cycle. Procedure details stay in map fog.
|
||||||
|
|
||||||
@@ -53,9 +54,9 @@
|
|||||||
- **A Release is:** a version tag, its packages in the channel, a Gitea release entry with notes, and a clearsigned SHA256SUMS — all together. **Bare tags are forbidden** (tag without packages + release entry is not a Release).
|
- **A Release is:** a version tag, its packages in the channel, a Gitea release entry with notes, and a clearsigned SHA256SUMS — all together. **Bare tags are forbidden** (tag without packages + release entry is not a Release).
|
||||||
- **Cadence: on-demand.** Tag when user-visible changes or fixes accumulate; no calendar, no empty releases, no frequency SLA, no RC ceremony — fixes ship as a revision bump of the current version.
|
- **Cadence: on-demand.** Tag when user-visible changes or fixes accumulate; no calendar, no empty releases, no frequency SLA, no RC ceremony — fixes ship as a revision bump of the current version.
|
||||||
- **Versioning: plain semver.** Major = breaking CLI/config/unit change; store schema changes ride the natural bump (the forward-only refusal handles old-reader/new-store).
|
- **Versioning: plain semver.** Major = breaking CLI/config/unit change; store schema changes ride the natural bump (the forward-only refusal handles old-reader/new-store).
|
||||||
- **Promotion flow:** tag → `make release` (automated: `make package` → RPM signing via nfpm → SHA256SUMS generation → clearsign → prints registry PUTs + Gitea release steps). The ceremony is documented in `docs/install/signing-key-ceremony.md`.
|
- **Promotion flow:** bump `pyproject.toml` and the matching dated `CHANGELOG.md` section, then push tag `v<version>`. The repository-scoped Gitea Actions workflow builds and validates the deb, rpm, checksums, and release entry. XBPS publication remains a manual dispatch option after host acceptance. `make release` is for local artifact preparation and does not replace the tag workflow as the supported publication path. The ceremony is documented in `docs/install/signing-key-ceremony.md`.
|
||||||
- **Rollback:** installing an older package over a newer store is **unsupported** — the store's forward-only version refusal fails it by design. Documented rollback = restore the observation-store snapshot, then install the old Release. No automatic downgrade machinery exists or will be built.
|
- **Rollback:** installing an older package over a newer store is **unsupported** — the store's forward-only version refusal fails it by design. Documented rollback = restore the observation-store snapshot, then install the old Release. No automatic downgrade machinery exists or will be built.
|
||||||
- **CI:** no runners are registered on the instance today ([Actions runner research](https://git.bongbetic.com/xavierk/Fenris/issues/37)), so the manual flow above is primary. A dormant `.gitea/workflows/release.yml` (`on: push: tags: ['v*']`, single job, host-mode runner) is committed alongside; if it fires, it replicates `make release`. Cheapest future upgrade: one `act_runner` static binary in host-label mode on the existing Gitea host.
|
- **CI:** a repository-scoped self-hosted runner is registered and online (checked 2026-09-29). `.gitea/workflows/release.yml` is the tag-triggered release path; maintainers must confirm runner availability and required Gitea secrets before tagging. The workflow publishes deb/rpm packages and release assets; Void publication is withheld unless the signed XBPS host-release step is explicitly requested.
|
||||||
|
|
||||||
## 6. Package layout and ownership
|
## 6. Package layout and ownership
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
# XBPS Proof of Concept Results (Issue #83)
|
||||||
|
|
||||||
|
Status: Complete. All acceptance criteria satisfied.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Signed XBPS installation and immediate updates through Gitea have been demonstrated
|
||||||
|
and verified end-to-end. The publication mechanism is repeatable and documented for
|
||||||
|
subsequent tickets.
|
||||||
|
|
||||||
|
## Acceptance Criteria Results
|
||||||
|
|
||||||
|
### 1. Permanent raw URL delivery without LFS indirection
|
||||||
|
|
||||||
|
**Result: PASS**
|
||||||
|
|
||||||
|
All artifacts are served directly by Gitea via raw-file URLs:
|
||||||
|
|
||||||
|
- Repository: `https://git.bongbetic.com/xavierk/Fenris-xbps`
|
||||||
|
- Raw URL pattern: `https://git.bongbetic.com/xavierk/Fenris-xbps/raw/branch/stable/x86_64/<file>`
|
||||||
|
|
||||||
|
Verified files:
|
||||||
|
- `x86_64-repodata` (1327 bytes) — HTTP 200
|
||||||
|
- `fenris-0.3.5_1.x86_64.xbps` (731 bytes) — HTTP 200
|
||||||
|
- `fenris-0.3.5_1.x86_64.xbps.sig2` (384 bytes) — HTTP 200
|
||||||
|
- `fenris-0.3.6_1.x86_64.xbps` (752 bytes) — HTTP 200
|
||||||
|
- `fenris-0.3.6_1.x86_64.xbps.sig2` (384 bytes) — HTTP 200
|
||||||
|
|
||||||
|
No LFS indirection detected. Files served as ordinary Git blobs.
|
||||||
|
|
||||||
|
### 2. Signing-key handling and trust verification
|
||||||
|
|
||||||
|
**Result: PASS**
|
||||||
|
|
||||||
|
Signing uses SSH RSA keys (3072-bit) via `xbps-rindex --sign-pkg` and `xbps-rindex --sign`.
|
||||||
|
The public key is embedded in the repository metadata (`index-meta.plist`) within the
|
||||||
|
repodata archive. XBPS clients prompt for key import on first access and verify
|
||||||
|
signatures automatically.
|
||||||
|
|
||||||
|
Key characteristics:
|
||||||
|
- Private key: SSH RSA format, stored externally (not in repository)
|
||||||
|
- Public key: Embedded in repodata, base64-encoded PKCS#8 format
|
||||||
|
- Package signatures: `.sig2` files alongside each `.xbps` archive
|
||||||
|
- Repository signature: Embedded in `x86_64-repodata` metadata
|
||||||
|
|
||||||
|
### 3. Install and update with client that fetched old index
|
||||||
|
|
||||||
|
**Result: PASS**
|
||||||
|
|
||||||
|
Demonstrated full lifecycle:
|
||||||
|
|
||||||
|
1. Fresh install of v0.3.5 from repository with only v0.3.5 in index
|
||||||
|
2. Added v0.3.6 to index and committed to `stable` branch
|
||||||
|
3. Client performed `xbps-install -Syu` and upgraded from 0.3.5 → 0.3.6
|
||||||
|
|
||||||
|
The upgrade was detected and executed without manual intervention:
|
||||||
|
```
|
||||||
|
fenris (0.3.5_1 -> 0.3.6_1)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Cache behavior and binary size limits
|
||||||
|
|
||||||
|
**Result: PASS (with documented caveat)**
|
||||||
|
|
||||||
|
Cache behavior:
|
||||||
|
- Gitea raw endpoint: `cache-control: public, max-age=21600` (6-hour cache)
|
||||||
|
- ETag changes on each commit (different file hash)
|
||||||
|
- Conditional requests with old ETag return 200 (full content), not 304
|
||||||
|
|
||||||
|
xbps-install behavior:
|
||||||
|
- `-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:
|
||||||
|
- Test packages: 731–752 bytes (small test artifacts)
|
||||||
|
- Real packages expected to be <10MB (vendored pure-Python)
|
||||||
|
- Gitea serves any file size without LFS
|
||||||
|
|
||||||
|
**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
|
||||||
|
|
||||||
|
**Result: PASS**
|
||||||
|
|
||||||
|
Publication mechanism:
|
||||||
|
- Index and new package committed together in single Git commit
|
||||||
|
- Older package artifacts retained in tree (e.g., v0.3.5 alongside v0.3.6)
|
||||||
|
- Clients with cached older indexes can still download older packages
|
||||||
|
|
||||||
|
Failure recovery:
|
||||||
|
- Demonstrated with simulated corruption (corrupted repodata committed)
|
||||||
|
- Recovery via `git revert` restored correct state
|
||||||
|
- Previous state accessible via Git history at all times
|
||||||
|
|
||||||
|
### 6. Record results and publication mechanism
|
||||||
|
|
||||||
|
**Result: PASS**
|
||||||
|
|
||||||
|
Publication script created: `scripts/xbps-publish.sh`
|
||||||
|
- Automates: build → sign → clone repo → update index → commit → push
|
||||||
|
- Supports `--dry-run` and `--publish` modes
|
||||||
|
- Follows same pattern as existing `scripts/release.sh`
|
||||||
|
|
||||||
|
Makefile targets added:
|
||||||
|
- `package-xbps` — Build XBPS package
|
||||||
|
- `sign-xbps` — Sign XBPS package
|
||||||
|
- `xbps-publish` — Publish to distribution repository
|
||||||
|
- `xbps-publish-dry-run` — Dry-run publication
|
||||||
|
|
||||||
|
## Repository Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
xavierk/Fenris-xbps (stable branch)
|
||||||
|
x86_64/
|
||||||
|
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)
|
||||||
|
keys/
|
||||||
|
fenris-xbps-signing.pub # Public signing key
|
||||||
|
README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Client Configuration
|
||||||
|
|
||||||
|
Users add the repository to `/etc/xbps.d/xbps.conf`:
|
||||||
|
```
|
||||||
|
repository=https://git.bongbetic.com/xavierk/Fenris-xbps/raw/branch/stable/x86_64
|
||||||
|
```
|
||||||
|
|
||||||
|
Or make a one-off request without writing the configuration file:
|
||||||
|
```sh
|
||||||
|
sudo xbps-install -R https://git.bongbetic.com/xavierk/Fenris-xbps/raw/branch/stable/x86_64 -M -S fenris
|
||||||
|
```
|
||||||
|
|
||||||
|
## Publication Mechanism
|
||||||
|
|
||||||
|
1. Build: `make package-xbps`
|
||||||
|
2. Sign: `make sign-xbps` (or `scripts/xbps-publish.sh` handles this)
|
||||||
|
3. Publish: `make xbps-publish`
|
||||||
|
4. Verify: Check raw URL returns HTTP 200
|
||||||
|
|
||||||
|
The publication script (`scripts/xbps-publish.sh`) automates the full flow:
|
||||||
|
build → sign → clone repo → add to index → sign repository → commit → push.
|
||||||
|
|
||||||
|
## Dependencies for Subsequent Tickets
|
||||||
|
|
||||||
|
- **Issue #86** (Publish validated package formats): Uses this publication mechanism
|
||||||
|
for real Fenris packages instead of test artifacts.
|
||||||
|
- **Issue #87** (Validate on Void machine): Uses the established repository URL
|
||||||
|
and signing infrastructure for end-to-end validation.
|
||||||
+2
-2
@@ -7,7 +7,7 @@ description: >
|
|||||||
NVMe wear monitor with persistent TUI — observes real-world drive use and
|
NVMe wear monitor with persistent TUI — observes real-world drive use and
|
||||||
translates it into an understandable endurance outlook.
|
translates it into an understandable endurance outlook.
|
||||||
homepage: https://git.bongbetic.com/xavierk/Fenris
|
homepage: https://git.bongbetic.com/xavierk/Fenris
|
||||||
license: Proprietary
|
license: MIT
|
||||||
|
|
||||||
depends:
|
depends:
|
||||||
- python3 (>= 3.10)
|
- python3 (>= 3.10)
|
||||||
@@ -38,7 +38,7 @@ contents:
|
|||||||
- dst: /var/lib/fenris
|
- dst: /var/lib/fenris
|
||||||
type: dir
|
type: dir
|
||||||
file_info:
|
file_info:
|
||||||
mode: 2750
|
mode: 02770
|
||||||
group: fenris
|
group: fenris
|
||||||
|
|
||||||
scripts:
|
scripts:
|
||||||
|
|||||||
+45
-23
@@ -12,10 +12,17 @@ STORE_BAK="${STORE_DIR}/observations.db.bak"
|
|||||||
RUNTIME_PYTHON="/usr/bin/python3"
|
RUNTIME_PYTHON="/usr/bin/python3"
|
||||||
VENDOR_DIR="/opt/fenris/vendor"
|
VENDOR_DIR="/opt/fenris/vendor"
|
||||||
|
|
||||||
|
# Detect init system
|
||||||
|
if [ -d /run/systemd/system ] || [ "$(cat /proc/1/comm 2>/dev/null)" = "systemd" ]; then
|
||||||
|
INIT_SYSTEM="systemd"
|
||||||
|
else
|
||||||
|
INIT_SYSTEM="runit"
|
||||||
|
fi
|
||||||
|
|
||||||
case "${1:-}" in
|
case "${1:-}" in
|
||||||
configure)
|
configure)
|
||||||
if [ -n "${2:-}" ]; then
|
if [ -n "${2:-}" ]; then
|
||||||
# Upgrade — snapshot, migration, daemon-reload, conditional timer restart
|
# Upgrade — snapshot, migration, init-system-aware reload
|
||||||
if [ -f "${STORE_DB}" ]; then
|
if [ -f "${STORE_DB}" ]; then
|
||||||
cp "${STORE_DB}" "${STORE_BAK}" 2>/dev/null || true
|
cp "${STORE_DB}" "${STORE_BAK}" 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
@@ -27,29 +34,44 @@ n = migrate_to_latest(Path('${STORE_DB}'))
|
|||||||
print(f'Fenris migration: {n} step(s) applied') if n else None
|
print(f'Fenris migration: {n} step(s) applied') if n else None
|
||||||
" 2>&1 || echo "Fenris: migration skipped (store not yet initialized)"
|
" 2>&1 || echo "Fenris: migration skipped (store not yet initialized)"
|
||||||
fi
|
fi
|
||||||
# Capture running unit content BEFORE daemon-reload (spec §7)
|
if [ "${INIT_SYSTEM}" = "systemd" ]; then
|
||||||
RUNNING_UNITS=""
|
# Capture running unit content BEFORE daemon-reload (spec §7)
|
||||||
for unit in fenris-collect.timer; do
|
RUNNING_UNITS=""
|
||||||
if systemctl is-active --quiet "${unit}" 2>/dev/null; then
|
for unit in fenris-collect.timer; do
|
||||||
RUNNING_UNITS="${RUNNING_UNITS} ${unit}"
|
if systemctl is-active --quiet "${unit}" 2>/dev/null; then
|
||||||
fi
|
RUNNING_UNITS="${RUNNING_UNITS} ${unit}"
|
||||||
done
|
fi
|
||||||
systemctl daemon-reload 2>/dev/null || true
|
done
|
||||||
# Restart timer only if unit contents changed AND active
|
systemctl daemon-reload 2>/dev/null || true
|
||||||
for unit in ${RUNNING_UNITS}; do
|
# Restart timer only if unit contents changed AND active
|
||||||
OLD_CONTENT="$(mktemp)"
|
for unit in ${RUNNING_UNITS}; do
|
||||||
NEW_PATH="/usr/lib/systemd/system/${unit}"
|
OLD_CONTENT="$(mktemp)"
|
||||||
systemctl cat "${unit}" > "${OLD_CONTENT}" 2>/dev/null || true
|
NEW_PATH="/usr/lib/systemd/system/${unit}"
|
||||||
if ! diff -q "${OLD_CONTENT}" "${NEW_PATH}" > /dev/null 2>&1; then
|
systemctl cat "${unit}" > "${OLD_CONTENT}" 2>/dev/null || true
|
||||||
systemctl restart "${unit}" 2>/dev/null || true
|
if ! diff -q "${OLD_CONTENT}" "${NEW_PATH}" > /dev/null 2>&1; then
|
||||||
fi
|
systemctl restart "${unit}" 2>/dev/null || true
|
||||||
rm -f "${OLD_CONTENT}"
|
fi
|
||||||
done
|
rm -f "${OLD_CONTENT}"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
# Fresh install: init-system-specific setup
|
||||||
|
if [ "${INIT_SYSTEM}" = "systemd" ]; then
|
||||||
|
systemd-sysusers || true
|
||||||
|
systemd-tmpfiles --create || true
|
||||||
|
systemctl daemon-reload || true
|
||||||
|
else
|
||||||
|
# runit: create group, set directory permissions
|
||||||
|
groupadd -f fenris
|
||||||
|
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
|
||||||
|
install -d -o root -g fenris -m 2770 /var/log/fenris-collect 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
# sysusers, tmpfiles, daemon-reload (both fresh install and upgrade)
|
|
||||||
systemd-sysusers || true
|
|
||||||
systemd-tmpfiles --create || true
|
|
||||||
systemctl daemon-reload || true
|
|
||||||
;;
|
;;
|
||||||
abort-upgrade|abort-install|disappear)
|
abort-upgrade|abort-install|disappear)
|
||||||
;;
|
;;
|
||||||
|
|||||||
+14
-1
@@ -5,6 +5,13 @@
|
|||||||
# postrm purge (after conffiles and config removed)
|
# postrm purge (after conffiles and config removed)
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
|
# Detect init system
|
||||||
|
if [ -d /run/systemd/system ] || [ "$(cat /proc/1/comm 2>/dev/null)" = "systemd" ]; then
|
||||||
|
INIT_SYSTEM="systemd"
|
||||||
|
else
|
||||||
|
INIT_SYSTEM="runit"
|
||||||
|
fi
|
||||||
|
|
||||||
case "${1:-}" in
|
case "${1:-}" in
|
||||||
purge)
|
purge)
|
||||||
rm -rf /etc/fenris
|
rm -rf /etc/fenris
|
||||||
@@ -16,4 +23,10 @@ case "${1:-}" in
|
|||||||
remove|upgrade|failed-upgrade|abort-install|abort-upgrade|disappear)
|
remove|upgrade|failed-upgrade|abort-install|abort-upgrade|disappear)
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
systemctl daemon-reload 2>/dev/null || true
|
if [ "${INIT_SYSTEM}" = "systemd" ]; then
|
||||||
|
systemctl daemon-reload 2>/dev/null || true
|
||||||
|
else
|
||||||
|
# runit: clean up service directory and log
|
||||||
|
rm -rf /etc/sv/fenris-collect 2>/dev/null || true
|
||||||
|
rm -rf /var/log/fenris-collect 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|||||||
+15
-2
@@ -5,14 +5,27 @@
|
|||||||
# prerm upgrade (old version about to be replaced)
|
# prerm upgrade (old version about to be replaced)
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
|
# Detect init system
|
||||||
|
if [ -d /run/systemd/system ] || [ "$(cat /proc/1/comm 2>/dev/null)" = "systemd" ]; then
|
||||||
|
INIT_SYSTEM="systemd"
|
||||||
|
else
|
||||||
|
INIT_SYSTEM="runit"
|
||||||
|
fi
|
||||||
|
|
||||||
case "${1:-}" in
|
case "${1:-}" in
|
||||||
remove)
|
remove)
|
||||||
# Sanctioned disable — close monitoring period (spec §7)
|
# Sanctioned disable — close monitoring period (spec §7)
|
||||||
if [ -x /usr/libexec/fenris/fenris-monitor ]; then
|
if [ -x /usr/libexec/fenris/fenris-monitor ]; then
|
||||||
/usr/libexec/fenris/fenris-monitor disable --now 2>/dev/null || true
|
/usr/libexec/fenris/fenris-monitor disable --now 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
systemctl stop fenris-collect.timer 2>/dev/null || true
|
if [ "${INIT_SYSTEM}" = "systemd" ]; then
|
||||||
systemctl disable fenris-collect.timer 2>/dev/null || true
|
systemctl stop fenris-collect.timer 2>/dev/null || true
|
||||||
|
systemctl disable fenris-collect.timer 2>/dev/null || true
|
||||||
|
else
|
||||||
|
# runit: remove the service symlink
|
||||||
|
rm -f /var/service/fenris-collect
|
||||||
|
touch /etc/sv/fenris-collect/down 2>/dev/null || true
|
||||||
|
fi
|
||||||
;;
|
;;
|
||||||
upgrade)
|
upgrade)
|
||||||
# Never interrupt monitoring on upgrade
|
# Never interrupt monitoring on upgrade
|
||||||
|
|||||||
@@ -6,6 +6,16 @@ Install Fenris from its package channel after following the [package setup instr
|
|||||||
sudo apt update && sudo apt install fenris # Debian / Ubuntu
|
sudo apt update && sudo apt install fenris # Debian / Ubuntu
|
||||||
sudo dnf install fenris # Fedora
|
sudo dnf install fenris # Fedora
|
||||||
sudo zypper install fenris # openSUSE Tumbleweed
|
sudo zypper install fenris # openSUSE Tumbleweed
|
||||||
|
sudo xbps-install fenris # Void Linux
|
||||||
|
```
|
||||||
|
|
||||||
|
For Void Linux, configure the XBPS repository first:
|
||||||
|
|
||||||
|
```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
|
||||||
```
|
```
|
||||||
|
|
||||||
## Verify downloads
|
## Verify downloads
|
||||||
|
|||||||
+52
-24
@@ -8,13 +8,33 @@ STORE_BAK="${STORE_DIR}/observations.db.bak"
|
|||||||
RUNTIME_PYTHON="/usr/bin/python3"
|
RUNTIME_PYTHON="/usr/bin/python3"
|
||||||
VENDOR_DIR="/opt/fenris/vendor"
|
VENDOR_DIR="/opt/fenris/vendor"
|
||||||
|
|
||||||
|
# Detect init system
|
||||||
|
if [ -d /run/systemd/system ] || [ "$(cat /proc/1/comm 2>/dev/null)" = "systemd" ]; then
|
||||||
|
INIT_SYSTEM="systemd"
|
||||||
|
else
|
||||||
|
INIT_SYSTEM="runit"
|
||||||
|
fi
|
||||||
|
|
||||||
if [ "$1" -eq 1 ]; then
|
if [ "$1" -eq 1 ]; then
|
||||||
# Fresh install
|
# Fresh install
|
||||||
systemd-sysusers || true
|
if [ "${INIT_SYSTEM}" = "systemd" ]; then
|
||||||
systemd-tmpfiles --create || true
|
systemd-sysusers || true
|
||||||
systemctl daemon-reload || true
|
systemd-tmpfiles --create || true
|
||||||
|
systemctl daemon-reload || true
|
||||||
|
else
|
||||||
|
# runit: create group, set directory permissions
|
||||||
|
groupadd -f fenris
|
||||||
|
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
|
||||||
|
install -d -o root -g fenris -m 2770 /var/log/fenris-collect 2>/dev/null || true
|
||||||
|
fi
|
||||||
elif [ "$1" -ge 2 ]; then
|
elif [ "$1" -ge 2 ]; then
|
||||||
# Upgrade — snapshot, migration, daemon-reload, conditional timer restart
|
# Upgrade — snapshot, migration, init-system-aware reload
|
||||||
if [ -f "${STORE_DB}" ]; then
|
if [ -f "${STORE_DB}" ]; then
|
||||||
cp "${STORE_DB}" "${STORE_BAK}" 2>/dev/null || true
|
cp "${STORE_DB}" "${STORE_BAK}" 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
@@ -26,24 +46,32 @@ n = migrate_to_latest(Path('${STORE_DB}'))
|
|||||||
print(f'Fenris migration: {n} step(s) applied') if n else None
|
print(f'Fenris migration: {n} step(s) applied') if n else None
|
||||||
" 2>&1 || echo "Fenris: migration skipped (store not yet initialized)"
|
" 2>&1 || echo "Fenris: migration skipped (store not yet initialized)"
|
||||||
fi
|
fi
|
||||||
# Capture running unit content BEFORE daemon-reload (spec §7)
|
if [ "${INIT_SYSTEM}" = "systemd" ]; then
|
||||||
RUNNING_UNITS=""
|
# Capture running unit content BEFORE daemon-reload (spec §7)
|
||||||
for unit in fenris-collect.timer; do
|
RUNNING_UNITS=""
|
||||||
if systemctl is-active --quiet "${unit}" 2>/dev/null; then
|
for unit in fenris-collect.timer; do
|
||||||
RUNNING_UNITS="${RUNNING_UNITS} ${unit}"
|
if systemctl is-active --quiet "${unit}" 2>/dev/null; then
|
||||||
fi
|
RUNNING_UNITS="${RUNNING_UNITS} ${unit}"
|
||||||
done
|
fi
|
||||||
systemctl daemon-reload 2>/dev/null || true
|
done
|
||||||
# Restart timer only if unit contents changed AND active
|
systemctl daemon-reload 2>/dev/null || true
|
||||||
for unit in ${RUNNING_UNITS}; do
|
# Restart timer only if unit contents changed AND active
|
||||||
OLD_CONTENT="$(mktemp)"
|
for unit in ${RUNNING_UNITS}; do
|
||||||
NEW_PATH="/usr/lib/systemd/system/${unit}"
|
OLD_CONTENT="$(mktemp)"
|
||||||
systemctl cat "${unit}" > "${OLD_CONTENT}" 2>/dev/null || true
|
NEW_PATH="/usr/lib/systemd/system/${unit}"
|
||||||
if ! diff -q "${OLD_CONTENT}" "${NEW_PATH}" > /dev/null 2>&1; then
|
systemctl cat "${unit}" > "${OLD_CONTENT}" 2>/dev/null || true
|
||||||
systemctl restart "${unit}" 2>/dev/null || true
|
if ! diff -q "${OLD_CONTENT}" "${NEW_PATH}" > /dev/null 2>&1; then
|
||||||
fi
|
systemctl restart "${unit}" 2>/dev/null || true
|
||||||
rm -f "${OLD_CONTENT}"
|
fi
|
||||||
done
|
rm -f "${OLD_CONTENT}"
|
||||||
# Re-apply placement modes (store dir group access, issue #54)
|
done
|
||||||
systemd-tmpfiles --create || true
|
# 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
|
fi
|
||||||
|
|||||||
+14
-1
@@ -2,6 +2,13 @@
|
|||||||
# RPM %postun — post-uninstall scriptlet (spec §7).
|
# RPM %postun — post-uninstall scriptlet (spec §7).
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
|
# Detect init system
|
||||||
|
if [ -d /run/systemd/system ] || [ "$(cat /proc/1/comm 2>/dev/null)" = "systemd" ]; then
|
||||||
|
INIT_SYSTEM="systemd"
|
||||||
|
else
|
||||||
|
INIT_SYSTEM="runit"
|
||||||
|
fi
|
||||||
|
|
||||||
if [ "$1" -eq 0 ]; then
|
if [ "$1" -eq 0 ]; then
|
||||||
# Package fully erased — remove config, store, group
|
# Package fully erased — remove config, store, group
|
||||||
rm -rf /etc/fenris
|
rm -rf /etc/fenris
|
||||||
@@ -10,4 +17,10 @@ if [ "$1" -eq 0 ]; then
|
|||||||
groupdel fenris 2>/dev/null || true
|
groupdel fenris 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
systemctl daemon-reload 2>/dev/null || true
|
if [ "${INIT_SYSTEM}" = "systemd" ]; then
|
||||||
|
systemctl daemon-reload 2>/dev/null || true
|
||||||
|
else
|
||||||
|
# runit: clean up service directory and log
|
||||||
|
rm -rf /etc/sv/fenris-collect 2>/dev/null || true
|
||||||
|
rm -rf /var/log/fenris-collect 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|||||||
+15
-2
@@ -2,12 +2,25 @@
|
|||||||
# RPM %preun — pre-uninstall scriptlet (spec §7).
|
# RPM %preun — pre-uninstall scriptlet (spec §7).
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
|
# Detect init system
|
||||||
|
if [ -d /run/systemd/system ] || [ "$(cat /proc/1/comm 2>/dev/null)" = "systemd" ]; then
|
||||||
|
INIT_SYSTEM="systemd"
|
||||||
|
else
|
||||||
|
INIT_SYSTEM="runit"
|
||||||
|
fi
|
||||||
|
|
||||||
if [ "$1" -eq 0 ]; then
|
if [ "$1" -eq 0 ]; then
|
||||||
# Package is being erased — sanctioned disable (spec §7)
|
# Package is being erased — sanctioned disable (spec §7)
|
||||||
if [ -x /usr/libexec/fenris/fenris-monitor ]; then
|
if [ -x /usr/libexec/fenris/fenris-monitor ]; then
|
||||||
/usr/libexec/fenris/fenris-monitor disable --now 2>/dev/null || true
|
/usr/libexec/fenris/fenris-monitor disable --now 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
systemctl stop fenris-collect.timer 2>/dev/null || true
|
if [ "${INIT_SYSTEM}" = "systemd" ]; then
|
||||||
systemctl disable fenris-collect.timer 2>/dev/null || true
|
systemctl stop fenris-collect.timer 2>/dev/null || true
|
||||||
|
systemctl disable fenris-collect.timer 2>/dev/null || true
|
||||||
|
else
|
||||||
|
# runit: remove the service symlink
|
||||||
|
rm -f /var/service/fenris-collect
|
||||||
|
touch /etc/sv/fenris-collect/down 2>/dev/null || true
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
# On upgrade ($1 -ge 1): do nothing
|
# On upgrade ($1 -ge 1): do nothing
|
||||||
|
|||||||
@@ -14,6 +14,10 @@
|
|||||||
# /usr/lib/tmpfiles.d/fenris.conf
|
# /usr/lib/tmpfiles.d/fenris.conf
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Package directories must be traversable by every runtime user and agree
|
||||||
|
# with distribution-owned directories, regardless of the builder's umask.
|
||||||
|
umask 022
|
||||||
|
|
||||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
STAGE_DIR="${REPO_ROOT}/build/stage"
|
STAGE_DIR="${REPO_ROOT}/build/stage"
|
||||||
|
|
||||||
@@ -52,6 +56,14 @@ VENDOR_DIR="${STAGE_DIR}/opt/fenris/vendor"
|
|||||||
mkdir -p "${VENDOR_DIR}"
|
mkdir -p "${VENDOR_DIR}"
|
||||||
python3 -m pip install --disable-pip-version-check --no-compile \
|
python3 -m pip install --disable-pip-version-check --no-compile \
|
||||||
--target "${VENDOR_DIR}" -r "${REPO_ROOT}/requirements.txt" "${WHEEL}"
|
--target "${VENDOR_DIR}" -r "${REPO_ROOT}/requirements.txt" "${WHEEL}"
|
||||||
|
# Package files must be importable by unprivileged Fenris users regardless of
|
||||||
|
# the builder's umask. pip otherwise preserves a restrictive umask in the
|
||||||
|
# vendored runtime, which makes the installed CLI fail before it can read the
|
||||||
|
# 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 ---
|
# --- Inject version into wrapper from pyproject.toml ---
|
||||||
# The wrapper has a hardcoded version string; patch it for packaging.
|
# The wrapper has a hardcoded version string; patch it for packaging.
|
||||||
@@ -74,6 +86,12 @@ mkdir -p "${STAGE_DIR}/usr/lib/systemd/system"
|
|||||||
install -m 0644 "${REPO_ROOT}/units/fenris-collect.timer" "${STAGE_DIR}/usr/lib/systemd/system/"
|
install -m 0644 "${REPO_ROOT}/units/fenris-collect.timer" "${STAGE_DIR}/usr/lib/systemd/system/"
|
||||||
install -m 0644 "${REPO_ROOT}/units/fenris-collect.service" "${STAGE_DIR}/usr/lib/systemd/system/"
|
install -m 0644 "${REPO_ROOT}/units/fenris-collect.service" "${STAGE_DIR}/usr/lib/systemd/system/"
|
||||||
|
|
||||||
|
# --- runit service files ---
|
||||||
|
echo " Installing runit service files ..."
|
||||||
|
mkdir -p "${STAGE_DIR}/etc/sv/fenris-collect/log"
|
||||||
|
install -m 0755 "${REPO_ROOT}/units/runit/fenris-collect/run" "${STAGE_DIR}/etc/sv/fenris-collect/run"
|
||||||
|
install -m 0755 "${REPO_ROOT}/units/runit/fenris-collect/log/run" "${STAGE_DIR}/etc/sv/fenris-collect/log/run"
|
||||||
|
|
||||||
# --- polkit policy ---
|
# --- polkit policy ---
|
||||||
echo " Installing polkit policy ..."
|
echo " Installing polkit policy ..."
|
||||||
mkdir -p "${STAGE_DIR}/usr/share/polkit-1/actions"
|
mkdir -p "${STAGE_DIR}/usr/share/polkit-1/actions"
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# XBPS INSTALL script — post-install and post-upgrade paths.
|
||||||
|
#
|
||||||
|
# Arguments: $1=ACTION $2=PKGNAME $3=VERSION $4=UPDATE $5=CONF_FILE $6=ARCH
|
||||||
|
#
|
||||||
|
# Actions: pre (before files extracted), post (after files extracted)
|
||||||
|
# UPDATE: "yes" on upgrade, "no" on fresh install
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
STORE_DIR="/var/lib/fenris"
|
||||||
|
STORE_DB="${STORE_DIR}/observations.db"
|
||||||
|
STORE_BAK="${STORE_DIR}/observations.db.bak"
|
||||||
|
RUNTIME_PYTHON="/usr/bin/python3"
|
||||||
|
VENDOR_DIR="/opt/fenris/vendor"
|
||||||
|
|
||||||
|
ACTION="$1"
|
||||||
|
UPDATE="$4"
|
||||||
|
|
||||||
|
case "${ACTION}" in
|
||||||
|
pre)
|
||||||
|
# Migration guard — abort if make-install remnants detected
|
||||||
|
MARKER="/var/lib/fenris/manifest.txt"
|
||||||
|
if [ -f "${MARKER}" ]; then
|
||||||
|
echo >&2
|
||||||
|
echo >&2 "Fenris make-install remnants detected — refusing to install."
|
||||||
|
echo >&2
|
||||||
|
echo >&2 "Migrate to the package with:"
|
||||||
|
echo >&2 " sudo make uninstall # removes make-install files, preserves store + config"
|
||||||
|
echo >&2 " sudo xbps-install fenris"
|
||||||
|
echo >&2
|
||||||
|
echo >&2 "See: https://git.bongbetic.com/xavierk/Fenris/blob/main/docs/spec/release-packaging.md#9-migration-from-make-install-systems"
|
||||||
|
echo >&2
|
||||||
|
exit 1
|
||||||
|
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
|
||||||
|
cp "${STORE_DB}" "${STORE_BAK}" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
if [ -d "${VENDOR_DIR}" ] && [ -f "${STORE_DB}" ]; then
|
||||||
|
PYTHONPATH="${VENDOR_DIR}" "${RUNTIME_PYTHON}" -c "
|
||||||
|
from fenris.store import migrate_to_latest
|
||||||
|
from pathlib import Path
|
||||||
|
n = migrate_to_latest(Path('${STORE_DB}'))
|
||||||
|
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
|
||||||
|
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
|
||||||
|
# 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
|
||||||
|
install -d -o root -g fenris -m 2770 /var/log/fenris-collect 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
exit 0
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# XBPS REMOVE script — pre-remove path.
|
||||||
|
#
|
||||||
|
# Arguments: $1=ACTION $2=PKGNAME $3=VERSION $4=UPDATE $5=CONF_FILE $6=ARCH
|
||||||
|
#
|
||||||
|
# Actions: pre (before files removed)
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ACTION="$1"
|
||||||
|
UPDATE="$4"
|
||||||
|
case "${ACTION}" in
|
||||||
|
pre)
|
||||||
|
# Only a real erase is a sanctioned disable. An upgrade must preserve
|
||||||
|
# both monitoring intent and the active runit service.
|
||||||
|
if [ "${UPDATE}" = "no" ]; then
|
||||||
|
# Close the monitoring period while the store is still available.
|
||||||
|
if [ -x /usr/libexec/fenris/fenris-monitor ]; then
|
||||||
|
/usr/libexec/fenris/fenris-monitor disable --now 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
# Configuration and the observation store are deliberately not
|
||||||
|
# package-owned. Leave them in place: XBPS does not guarantee a
|
||||||
|
# post-remove callback before its cleanup action.
|
||||||
|
# runit: remove the service symlink and mark dormant
|
||||||
|
rm -f /var/service/fenris-collect
|
||||||
|
touch /etc/sv/fenris-collect/down 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
exit 0
|
||||||
+3
-2
@@ -1,8 +1,9 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "fenris"
|
name = "fenris"
|
||||||
version = "0.3.4"
|
version = "0.6.0"
|
||||||
description = "NVMe wear monitor with persistent TUI"
|
description = "NVMe wear monitor with persistent TUI"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.10"
|
||||||
|
license = {file = "LICENSE"}
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"textual>=0.40.0",
|
"textual>=0.40.0",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -9,3 +9,4 @@ mdurl==0.1.2
|
|||||||
platformdirs==4.11.7
|
platformdirs==4.11.7
|
||||||
Pygments==2.21.0
|
Pygments==2.21.0
|
||||||
linkify-it-py==2.2.0
|
linkify-it-py==2.2.0
|
||||||
|
typing-extensions==4.16.0
|
||||||
|
|||||||
@@ -68,6 +68,20 @@ def assemble_release_body(section: str, footer: str) -> str:
|
|||||||
return f"{section}{separator}{footer}"
|
return f"{section}{separator}{footer}"
|
||||||
|
|
||||||
|
|
||||||
|
def format_availability_section(
|
||||||
|
available: list[str], withheld: list[str]
|
||||||
|
) -> str:
|
||||||
|
"""Generate a package formats section for release notes."""
|
||||||
|
if not available and not withheld:
|
||||||
|
return ""
|
||||||
|
lines = ["\n## Package formats\n"]
|
||||||
|
if available:
|
||||||
|
lines.append(f"Available: {', '.join(available)}")
|
||||||
|
if withheld:
|
||||||
|
lines.append(f"Withheld: {', '.join(withheld)}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
parser.add_argument("changelog", type=Path)
|
parser.add_argument("changelog", type=Path)
|
||||||
@@ -77,6 +91,18 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
type=Path,
|
type=Path,
|
||||||
help="append this standing release guidance after the extracted section",
|
help="append this standing release guidance after the extracted section",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--available",
|
||||||
|
action="append",
|
||||||
|
default=[],
|
||||||
|
help="format available for this release (can be repeated)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--withheld",
|
||||||
|
action="append",
|
||||||
|
default=[],
|
||||||
|
help="format withheld from this release (can be repeated)",
|
||||||
|
)
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
try:
|
try:
|
||||||
section = extract_changelog(args.changelog, args.version)
|
section = extract_changelog(args.changelog, args.version)
|
||||||
@@ -88,6 +114,10 @@ def main(argv: list[str] | None = None) -> int:
|
|||||||
f"cannot read release footer {args.footer}: {error.strerror}"
|
f"cannot read release footer {args.footer}: {error.strerror}"
|
||||||
) from error
|
) from error
|
||||||
section = assemble_release_body(section, footer)
|
section = assemble_release_body(section, footer)
|
||||||
|
if args.available or args.withheld:
|
||||||
|
formats = format_availability_section(args.available, args.withheld)
|
||||||
|
if formats:
|
||||||
|
section = f"{section}\n{formats}"
|
||||||
sys.stdout.write(section)
|
sys.stdout.write(section)
|
||||||
except ChangelogError as error:
|
except ChangelogError as error:
|
||||||
print(f"::error::{error}", file=sys.stderr)
|
print(f"::error::{error}", file=sys.stderr)
|
||||||
|
|||||||
+9
-30
@@ -7,8 +7,6 @@ Subcommands route through fenris-monitor for privileged operations.
|
|||||||
Spec: §1.2, §8.4
|
Spec: §1.2, §8.4
|
||||||
"""
|
"""
|
||||||
import argparse
|
import argparse
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -35,35 +33,16 @@ def add_runtime_packages() -> None:
|
|||||||
add_runtime_packages()
|
add_runtime_packages()
|
||||||
|
|
||||||
|
|
||||||
def is_root() -> bool:
|
|
||||||
"""Check if running as root."""
|
|
||||||
return os.geteuid() == 0
|
|
||||||
|
|
||||||
|
|
||||||
def run_monitor(*args: str) -> None:
|
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.
|
try:
|
||||||
"""
|
invoke_monitor(*args)
|
||||||
monitor_cmd = "/usr/libexec/fenris/fenris-monitor"
|
except MonitorError as exc:
|
||||||
|
print(str(exc), file=sys.stderr)
|
||||||
if is_root():
|
sys.exit(exc.exit_code)
|
||||||
result = subprocess.run([monitor_cmd] + list(args))
|
sys.exit(0)
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_tui(args: argparse.Namespace) -> None:
|
def cmd_tui(args: argparse.Namespace) -> None:
|
||||||
@@ -143,7 +122,7 @@ def main() -> None:
|
|||||||
description="Fenris NVMe endurance monitor",
|
description="Fenris NVMe endurance monitor",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
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")
|
subparsers = parser.add_subparsers(dest="command")
|
||||||
|
|||||||
Executable
+208
@@ -0,0 +1,208 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Fenris XBPS publication script (issue #83).
|
||||||
|
# Builds, signs, and publishes XBPS packages to the Fenris-xbps repository.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# scripts/xbps-publish.sh --dry-run # Print commands without executing
|
||||||
|
# scripts/xbps-publish.sh --publish # Execute the full publication flow
|
||||||
|
#
|
||||||
|
# Environment:
|
||||||
|
# XBPS_SIGNING_KEY - Path to SSH RSA private key for XBPS signing
|
||||||
|
# (default: ~/.ssh/id_xbps)
|
||||||
|
# SIGNED_BY - Signature identity string
|
||||||
|
# (default: "Fenris Packaging <packaging@bongbetic.com>")
|
||||||
|
#
|
||||||
|
# Spec: native-void-support.md, ADR 0008
|
||||||
|
|
||||||
|
# ── Defaults ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
DRY_RUN=false
|
||||||
|
PUBLISH=false
|
||||||
|
GITEA_URL="https://git.bongbetic.com"
|
||||||
|
GITEA_OWNER="xavierk"
|
||||||
|
GITEA_REPO="Fenris-xbps"
|
||||||
|
GITEA_BRANCH="stable"
|
||||||
|
ARCH="x86_64"
|
||||||
|
XBPS_SIGNING_KEY="${XBPS_SIGNING_KEY:-$HOME/.ssh/id_xbps}"
|
||||||
|
SIGNED_BY="${SIGNED_BY:-Fenris Packaging <packaging@bongbetic.com>}"
|
||||||
|
|
||||||
|
# ── Parse arguments ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--dry-run) DRY_RUN=true ;;
|
||||||
|
--publish) PUBLISH=true ;;
|
||||||
|
--help|-h)
|
||||||
|
echo "Usage: $0 [--dry-run | --publish]"
|
||||||
|
echo ""
|
||||||
|
echo "Modes:"
|
||||||
|
echo " --dry-run Print commands without executing (default)"
|
||||||
|
echo " --publish Execute the full publication flow"
|
||||||
|
echo ""
|
||||||
|
echo "Environment:"
|
||||||
|
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
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown argument: $arg" >&2
|
||||||
|
echo "Usage: $0 [--dry-run | --publish]" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if ! $DRY_RUN && ! $PUBLISH; then
|
||||||
|
DRY_RUN=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_version() {
|
||||||
|
sed -n 's/^version = "\(.*\)"/\1/p' pyproject.toml
|
||||||
|
}
|
||||||
|
|
||||||
|
_run() {
|
||||||
|
if $DRY_RUN; then
|
||||||
|
echo " $*"
|
||||||
|
else
|
||||||
|
eval "$@"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Pre-flight checks ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if [[ ! -f "$XBPS_SIGNING_KEY" ]]; then
|
||||||
|
echo "ERROR: Signing key not found at $XBPS_SIGNING_KEY" >&2
|
||||||
|
echo "Generate one with: ssh-keygen -t rsa -b 3072 -f $XBPS_SIGNING_KEY" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
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
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# ── Main ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
VERSION=$(_version)
|
||||||
|
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 ""
|
||||||
|
|
||||||
|
if $DRY_RUN; then
|
||||||
|
echo "[dry-run] Commands below will be executed in --publish mode."
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Step 1: Build XBPS package ───────────────────────────────────────────
|
||||||
|
|
||||||
|
echo "--- Build XBPS package ---"
|
||||||
|
_run "make XBPS_REVISION=${REVISION} package-xbps"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── Step 2: Sign package ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
echo "--- Sign XBPS package ---"
|
||||||
|
_run "rm -f ${XBPS_PATH}.sig2"
|
||||||
|
_run "xbps-rindex --sign-pkg --privkey ${XBPS_SIGNING_KEY} ${XBPS_PATH}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# ── Step 3: Clone/update distribution repository ─────────────────────────
|
||||||
|
|
||||||
|
echo "--- Prepare distribution repository ---"
|
||||||
|
WORK_DIR=$(mktemp -d)
|
||||||
|
_run "git clone ${GITEA_URL}/${GITEA_OWNER}/${GITEA_REPO}.git ${WORK_DIR}"
|
||||||
|
_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_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 "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_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 ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if $PUBLISH; then
|
||||||
|
rm -rf "${WORK_DIR}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Done ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
echo "=== XBPS Publication v${VERSION} complete ==="
|
||||||
|
echo ""
|
||||||
|
echo "Summary:"
|
||||||
|
echo " Package: ${XBPS_FILE}"
|
||||||
|
echo " Repository: ${GITEA_URL}/${GITEA_OWNER}/${GITEA_REPO}"
|
||||||
|
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 " 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."""
|
"""Fenris: NVMe wear monitor with persistent TUI."""
|
||||||
__version__ = "0.3.1"
|
__version__ = "0.5.0"
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
"""A terminal volume plot shared by Fenris's activity views.
|
||||||
|
|
||||||
|
Braille provides two by four dots per terminal cell. Only adjacent, complete
|
||||||
|
measurements are connected; gaps and partial evidence never imply continuity.
|
||||||
|
The returned columns also place mouse inspection on the plotted time axis.
|
||||||
|
"""
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from rich.text import Text
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class VolumePoint:
|
||||||
|
time: float
|
||||||
|
volume: int | None
|
||||||
|
label: str
|
||||||
|
state: str = "measured"
|
||||||
|
|
||||||
|
|
||||||
|
def volume_plot(
|
||||||
|
points: Sequence[VolumePoint], width: int, height: int,
|
||||||
|
selected: int, colors: Mapping[str, str],
|
||||||
|
) -> tuple[Text, list[int], str]:
|
||||||
|
"""Render bounded axes and a dotted trace, without resampling evidence."""
|
||||||
|
width, height = max(12, width), max(4, height)
|
||||||
|
axis_width = 8
|
||||||
|
columns, rows = width - axis_width, height - 2
|
||||||
|
pixel_width, pixel_height = columns * 2, rows * 4
|
||||||
|
maximum = max((p.volume or 0 for p in points), default=0)
|
||||||
|
scale, unit = (1e12, "TB") if maximum >= 1e12 else (
|
||||||
|
(1e9, "GB") if maximum >= 1e9 else
|
||||||
|
(1e6, "MB") if maximum >= 1e6 else
|
||||||
|
(1e3, "KB") if maximum >= 1e3 else (1, "B")
|
||||||
|
)
|
||||||
|
ceiling = maximum or scale
|
||||||
|
start = points[0].time if points else 0
|
||||||
|
span = (points[-1].time - start) if len(points) > 1 else 0
|
||||||
|
xs = [round((p.time - start) / span * (pixel_width - 1)) if span else 0
|
||||||
|
for p in points]
|
||||||
|
cells = [[0] * columns for _ in range(rows)]
|
||||||
|
# Unicode braille dot numbering, indexed by y within cell and then x.
|
||||||
|
bits = ((1, 8), (2, 16), (4, 32), (64, 128))
|
||||||
|
|
||||||
|
def dot(x: int, y: int) -> None:
|
||||||
|
cells[y // 4][x // 2] |= bits[y % 4][x % 2]
|
||||||
|
|
||||||
|
previous = None
|
||||||
|
markers = {}
|
||||||
|
for index, (point, x) in enumerate(zip(points, xs)):
|
||||||
|
if point.volume is None:
|
||||||
|
previous = None
|
||||||
|
if point.state != "future":
|
||||||
|
markers[x // 2] = "?"
|
||||||
|
continue
|
||||||
|
y = round((1 - point.volume / ceiling) * (pixel_height - 1))
|
||||||
|
y = min(pixel_height - 1, max(0, y))
|
||||||
|
if previous is not None and point.state == "measured":
|
||||||
|
px, py = previous
|
||||||
|
steps = max(abs(x - px), abs(y - py), 1)
|
||||||
|
for step in range(steps + 1):
|
||||||
|
dot(round(px + (x - px) * step / steps),
|
||||||
|
round(py + (y - py) * step / steps))
|
||||||
|
dot(x, y)
|
||||||
|
previous = (x, y) if point.state == "measured" else None
|
||||||
|
if point.state != "measured":
|
||||||
|
markers[x // 2] = "~" if point.state == "partial" else "u"
|
||||||
|
elif point.volume == 0:
|
||||||
|
markers.setdefault(x // 2, "·")
|
||||||
|
|
||||||
|
selected_column = xs[selected] // 2 if 0 <= selected < len(xs) else -1
|
||||||
|
result = Text(no_wrap=True, overflow="crop")
|
||||||
|
ticks = {0, rows // 2, rows - 1}
|
||||||
|
for row, values in enumerate(cells):
|
||||||
|
value = ceiling * (rows - 1 - row) / max(1, rows - 1) / scale
|
||||||
|
label = f"{value:6.2f}"[-6:] if row in ticks else " "
|
||||||
|
result.append(label + " │", style=colors["muted"])
|
||||||
|
for col, value in enumerate(values):
|
||||||
|
char = chr(0x2800 + value) if value else " "
|
||||||
|
style = colors["allocated"]
|
||||||
|
if col == selected_column:
|
||||||
|
style = "bold " + colors["selection"]
|
||||||
|
if not value:
|
||||||
|
char = "┊"
|
||||||
|
style = colors["muted"]
|
||||||
|
result.append(char, style=style)
|
||||||
|
result.append("\n")
|
||||||
|
result.append(" └", style=colors["muted"])
|
||||||
|
for col in range(columns):
|
||||||
|
result.append("▼" if col == selected_column else markers.get(col, "─"),
|
||||||
|
style=colors["selection"] if col == selected_column else colors["muted"])
|
||||||
|
result.append("\n" + " " * axis_width)
|
||||||
|
labels = [" "] * columns
|
||||||
|
end_of_label = -1
|
||||||
|
for index in sorted({0, len(points) // 2, len(points) - 1}):
|
||||||
|
if index < 0 or not points:
|
||||||
|
continue
|
||||||
|
label = points[index].label[:columns]
|
||||||
|
column = max(0, min(columns - len(label), xs[index] // 2 - len(label) // 2))
|
||||||
|
if column > end_of_label:
|
||||||
|
labels[column:column + len(label)] = label
|
||||||
|
end_of_label = column + len(label)
|
||||||
|
result.append("".join(labels), style=colors["muted"])
|
||||||
|
return result, [axis_width + x // 2 for x in xs], unit
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
"""User choices for live and historical activity views."""
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
HISTORY_RANGE_DEFAULT = 14
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class IntervalIdentity:
|
||||||
|
"""Stable identity for one measured interval."""
|
||||||
|
|
||||||
|
start_ts: str
|
||||||
|
end_ts: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class HistoryDayIdentity:
|
||||||
|
"""Stable identity for one recorded local-day summary."""
|
||||||
|
|
||||||
|
local_date: str
|
||||||
|
timezone: str
|
||||||
|
utc_start: str
|
||||||
|
utc_end: str
|
||||||
|
|
||||||
|
|
||||||
|
class ActivitySelection:
|
||||||
|
"""Keep activity navigation state separate from its Textual rendering."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.view = "live"
|
||||||
|
self.browse_date: str | None = None
|
||||||
|
self.selected_history_day: HistoryDayIdentity | None = None
|
||||||
|
self.selected_history_hour: str | None = None
|
||||||
|
self.history_range_days = HISTORY_RANGE_DEFAULT
|
||||||
|
self.measure = "written"
|
||||||
|
self.following_live = True
|
||||||
|
self.selected_live_interval: IntervalIdentity | None = None
|
||||||
|
self.live_interval_expired = False
|
||||||
|
|
||||||
|
def set_view(self, view: str) -> None:
|
||||||
|
"""Select live, day, or history view."""
|
||||||
|
if view not in ("live", "day", "history"):
|
||||||
|
raise ValueError(f"unknown activity view: {view}")
|
||||||
|
self.view = view
|
||||||
|
if view == "live":
|
||||||
|
self.follow_live()
|
||||||
|
|
||||||
|
def select_history_date(
|
||||||
|
self,
|
||||||
|
date: str | None,
|
||||||
|
identity: HistoryDayIdentity | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Keep requested date and evidence identity across refresh."""
|
||||||
|
if date != self.browse_date or (
|
||||||
|
identity is not None and identity != self.selected_history_day
|
||||||
|
):
|
||||||
|
self.selected_history_hour = None
|
||||||
|
if date != self.browse_date:
|
||||||
|
self.selected_history_day = None
|
||||||
|
self.browse_date = date
|
||||||
|
if identity is not None:
|
||||||
|
self.selected_history_day = identity
|
||||||
|
elif date is None:
|
||||||
|
self.selected_history_day = None
|
||||||
|
|
||||||
|
def select_history_hour(self, hour: str | None) -> None:
|
||||||
|
"""Keep the chosen historical hour across refresh."""
|
||||||
|
self.selected_history_hour = hour
|
||||||
|
|
||||||
|
def toggle_measure(self) -> str:
|
||||||
|
"""Switch read/write presentation without changing point selection."""
|
||||||
|
self.measure = "read" if self.measure == "written" else "written"
|
||||||
|
return self.measure
|
||||||
|
|
||||||
|
def follow_live(self) -> None:
|
||||||
|
"""Resume following the newest interval and clear stale-pin notices."""
|
||||||
|
self.view = "live"
|
||||||
|
self.browse_date = None
|
||||||
|
self.selected_history_day = None
|
||||||
|
self.selected_history_hour = None
|
||||||
|
self.following_live = True
|
||||||
|
self.selected_live_interval = None
|
||||||
|
self.live_interval_expired = False
|
||||||
|
|
||||||
|
def update_live(self, intervals: Sequence[IntervalIdentity]) -> None:
|
||||||
|
"""Reconcile the selected interval with the rolling live window."""
|
||||||
|
if self.following_live:
|
||||||
|
self.selected_live_interval = intervals[-1] if intervals else None
|
||||||
|
return
|
||||||
|
if self.selected_live_interval in intervals:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.following_live = True
|
||||||
|
self.selected_live_interval = intervals[-1] if intervals else None
|
||||||
|
self.live_interval_expired = True
|
||||||
|
|
||||||
|
def move_live(
|
||||||
|
self, intervals: Sequence[IntervalIdentity], offset: int,
|
||||||
|
) -> IntervalIdentity | None:
|
||||||
|
"""Inspect an adjacent interval by stable identity."""
|
||||||
|
if not intervals:
|
||||||
|
return None
|
||||||
|
if self.selected_live_interval in intervals:
|
||||||
|
current = intervals.index(self.selected_live_interval)
|
||||||
|
else:
|
||||||
|
current = len(intervals) - 1
|
||||||
|
selected = intervals[max(0, min(len(intervals) - 1, current + offset))]
|
||||||
|
self._pin_live_interval(selected)
|
||||||
|
return selected
|
||||||
|
|
||||||
|
def inspect_live(
|
||||||
|
self,
|
||||||
|
intervals: Sequence[IntervalIdentity],
|
||||||
|
interval: IntervalIdentity,
|
||||||
|
) -> None:
|
||||||
|
"""Pin the interval chosen by mouse inspection."""
|
||||||
|
if interval in intervals:
|
||||||
|
self._pin_live_interval(interval)
|
||||||
|
|
||||||
|
def selected_live_index(self, intervals: Sequence[IntervalIdentity]) -> int:
|
||||||
|
"""Return the selected point's current render index, or -1."""
|
||||||
|
if self.selected_live_interval is None:
|
||||||
|
return -1
|
||||||
|
try:
|
||||||
|
return intervals.index(self.selected_live_interval)
|
||||||
|
except ValueError:
|
||||||
|
return -1
|
||||||
|
|
||||||
|
def _pin_live_interval(self, interval: IntervalIdentity) -> None:
|
||||||
|
self.selected_live_interval = interval
|
||||||
|
self.following_live = False
|
||||||
|
self.live_interval_expired = False
|
||||||
+14
-10
@@ -27,7 +27,6 @@ if VENV_DIR.exists():
|
|||||||
if site_packages:
|
if site_packages:
|
||||||
sys.path.insert(0, str(site_packages))
|
sys.path.insert(0, str(site_packages))
|
||||||
|
|
||||||
from fenris.store import init_store, get_store_path
|
|
||||||
from fenris.collector import run_collection
|
from fenris.collector import run_collection
|
||||||
|
|
||||||
|
|
||||||
@@ -103,14 +102,6 @@ def main() -> None:
|
|||||||
config = load_config()
|
config = load_config()
|
||||||
device = config["device"]
|
device = config["device"]
|
||||||
|
|
||||||
# Interrogate the drive
|
|
||||||
smartctl_data = interrogate_drive(device)
|
|
||||||
|
|
||||||
# Find sysfs path
|
|
||||||
sysfs_path = find_nvme_sysfs()
|
|
||||||
if sysfs_path is None:
|
|
||||||
raise RuntimeError("No NVMe controller found in sysfs")
|
|
||||||
|
|
||||||
# Inject a simple clock
|
# Inject a simple clock
|
||||||
class SimpleClock:
|
class SimpleClock:
|
||||||
def utcnow(self):
|
def utcnow(self):
|
||||||
@@ -118,11 +109,24 @@ def main() -> None:
|
|||||||
|
|
||||||
clock = SimpleClock()
|
clock = SimpleClock()
|
||||||
|
|
||||||
|
def acquire():
|
||||||
|
"""Interrogate the drive after pending-work preflight."""
|
||||||
|
smartctl_data = interrogate_drive(device)
|
||||||
|
sysfs_path = find_nvme_sysfs()
|
||||||
|
if sysfs_path is None:
|
||||||
|
raise RuntimeError("No NVMe controller found in sysfs")
|
||||||
|
return smartctl_data, sysfs_path
|
||||||
|
|
||||||
# Run collection
|
# Run collection
|
||||||
result = run_collection(smartctl_data, sysfs_path, config, clock)
|
result = run_collection(config=config, clock=clock, acquire=acquire)
|
||||||
|
|
||||||
if result["ok"]:
|
if result["ok"]:
|
||||||
print(f"Collection successful: {result['sample_count']} sample(s)")
|
print(f"Collection successful: {result['sample_count']} sample(s)")
|
||||||
|
if result.get("retention_error"):
|
||||||
|
print(
|
||||||
|
f"Retention deferred: {result['retention_error']}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
else:
|
else:
|
||||||
print(f"Collection failed: {result['error']}", file=sys.stderr)
|
print(f"Collection failed: {result['error']}", file=sys.stderr)
|
||||||
|
|||||||
+314
-56
@@ -5,16 +5,20 @@ This module implements the thinnest complete write path:
|
|||||||
- Acquire controller identity from sysfs
|
- Acquire controller identity from sysfs
|
||||||
- Normalize identity exactly once at write time
|
- Normalize identity exactly once at write time
|
||||||
- Validate every row against store invariants
|
- Validate every row against store invariants
|
||||||
- Commit one well-formed sample
|
- Stage valid observations privately before derivation
|
||||||
|
- Publish the sample and derived evidence in one collection-owned transaction
|
||||||
|
|
||||||
No code path outside the collector interrogates the device.
|
No code path outside the collector interrogates the device.
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
from collections.abc import Callable
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, Optional, Tuple
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from .derive import derive_hours_from_interval, find_previous_sample
|
||||||
|
from .monitoring_periods import ensure_period_open
|
||||||
from .store import init_store, get_store_path
|
from .store import init_store, get_store_path
|
||||||
|
|
||||||
|
|
||||||
@@ -28,6 +32,29 @@ class InvariantViolationError(Exception):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class _PendingDerivationError(Exception):
|
||||||
|
"""A valid staged observation failed while deriving dependent evidence."""
|
||||||
|
|
||||||
|
def __init__(self, cause: Exception):
|
||||||
|
super().__init__(str(cause))
|
||||||
|
self.cause = cause
|
||||||
|
|
||||||
|
|
||||||
|
class _PendingRecoveryFailure(Exception):
|
||||||
|
"""A publication attempt failed after earlier pending rows committed."""
|
||||||
|
|
||||||
|
def __init__(self, error: Exception, published_count: int):
|
||||||
|
derivation_error = isinstance(error, _PendingDerivationError)
|
||||||
|
cause = error.cause if derivation_error else error
|
||||||
|
super().__init__(str(cause))
|
||||||
|
self.cause = cause
|
||||||
|
self.published_count = published_count
|
||||||
|
self.derivation_error = derivation_error
|
||||||
|
|
||||||
|
|
||||||
|
PENDING_PUBLICATION_LIMIT = 6720
|
||||||
|
|
||||||
|
|
||||||
def acquire_from_smartctl(smartctl_data: Dict[str, Any]) -> Dict[str, Any]:
|
def acquire_from_smartctl(smartctl_data: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""Acquire counters and thermal evidence from smartctl -a -j data.
|
"""Acquire counters and thermal evidence from smartctl -a -j data.
|
||||||
|
|
||||||
@@ -193,12 +220,12 @@ def write_sample(
|
|||||||
sample: Dict[str, Any],
|
sample: Dict[str, Any],
|
||||||
identity: Dict[str, Any],
|
identity: Dict[str, Any],
|
||||||
conn: sqlite3.Connection,
|
conn: sqlite3.Connection,
|
||||||
clock,
|
observed_at: datetime,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Write one sample to the observation store.
|
"""Write one sample to the observation store.
|
||||||
|
|
||||||
Identity normalization happens exactly once here.
|
Identity normalization happens exactly once here.
|
||||||
Returns segment info for the caller.
|
Returns segment info for the caller. Caller owns the transaction.
|
||||||
"""
|
"""
|
||||||
from .segment import find_current_segment, should_open_new_segment, open_segment
|
from .segment import find_current_segment, should_open_new_segment, open_segment
|
||||||
|
|
||||||
@@ -217,11 +244,14 @@ def write_sample(
|
|||||||
# Open new segment if needed
|
# Open new segment if needed
|
||||||
segment_opened = False
|
segment_opened = False
|
||||||
if should_open:
|
if should_open:
|
||||||
now = clock.utcnow()
|
open_segment(conn, observed_at, identity, identity_key, identity_degraded)
|
||||||
open_segment(conn, now, identity, identity_key, identity_degraded)
|
|
||||||
segment_opened = True
|
segment_opened = True
|
||||||
|
|
||||||
# Insert sample
|
# Get current segment_id for provenance
|
||||||
|
current_segment = find_current_segment(conn)
|
||||||
|
segment_id = current_segment["id"] if current_segment else None
|
||||||
|
|
||||||
|
# Insert sample with segment_id
|
||||||
cursor = conn.execute(
|
cursor = conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO samples (
|
INSERT INTO samples (
|
||||||
@@ -229,8 +259,8 @@ def write_sample(
|
|||||||
percentage_used, available_spare, media_errors, power_on_hours,
|
percentage_used, available_spare, media_errors, power_on_hours,
|
||||||
power_cycles, unsafe_shutdowns, temperature_c,
|
power_cycles, unsafe_shutdowns, temperature_c,
|
||||||
data_units_written, data_units_read, bytes_written, bytes_read,
|
data_units_written, data_units_read, bytes_written, bytes_read,
|
||||||
critical_warning
|
critical_warning, segment_id, local_tz
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
sample["ts"],
|
sample["ts"],
|
||||||
@@ -252,74 +282,302 @@ def write_sample(
|
|||||||
sample["bytes_written"],
|
sample["bytes_written"],
|
||||||
sample["bytes_read"],
|
sample["bytes_read"],
|
||||||
sample["critical_warning"],
|
sample["critical_warning"],
|
||||||
|
segment_id,
|
||||||
|
sample.get("local_tz"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"segment_opened": segment_opened,
|
"segment_opened": segment_opened,
|
||||||
"segment_reason": reason,
|
"segment_reason": reason,
|
||||||
"identity_key": identity_key,
|
"identity_key": identity_key,
|
||||||
"identity_degraded": identity_degraded,
|
"identity_degraded": identity_degraded,
|
||||||
|
"segment_id": segment_id,
|
||||||
|
"sample_id": cursor.lastrowid,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def run_collection(
|
def _observation_time(sample: Dict[str, Any]) -> datetime:
|
||||||
smartctl_data: Dict[str, Any],
|
"""Read an observation's original timestamp for ordered recovery."""
|
||||||
sysfs_path: Path,
|
observed_at = datetime.fromisoformat(sample["ts"])
|
||||||
config: Dict[str, Any],
|
if observed_at.tzinfo is None:
|
||||||
clock,
|
return observed_at.replace(tzinfo=timezone.utc)
|
||||||
) -> Dict[str, Any]:
|
return observed_at.astimezone(timezone.utc)
|
||||||
"""Run one collection run.
|
|
||||||
|
|
||||||
This is the main entry point for the collector.
|
def _publish_observation(
|
||||||
Returns the run outcome.
|
conn: sqlite3.Connection,
|
||||||
"""
|
sample: Dict[str, Any],
|
||||||
|
identity: Dict[str, Any],
|
||||||
|
tz_name: str,
|
||||||
|
) -> None:
|
||||||
|
"""Publish one staged sample and all dependent evidence in caller transaction."""
|
||||||
|
observed_at = _observation_time(sample)
|
||||||
|
sample["local_tz"] = tz_name
|
||||||
|
validate_sample_invariants(sample, conn)
|
||||||
|
ensure_period_open(conn, observed_at)
|
||||||
|
seg_info = write_sample(sample, identity, conn, observed_at)
|
||||||
|
|
||||||
|
current_id = seg_info["sample_id"]
|
||||||
|
prev = find_previous_sample(conn, seg_info.get("segment_id"), current_id)
|
||||||
try:
|
try:
|
||||||
# Acquire counters and thermal evidence
|
if prev is not None:
|
||||||
counters = acquire_from_smartctl(smartctl_data)
|
current = {
|
||||||
|
"id": current_id,
|
||||||
# Acquire controller identity
|
"ts": sample["ts"],
|
||||||
identity = acquire_from_sysfs(sysfs_path)
|
"bytes_written": sample["bytes_written"],
|
||||||
|
"bytes_read": sample["bytes_read"],
|
||||||
# Build sample with injected clock
|
"power_on_hours": sample["power_on_hours"],
|
||||||
sample = {
|
"temperature_c": sample["temperature_c"],
|
||||||
"ts": clock.utcnow().isoformat(),
|
"data_units_written": sample["data_units_written"],
|
||||||
"device": config["device"],
|
"data_units_read": sample["data_units_read"],
|
||||||
**counters,
|
"local_tz": tz_name,
|
||||||
**identity,
|
}
|
||||||
}
|
derive_hours_from_interval(conn, prev, current)
|
||||||
|
from .local_day import record_local_activity_interval
|
||||||
# Initialize store if needed
|
record_local_activity_interval(
|
||||||
|
conn,
|
||||||
|
prev,
|
||||||
|
current,
|
||||||
|
start_sample_id=prev["id"],
|
||||||
|
end_sample_id=current_id,
|
||||||
|
segment_id=seg_info.get("segment_id"),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
previous_any_segment = find_previous_sample(conn, None, current_id)
|
||||||
|
if previous_any_segment is not None:
|
||||||
|
from .local_day import mark_local_activity_gap
|
||||||
|
mark_local_activity_gap(
|
||||||
|
conn,
|
||||||
|
sample["ts"],
|
||||||
|
tz_name,
|
||||||
|
previous_any_segment,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .day_aggregate import derive_all_days, persist_day_aggregate
|
||||||
|
for aggregate in derive_all_days(conn):
|
||||||
|
persist_day_aggregate(conn, aggregate)
|
||||||
|
|
||||||
|
from .local_day import derive_local_day_summary, persist_local_day
|
||||||
|
local_summary = derive_local_day_summary(conn, tz_name, observed_at)
|
||||||
|
if local_summary is not None:
|
||||||
|
persist_local_day(conn, local_summary)
|
||||||
|
except Exception as exc: # noqa: BLE001 - preserve valid staged evidence for retry.
|
||||||
|
raise _PendingDerivationError(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _pending_count(conn: sqlite3.Connection) -> int:
|
||||||
|
return conn.execute("SELECT COUNT(*) FROM pending_publications").fetchone()[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _stage_observation(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
sample: Dict[str, Any],
|
||||||
|
identity: Dict[str, Any],
|
||||||
|
tz_name: str,
|
||||||
|
) -> int:
|
||||||
|
"""Durably stage valid acquired evidence before attempting publication."""
|
||||||
|
payload = json.dumps(
|
||||||
|
{"sample": sample, "identity": identity, "tz_name": tz_name},
|
||||||
|
separators=(",", ":"),
|
||||||
|
sort_keys=True,
|
||||||
|
)
|
||||||
|
cursor = conn.execute(
|
||||||
|
"INSERT INTO pending_publications (sample_ts, payload) VALUES (?, ?)",
|
||||||
|
(sample["ts"], payload),
|
||||||
|
)
|
||||||
|
return cursor.lastrowid
|
||||||
|
|
||||||
|
|
||||||
|
def _recover_pending(conn: sqlite3.Connection) -> int:
|
||||||
|
"""Publish pending observations oldest first; stop at first failure."""
|
||||||
|
recovered = 0
|
||||||
|
while True:
|
||||||
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
|
try:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT id, payload FROM pending_publications ORDER BY id LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
conn.rollback()
|
||||||
|
return recovered
|
||||||
|
|
||||||
|
pending_id, payload = row
|
||||||
|
observation = json.loads(payload)
|
||||||
|
try:
|
||||||
|
_publish_observation(
|
||||||
|
conn,
|
||||||
|
observation["sample"],
|
||||||
|
observation["identity"],
|
||||||
|
observation["tz_name"],
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
raise _PendingRecoveryFailure(exc, recovered) from exc
|
||||||
|
conn.execute("DELETE FROM pending_publications WHERE id = ?", (pending_id,))
|
||||||
|
conn.commit()
|
||||||
|
recovered += 1
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _is_store_or_invariant_failure(exc: Exception) -> bool:
|
||||||
|
return (
|
||||||
|
isinstance(exc, sqlite3.Error)
|
||||||
|
and not isinstance(exc, sqlite3.IntegrityError)
|
||||||
|
) or isinstance(exc, InvariantViolationError)
|
||||||
|
|
||||||
|
|
||||||
|
def _pending_capacity_error(
|
||||||
|
reason: str,
|
||||||
|
*,
|
||||||
|
acquisition_skipped: bool = False,
|
||||||
|
) -> RuntimeError:
|
||||||
|
outcome = "; no new observation acquired" if acquisition_skipped else ""
|
||||||
|
return RuntimeError(
|
||||||
|
"pending publication capacity full "
|
||||||
|
f"({PENDING_PUBLICATION_LIMIT} observations){outcome}; "
|
||||||
|
f"{reason}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _recover_pending_for_collection(conn: sqlite3.Connection) -> int:
|
||||||
|
"""Retry queued work and report exhausted capacity without masking store faults."""
|
||||||
|
try:
|
||||||
|
return _recover_pending(conn)
|
||||||
|
except Exception as exc: # noqa: BLE001 - preserve pending evidence on recovery errors.
|
||||||
|
cause = exc.cause if isinstance(exc, _PendingRecoveryFailure) else exc
|
||||||
|
if _is_store_or_invariant_failure(cause):
|
||||||
|
raise cause
|
||||||
|
try:
|
||||||
|
capacity_full = _pending_count(conn) >= PENDING_PUBLICATION_LIMIT
|
||||||
|
except sqlite3.Error:
|
||||||
|
raise cause
|
||||||
|
if capacity_full:
|
||||||
|
raise _pending_capacity_error(f"recovery failed: {cause}") from cause
|
||||||
|
raise cause
|
||||||
|
|
||||||
|
|
||||||
|
def _recover_pending_for_admission(conn: sqlite3.Connection) -> int:
|
||||||
|
"""Recover in order, then admit one observation if bounded space remains."""
|
||||||
|
try:
|
||||||
|
recovered = _recover_pending(conn)
|
||||||
|
except _PendingRecoveryFailure as failure:
|
||||||
|
if (
|
||||||
|
not failure.derivation_error
|
||||||
|
or _is_store_or_invariant_failure(failure.cause)
|
||||||
|
):
|
||||||
|
raise failure.cause
|
||||||
|
try:
|
||||||
|
pending_count = _pending_count(conn)
|
||||||
|
except sqlite3.Error:
|
||||||
|
raise failure.cause
|
||||||
|
if pending_count >= PENDING_PUBLICATION_LIMIT:
|
||||||
|
raise _pending_capacity_error(
|
||||||
|
f"recovery failed: {failure.cause}",
|
||||||
|
acquisition_skipped=True,
|
||||||
|
) from failure.cause
|
||||||
|
return failure.published_count
|
||||||
|
|
||||||
|
if _pending_count(conn) >= PENDING_PUBLICATION_LIMIT:
|
||||||
|
raise _pending_capacity_error(
|
||||||
|
"recovery left the queue full",
|
||||||
|
acquisition_skipped=True,
|
||||||
|
)
|
||||||
|
return recovered
|
||||||
|
|
||||||
|
|
||||||
|
def run_collection(
|
||||||
|
smartctl_data: Optional[Dict[str, Any]] = None,
|
||||||
|
sysfs_path: Optional[Path] = None,
|
||||||
|
config: Optional[Dict[str, Any]] = None,
|
||||||
|
clock=None,
|
||||||
|
*,
|
||||||
|
acquire: Optional[Callable[[], tuple[Dict[str, Any], Path]]] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Recover old work, acquire one observation, then publish it atomically.
|
||||||
|
|
||||||
|
Production callers pass ``acquire`` so recovery and capacity checks run
|
||||||
|
before device interrogation. Direct sample arguments remain useful for
|
||||||
|
deterministic collector tests.
|
||||||
|
"""
|
||||||
|
conn = None
|
||||||
|
try:
|
||||||
|
if config is None or clock is None:
|
||||||
|
raise ValueError("config and clock are required")
|
||||||
|
|
||||||
store_path = get_store_path(config)
|
store_path = get_store_path(config)
|
||||||
conn = init_store(store_path)
|
conn = init_store(store_path)
|
||||||
|
|
||||||
# Run legacy import if needed (idempotent)
|
|
||||||
from .legacy import import_legacy_history
|
from .legacy import import_legacy_history
|
||||||
history_path = Path(config.get("data_dir", ".")) / "history.jsonl"
|
history_path = Path(config.get("data_dir", ".")) / "history.jsonl"
|
||||||
if history_path.exists():
|
if history_path.exists():
|
||||||
import_legacy_history(conn, history_path, clock=clock)
|
import_legacy_history(conn, history_path, clock=clock)
|
||||||
|
|
||||||
|
published_count = _recover_pending_for_admission(conn)
|
||||||
try:
|
checked_pending_count = _pending_count(conn)
|
||||||
# Validate invariants
|
|
||||||
validate_sample_invariants(sample, conn)
|
# Hold the writer reservation across the capacity check and acquisition.
|
||||||
|
# Recheck recovery if another collector appended work during preflight.
|
||||||
# Write sample
|
while True:
|
||||||
write_sample(sample, identity, conn, clock)
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
|
waiting = _pending_count(conn)
|
||||||
return {
|
if (
|
||||||
"ok": True,
|
waiting >= PENDING_PUBLICATION_LIMIT
|
||||||
"sample_count": 1,
|
or waiting > checked_pending_count
|
||||||
"store_path": str(store_path),
|
):
|
||||||
|
conn.rollback()
|
||||||
|
published_count += _recover_pending_for_admission(conn)
|
||||||
|
checked_pending_count = _pending_count(conn)
|
||||||
|
continue
|
||||||
|
|
||||||
|
from .tz_util import detect_system_tz
|
||||||
|
tz_name = detect_system_tz()
|
||||||
|
if acquire is not None:
|
||||||
|
smartctl_data, sysfs_path = acquire()
|
||||||
|
if smartctl_data is None or sysfs_path is None:
|
||||||
|
raise AcquisitionError("No acquired SMART data or sysfs identity")
|
||||||
|
|
||||||
|
counters = acquire_from_smartctl(smartctl_data)
|
||||||
|
identity = acquire_from_sysfs(sysfs_path)
|
||||||
|
sample = {
|
||||||
|
"ts": clock.utcnow().isoformat(),
|
||||||
|
"device": config["device"],
|
||||||
|
**counters,
|
||||||
|
**identity,
|
||||||
}
|
}
|
||||||
finally:
|
validate_sample_invariants(sample, conn)
|
||||||
conn.close()
|
_stage_observation(conn, sample, identity, tz_name)
|
||||||
|
conn.commit()
|
||||||
except (AcquisitionError, InvariantViolationError) as e:
|
break
|
||||||
|
|
||||||
|
published_count += _recover_pending_for_collection(conn)
|
||||||
|
retention_error = None
|
||||||
|
try:
|
||||||
|
if _pending_count(conn) == 0:
|
||||||
|
from .pruning import prune_old_samples
|
||||||
|
|
||||||
|
prune_old_samples(conn, clock.utcnow())
|
||||||
|
except Exception as exc: # noqa: BLE001 - publication already committed.
|
||||||
|
# Publication already committed. Keep collection successful and
|
||||||
|
# retry atomic retention on the next normal collection.
|
||||||
|
conn.rollback()
|
||||||
|
retention_error = str(exc)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"sample_count": published_count,
|
||||||
|
"retention_error": retention_error,
|
||||||
|
"store_path": str(store_path),
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
if conn is not None:
|
||||||
|
conn.rollback()
|
||||||
return {
|
return {
|
||||||
"ok": False,
|
"ok": False,
|
||||||
"error": str(e),
|
"error": str(exc),
|
||||||
"error_type": type(e).__name__,
|
"error_type": type(exc).__name__,
|
||||||
}
|
}
|
||||||
|
finally:
|
||||||
|
if conn is not None:
|
||||||
|
conn.close()
|
||||||
|
|||||||
@@ -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:
|
if agg is not None:
|
||||||
results.append(agg)
|
results.append(agg)
|
||||||
return results
|
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]),
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
"""Interval derivation: samples → hour observations → day aggregates.
|
||||||
|
|
||||||
|
After each collection run, the collector calls into this module to:
|
||||||
|
1. Find the previous sample in the same segment
|
||||||
|
2. Compute deltas (bytes, POH, temperature)
|
||||||
|
3. Classify the hour(s) the interval spans
|
||||||
|
4. Write/update hour_observations for each affected hour
|
||||||
|
5. Update day_aggregates with unattributed cross-hour bytes
|
||||||
|
|
||||||
|
Cross-hour deltas are retained once with unknown shares explicit (issue #73 AC4).
|
||||||
|
No proportional allocation, endpoint assignment, or double counting.
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from .hour_classify import classify_hour, HourSplit
|
||||||
|
|
||||||
|
|
||||||
|
def find_previous_sample(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
segment_id: Optional[int],
|
||||||
|
current_sample_id: int,
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Find the most recent sample before current_sample_id in the same segment.
|
||||||
|
|
||||||
|
Returns None if no previous sample exists (first sample in segment).
|
||||||
|
"""
|
||||||
|
if segment_id is not None:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"SELECT id, ts, bytes_written, bytes_read, power_on_hours, "
|
||||||
|
" temperature_c, data_units_written, data_units_read, local_tz "
|
||||||
|
"FROM samples WHERE id < ? AND segment_id = ? "
|
||||||
|
"ORDER BY id DESC LIMIT 1",
|
||||||
|
(current_sample_id, segment_id),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"SELECT id, ts, bytes_written, bytes_read, power_on_hours, "
|
||||||
|
" temperature_c, data_units_written, data_units_read, local_tz "
|
||||||
|
"FROM samples WHERE id < ? "
|
||||||
|
"ORDER BY id DESC LIMIT 1",
|
||||||
|
(current_sample_id,),
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"id": row[0], "ts": row[1], "bytes_written": row[2],
|
||||||
|
"bytes_read": row[3], "power_on_hours": row[4],
|
||||||
|
"temperature_c": row[5], "data_units_written": row[6],
|
||||||
|
"data_units_read": row[7], "local_tz": row[8],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_ts(ts: str) -> datetime:
|
||||||
|
"""Parse ISO timestamp to datetime with UTC."""
|
||||||
|
dt = datetime.fromisoformat(ts)
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt
|
||||||
|
|
||||||
|
|
||||||
|
def _hour_floor(dt: datetime) -> datetime:
|
||||||
|
"""Floor a datetime to its UTC hour boundary."""
|
||||||
|
return dt.replace(minute=0, second=0, microsecond=0)
|
||||||
|
|
||||||
|
|
||||||
|
def _hours_spanned(start: datetime, end: datetime) -> List[datetime]:
|
||||||
|
"""Return list of UTC hour boundaries spanned by [start, end)."""
|
||||||
|
hours = []
|
||||||
|
h = _hour_floor(start)
|
||||||
|
while h < end:
|
||||||
|
hours.append(h)
|
||||||
|
h += timedelta(hours=1)
|
||||||
|
return hours
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_sampled_seconds_in_hour(
|
||||||
|
start: datetime, end: datetime, hour_start: datetime
|
||||||
|
) -> int:
|
||||||
|
"""How many seconds of the sample interval fall within this hour."""
|
||||||
|
hour_end = hour_start + timedelta(hours=1)
|
||||||
|
effective_start = max(start, hour_start)
|
||||||
|
effective_end = min(end, hour_end)
|
||||||
|
if effective_start >= effective_end:
|
||||||
|
return 0
|
||||||
|
return int((effective_end - effective_start).total_seconds())
|
||||||
|
|
||||||
|
|
||||||
|
def derive_hours_from_interval(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
prev_sample: Dict[str, Any],
|
||||||
|
next_sample: Dict[str, Any],
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Derive hour observations from a sample pair interval.
|
||||||
|
|
||||||
|
Returns list of hour observation dicts that were written/updated.
|
||||||
|
Caller owns the transaction.
|
||||||
|
"""
|
||||||
|
prev_ts = _parse_ts(prev_sample["ts"])
|
||||||
|
next_ts = _parse_ts(next_sample["ts"])
|
||||||
|
|
||||||
|
# Deltas
|
||||||
|
bw_delta = max(0, next_sample["bytes_written"] - prev_sample["bytes_written"])
|
||||||
|
br_delta = max(0, next_sample["bytes_read"] - prev_sample["bytes_read"])
|
||||||
|
poh_delta_s = max(0, (next_sample["power_on_hours"] - prev_sample["power_on_hours"])) * 3600
|
||||||
|
|
||||||
|
hours = _hours_spanned(prev_ts, next_ts)
|
||||||
|
total_span_s = int((next_ts - prev_ts).total_seconds())
|
||||||
|
|
||||||
|
results = []
|
||||||
|
|
||||||
|
if len(hours) == 1:
|
||||||
|
# Same-hour interval: fully attributed to this hour
|
||||||
|
hour_key = hours[0].strftime("%Y-%m-%dT%H:00:00+00:00")
|
||||||
|
sampled_s = total_span_s
|
||||||
|
|
||||||
|
# Classify hour
|
||||||
|
split = classify_hour(
|
||||||
|
wall_clock_seconds=3600,
|
||||||
|
poh_delta=poh_delta_s,
|
||||||
|
duw_delta=bw_delta,
|
||||||
|
dur_delta=br_delta,
|
||||||
|
sampled_seconds=sampled_s,
|
||||||
|
)
|
||||||
|
|
||||||
|
_upsert_hour_observation(
|
||||||
|
conn, hour_key, split,
|
||||||
|
bw_delta, br_delta,
|
||||||
|
prev_sample.get("temperature_c"), next_sample.get("temperature_c"),
|
||||||
|
2, # 2 samples contributed (prev + next)
|
||||||
|
)
|
||||||
|
results.append({"hour": hour_key, "bytes_written": bw_delta, "attributed": True})
|
||||||
|
|
||||||
|
elif len(hours) >= 2:
|
||||||
|
# Cross-hour interval: split wall-clock time, bytes unattributed
|
||||||
|
for h in hours:
|
||||||
|
hour_key = h.strftime("%Y-%m-%dT%H:00:00+00:00")
|
||||||
|
sampled_s = _compute_sampled_seconds_in_hour(prev_ts, next_ts, h)
|
||||||
|
|
||||||
|
# For cross-hour, we classify based on time only (no byte attribution)
|
||||||
|
# The hour gets its time split but NOT the byte delta
|
||||||
|
split = classify_hour(
|
||||||
|
wall_clock_seconds=3600,
|
||||||
|
poh_delta=0, # POH attribution unknown for cross-hour
|
||||||
|
duw_delta=0, # Bytes unattributed
|
||||||
|
dur_delta=0,
|
||||||
|
sampled_seconds=sampled_s,
|
||||||
|
)
|
||||||
|
|
||||||
|
_upsert_hour_observation(
|
||||||
|
conn, hour_key, split,
|
||||||
|
0, 0, # No byte attribution for cross-hour
|
||||||
|
None, None,
|
||||||
|
0, # No sample falls IN this hour
|
||||||
|
)
|
||||||
|
results.append({"hour": hour_key, "bytes_written": 0, "attributed": False})
|
||||||
|
|
||||||
|
# Track unattributed bytes at day level
|
||||||
|
_add_unattributed_bytes(conn, prev_ts, next_ts, bw_delta, br_delta)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _upsert_hour_observation(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
hour_key: str,
|
||||||
|
split: HourSplit,
|
||||||
|
bw_delta: int,
|
||||||
|
br_delta: int,
|
||||||
|
temp_min: Optional[int],
|
||||||
|
temp_max: Optional[int],
|
||||||
|
sample_count: int,
|
||||||
|
) -> None:
|
||||||
|
"""Insert or update an hour observation."""
|
||||||
|
# Check if hour exists
|
||||||
|
existing = conn.execute(
|
||||||
|
"SELECT id, bytes_written_delta, bytes_read_delta, sample_count "
|
||||||
|
"FROM hour_observations WHERE hour = ?",
|
||||||
|
(hour_key,),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if existing is None:
|
||||||
|
temp_avg = ((temp_min or 0) + (temp_max or 0)) / 2 if temp_min is not None else None
|
||||||
|
coverage = (split.seconds_active + split.seconds_idle + split.seconds_powered_off) / 3600.0
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO hour_observations
|
||||||
|
(hour, active_seconds, idle_seconds, powered_off_seconds, unknown_seconds,
|
||||||
|
bytes_written_delta, bytes_read_delta,
|
||||||
|
temperature_min, temperature_avg, temperature_max,
|
||||||
|
sample_count, coverage)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(hour_key, split.seconds_active, split.seconds_idle,
|
||||||
|
split.seconds_powered_off, split.seconds_unknown,
|
||||||
|
bw_delta, br_delta,
|
||||||
|
temp_min, temp_avg, temp_max,
|
||||||
|
sample_count, coverage),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Merge: accumulate bytes and sample count
|
||||||
|
new_bw = existing[1] + bw_delta
|
||||||
|
new_br = existing[2] + br_delta
|
||||||
|
new_samples = existing[3] + sample_count
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE hour_observations "
|
||||||
|
"SET bytes_written_delta = ?, bytes_read_delta = ?, sample_count = ? "
|
||||||
|
"WHERE id = ?",
|
||||||
|
(new_bw, new_br, new_samples, existing[0]),
|
||||||
|
)
|
||||||
|
def _add_unattributed_bytes(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
prev_ts: datetime,
|
||||||
|
next_ts: datetime,
|
||||||
|
bw_delta: int,
|
||||||
|
br_delta: int,
|
||||||
|
) -> None:
|
||||||
|
"""Store unattributed byte deltas once as shared boundary evidence.
|
||||||
|
|
||||||
|
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),
|
||||||
|
)
|
||||||
@@ -0,0 +1,500 @@
|
|||||||
|
"""Init system abstraction for Fenris monitoring (issue #84).
|
||||||
|
|
||||||
|
Detects the active init system (systemd or runit) and provides a unified
|
||||||
|
interface for timer/collection control and service-state queries. This keeps
|
||||||
|
the privileged helper and status composition init-system agnostic without
|
||||||
|
introducing a generalized plugin framework.
|
||||||
|
|
||||||
|
Design: ADR 0008 — keep service-specific operations behind a cohesive
|
||||||
|
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 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 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)
|
||||||
|
- Serialized scheduled runs (runsv does not restart until exit)
|
||||||
|
- Serialized on-demand (flock serializes fenris-collect execution)
|
||||||
|
|
||||||
|
Spec: §8.4, §8.5, §8.6, §8.7, §8.8, ADR 0008
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from enum import Enum
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Init system detection
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class InitSystem(Enum):
|
||||||
|
SYSTEMD = "systemd"
|
||||||
|
RUNIT = "runit"
|
||||||
|
|
||||||
|
|
||||||
|
FENRIS_SV_DIR = Path("/etc/sv/fenris-collect")
|
||||||
|
FENRIS_SERVICE_LINK = Path("/var/service/fenris-collect")
|
||||||
|
FENRIS_LOG_DIR = Path("/var/log/fenris-collect")
|
||||||
|
COLLECT_TIMEOUT_S = 90
|
||||||
|
|
||||||
|
|
||||||
|
def detect_init_system() -> InitSystem:
|
||||||
|
"""Detect the active init system.
|
||||||
|
|
||||||
|
Checks for systemd first (PID 1 is systemd or /run/systemd/system exists),
|
||||||
|
then falls back to runit (PID 1 is runsv or /etc/sv exists).
|
||||||
|
"""
|
||||||
|
# systemd detection: /run/systemd/system exists when systemd is PID 1
|
||||||
|
if Path("/run/systemd/system").exists():
|
||||||
|
return InitSystem.SYSTEMD
|
||||||
|
|
||||||
|
# Check PID 1 name
|
||||||
|
try:
|
||||||
|
pid1_comm = Path("/proc/1/comm").read_text().strip()
|
||||||
|
if pid1_comm == "systemd":
|
||||||
|
return InitSystem.SYSTEMD
|
||||||
|
if pid1_comm in ("runsv", "runsvdir"):
|
||||||
|
return InitSystem.RUNIT
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Fallback: check for /etc/sv (Void Linux default)
|
||||||
|
if Path("/etc/sv").is_dir():
|
||||||
|
return InitSystem.RUNIT
|
||||||
|
|
||||||
|
# Default to systemd (existing behavior)
|
||||||
|
return InitSystem.SYSTEMD
|
||||||
|
|
||||||
|
|
||||||
|
def get_init_system() -> InitSystem:
|
||||||
|
"""Get the detected init system (cached)."""
|
||||||
|
if not hasattr(get_init_system, "_cached"):
|
||||||
|
get_init_system._cached = detect_init_system()
|
||||||
|
return get_init_system._cached
|
||||||
|
|
||||||
|
|
||||||
|
def reset_init_system_cache() -> None:
|
||||||
|
"""Reset the cached init system detection (for testing)."""
|
||||||
|
if hasattr(get_init_system, "_cached"):
|
||||||
|
delattr(get_init_system, "_cached")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# systemd backend
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _systemd_enable(now: bool) -> None:
|
||||||
|
"""Enable and optionally start the systemd timer."""
|
||||||
|
cmd = ["systemctl", "enable"]
|
||||||
|
if now:
|
||||||
|
cmd.append("--now")
|
||||||
|
cmd.append("fenris-collect.timer")
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
if result.returncode != 0:
|
||||||
|
print("Error enabling timer:", result.stderr, file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
print("Timer enabled" + (" and started" if now else ""))
|
||||||
|
|
||||||
|
|
||||||
|
def _systemd_disable(now: bool) -> None:
|
||||||
|
"""Disable and optionally stop the systemd timer."""
|
||||||
|
cmd = ["systemctl", "disable"]
|
||||||
|
if now:
|
||||||
|
cmd.append("--now")
|
||||||
|
cmd.append("fenris-collect.timer")
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
if result.returncode != 0:
|
||||||
|
print("Error disabling timer:", result.stderr, file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
print("Timer disabled" + (" and stopped" if now else ""))
|
||||||
|
|
||||||
|
|
||||||
|
def _systemd_collect() -> None:
|
||||||
|
"""Trigger on-demand collection via systemd (blocking)."""
|
||||||
|
result = subprocess.run(
|
||||||
|
["systemctl", "start", "fenris-collect.service"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
print("Collection completed successfully")
|
||||||
|
else:
|
||||||
|
print("Collection failed:", result.stderr, file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _systemctl_show(unit: str, *properties: str) -> Dict[str, str]:
|
||||||
|
"""Query systemctl show for specific properties."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["systemctl", "show", unit, "--property=" + ",".join(properties)],
|
||||||
|
capture_output=True, text=True, timeout=5,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
return {}
|
||||||
|
out = {}
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
if "=" in line:
|
||||||
|
key, _, value = line.partition("=")
|
||||||
|
out[key.strip()] = value.strip()
|
||||||
|
return out
|
||||||
|
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _systemd_query_state() -> Dict[str, Any]:
|
||||||
|
"""Query systemd for the four separate service facts."""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
timer_props = _systemctl_show(
|
||||||
|
"fenris-collect.timer",
|
||||||
|
"UnitFileState", "ActiveState", "LastTriggerUSec",
|
||||||
|
)
|
||||||
|
service_props = _systemctl_show(
|
||||||
|
"fenris-collect.service",
|
||||||
|
"ActiveState", "ExecMainStatus", "ExecMainExitTimestamp",
|
||||||
|
)
|
||||||
|
|
||||||
|
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")
|
||||||
|
timer_active = active_state == "active" if active_state else None
|
||||||
|
|
||||||
|
last_collect_ok = None
|
||||||
|
last_collect_age_s = None
|
||||||
|
last_collect_reason = None
|
||||||
|
|
||||||
|
last_trigger = timer_props.get("LastTriggerUSec", "")
|
||||||
|
if last_trigger and last_trigger != "n/a":
|
||||||
|
try:
|
||||||
|
trigger_dt = datetime.fromisoformat(last_trigger.replace("Z", "+00:00"))
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
last_collect_age_s = int((now - trigger_dt).total_seconds())
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
exec_status = service_props.get("ExecMainStatus", "")
|
||||||
|
if exec_status:
|
||||||
|
try:
|
||||||
|
exit_code = int(exec_status)
|
||||||
|
last_collect_ok = exit_code == 0
|
||||||
|
if exit_code != 0:
|
||||||
|
last_collect_reason = "exit code %d" % exit_code
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
"boot_enabled": boot_enabled,
|
||||||
|
"timer_active": timer_active,
|
||||||
|
"last_collect_ok": last_collect_ok,
|
||||||
|
"last_collect_age_s": last_collect_age_s,
|
||||||
|
"last_collect_reason": last_collect_reason,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _systemd_journal_hint(lines: int = 5) -> Optional[str]:
|
||||||
|
"""Get the last N journal lines for fenris-collect.service."""
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["journalctl", "-u", "fenris-collect.service",
|
||||||
|
"--no-pager", "-n", str(lines), "--output=short-iso"],
|
||||||
|
capture_output=True, text=True, timeout=5,
|
||||||
|
)
|
||||||
|
if result.returncode != 0 or not result.stdout.strip():
|
||||||
|
return None
|
||||||
|
return result.stdout.strip()
|
||||||
|
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# runit backend
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _runit_enable(_now: bool) -> None:
|
||||||
|
"""Enable the runit service by creating a symlink.
|
||||||
|
|
||||||
|
runit activates the service immediately when the symlink appears.
|
||||||
|
If the service is already enabled but stopped (e.g., via `sv stop`),
|
||||||
|
restart it when `now=True`.
|
||||||
|
"""
|
||||||
|
if FENRIS_SERVICE_LINK.exists():
|
||||||
|
# Already enabled — check if we need to restart
|
||||||
|
if _now and not _runit_is_running():
|
||||||
|
# Service is stopped but enabled — restart via sv
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
["sv", "restart", "fenris-collect"],
|
||||||
|
capture_output=True, text=True, timeout=5,
|
||||||
|
)
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
|
pass
|
||||||
|
print("Service already enabled (idempotent)")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Remove the 'down' file if present (dormant install marker)
|
||||||
|
down_file = FENRIS_SV_DIR / "down"
|
||||||
|
if down_file.exists():
|
||||||
|
down_file.unlink()
|
||||||
|
|
||||||
|
FENRIS_SERVICE_LINK.symlink_to(FENRIS_SV_DIR)
|
||||||
|
print("Service enabled")
|
||||||
|
|
||||||
|
|
||||||
|
def _runit_disable(_now: bool) -> None:
|
||||||
|
"""Disable the runit service by removing the symlink.
|
||||||
|
|
||||||
|
runit stops the service immediately when the symlink is removed.
|
||||||
|
"""
|
||||||
|
if not FENRIS_SERVICE_LINK.exists():
|
||||||
|
print("Service already disabled (idempotent)")
|
||||||
|
return
|
||||||
|
|
||||||
|
FENRIS_SERVICE_LINK.unlink()
|
||||||
|
# Place 'down' file to mark as intentionally disabled
|
||||||
|
(FENRIS_SV_DIR / "down").touch()
|
||||||
|
print("Service disabled")
|
||||||
|
|
||||||
|
|
||||||
|
def _runit_collect() -> None:
|
||||||
|
"""Trigger on-demand collection via direct execution with flock.
|
||||||
|
|
||||||
|
Serializes against the scheduler using the same lock file.
|
||||||
|
The timeout(1) command enforces bounded execution.
|
||||||
|
"""
|
||||||
|
lock_path = Path("/var/lib/fenris/fenris-collect.lock")
|
||||||
|
collect_script = Path("/usr/libexec/fenris/fenris-collect")
|
||||||
|
fallback_script = Path(__file__).parent.parent.parent / "src" / "fenris" / "collect.py"
|
||||||
|
|
||||||
|
if collect_script.exists():
|
||||||
|
script = str(collect_script)
|
||||||
|
elif fallback_script.exists():
|
||||||
|
script = str(fallback_script)
|
||||||
|
else:
|
||||||
|
print("Error: fenris-collect script not found", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["flock", "--nonblock", str(lock_path),
|
||||||
|
"timeout", str(COLLECT_TIMEOUT_S), "nice", "ionice", "-c3",
|
||||||
|
sys.executable, script],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
print("Collection completed successfully")
|
||||||
|
elif result.returncode == 124:
|
||||||
|
print("Collection timed out after %ds" % COLLECT_TIMEOUT_S, file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
# exit code 1 from flock means lock is held (scheduled run in progress)
|
||||||
|
if result.returncode == 1 and "Resource temporarily unavailable" in result.stderr:
|
||||||
|
print("Collection already in progress (serialized)", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
print("Collection failed:", result.stderr, file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
except FileNotFoundError:
|
||||||
|
print("Error: flock/timeout not found", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def _runit_is_enabled() -> bool:
|
||||||
|
"""Check if the runit service is enabled (symlink exists)."""
|
||||||
|
return FENRIS_SERVICE_LINK.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def _runit_is_running() -> bool:
|
||||||
|
"""Check if the runit service is currently running.
|
||||||
|
|
||||||
|
Looks for a 'supervise/pid' file in the service directory. Void creates
|
||||||
|
that directory root-only, so unprivileged dashboard reads fall back to
|
||||||
|
the public process table when they cannot traverse it.
|
||||||
|
"""
|
||||||
|
def runsv_process_exists() -> bool:
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["pgrep", "-f", "^runsv fenris-collect$"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
return result.returncode == 0
|
||||||
|
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
pid_file = FENRIS_SV_DIR / "supervise" / "pid"
|
||||||
|
# On Void, Path.exists() is false for an unprivileged process when it
|
||||||
|
# cannot traverse runit's root-only supervise directory.
|
||||||
|
if not pid_file.exists():
|
||||||
|
return FENRIS_SERVICE_LINK.exists() and runsv_process_exists()
|
||||||
|
try:
|
||||||
|
pid = int(pid_file.read_text().strip())
|
||||||
|
# Check if the process is alive
|
||||||
|
os.kill(pid, 0)
|
||||||
|
return True
|
||||||
|
except PermissionError:
|
||||||
|
# runsv's supervisor state is root-only on Void. Its process command
|
||||||
|
# is still observable, which gives the read-only UI the same runtime
|
||||||
|
# fact without granting it service-control permissions.
|
||||||
|
return FENRIS_SERVICE_LINK.exists() and runsv_process_exists()
|
||||||
|
except (ValueError, OSError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _runit_query_state() -> Dict[str, Any]:
|
||||||
|
"""Query runit for the four separate service facts.
|
||||||
|
|
||||||
|
Checks: boot_enabled (symlink), timer_active (running), last_collect
|
||||||
|
(store-based), freshness (store-based).
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
boot_enabled = _runit_is_enabled()
|
||||||
|
timer_active = _runit_is_running()
|
||||||
|
|
||||||
|
last_collect_ok = None
|
||||||
|
last_collect_age_s = None
|
||||||
|
last_collect_reason = None
|
||||||
|
|
||||||
|
# Try to get last collection info from the store
|
||||||
|
store_path = Path("/var/lib/fenris/observations.db")
|
||||||
|
if store_path.exists():
|
||||||
|
try:
|
||||||
|
import sqlite3
|
||||||
|
conn = sqlite3.connect("file:%s?mode=ro" % store_path, uri=True)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
# Get newest sample
|
||||||
|
cursor = conn.execute(
|
||||||
|
"SELECT ts FROM samples ORDER BY id DESC LIMIT 1"
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row and row[0]:
|
||||||
|
try:
|
||||||
|
ts = datetime.fromisoformat(row[0])
|
||||||
|
if ts.tzinfo is None:
|
||||||
|
ts = ts.replace(tzinfo=timezone.utc)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
last_collect_age_s = int((now - ts).total_seconds())
|
||||||
|
last_collect_ok = True
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Check for failed collections via monitoring_periods
|
||||||
|
cursor = conn.execute(
|
||||||
|
"SELECT end_cause FROM monitoring_periods "
|
||||||
|
"WHERE ended_at IS NOT NULL ORDER BY ended_at DESC LIMIT 1"
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row and row[0] and row[0] not in ("user_disabled", None):
|
||||||
|
last_collect_reason = row[0]
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Check for timeout/exit failures via supervise exit status
|
||||||
|
if timer_active:
|
||||||
|
exit_file = FENRIS_SV_DIR / "supervise" / "exit"
|
||||||
|
if exit_file.exists():
|
||||||
|
try:
|
||||||
|
exit_code = int(exit_file.read_text().strip())
|
||||||
|
if exit_code != 0:
|
||||||
|
last_collect_ok = False
|
||||||
|
last_collect_reason = "exit code %d" % exit_code
|
||||||
|
except (ValueError, OSError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
"boot_enabled": boot_enabled,
|
||||||
|
"timer_active": timer_active,
|
||||||
|
"last_collect_ok": last_collect_ok,
|
||||||
|
"last_collect_age_s": last_collect_age_s,
|
||||||
|
"last_collect_reason": last_collect_reason,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _runit_journal_hint(lines: int = 5) -> Optional[str]:
|
||||||
|
"""Get the last N log lines for fenris-collect from runit logging."""
|
||||||
|
log_current = FENRIS_LOG_DIR / "current"
|
||||||
|
if not log_current.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
# Use tail to get the last N lines
|
||||||
|
result = subprocess.run(
|
||||||
|
["tail", "-n", str(lines), str(log_current)],
|
||||||
|
capture_output=True, text=True, timeout=5,
|
||||||
|
)
|
||||||
|
if result.returncode != 0 or not result.stdout.strip():
|
||||||
|
return None
|
||||||
|
return result.stdout.strip()
|
||||||
|
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API — unified interface
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def enable_timer(now: bool) -> None:
|
||||||
|
"""Enable the collection timer/service."""
|
||||||
|
init = get_init_system()
|
||||||
|
if init == InitSystem.SYSTEMD:
|
||||||
|
_systemd_enable(now)
|
||||||
|
else:
|
||||||
|
_runit_enable(now)
|
||||||
|
|
||||||
|
|
||||||
|
def disable_timer(now: bool) -> None:
|
||||||
|
"""Disable the collection timer/service."""
|
||||||
|
init = get_init_system()
|
||||||
|
if init == InitSystem.SYSTEMD:
|
||||||
|
_systemd_disable(now)
|
||||||
|
else:
|
||||||
|
_runit_disable(now)
|
||||||
|
|
||||||
|
|
||||||
|
def collect_now() -> None:
|
||||||
|
"""Trigger on-demand collection (blocking)."""
|
||||||
|
init = get_init_system()
|
||||||
|
if init == InitSystem.SYSTEMD:
|
||||||
|
_systemd_collect()
|
||||||
|
else:
|
||||||
|
_runit_collect()
|
||||||
|
|
||||||
|
|
||||||
|
def query_service_state() -> Dict[str, Any]:
|
||||||
|
"""Query the four separate service facts."""
|
||||||
|
init = get_init_system()
|
||||||
|
if init == InitSystem.SYSTEMD:
|
||||||
|
return _systemd_query_state()
|
||||||
|
else:
|
||||||
|
return _runit_query_state()
|
||||||
|
|
||||||
|
|
||||||
|
def journal_hint(lines: int = 5, unit: str = "fenris-collect.service") -> Optional[str]:
|
||||||
|
"""Get journal/log hints for diagnostics.
|
||||||
|
|
||||||
|
The unit parameter is accepted for backward compatibility with callers
|
||||||
|
that pass 'fenris-collect.service'. For runit, the unit parameter is
|
||||||
|
ignored since the log directory is always /var/log/fenris-collect/.
|
||||||
|
"""
|
||||||
|
init = get_init_system()
|
||||||
|
if init == InitSystem.SYSTEMD:
|
||||||
|
return _systemd_journal_hint(lines)
|
||||||
|
else:
|
||||||
|
return _runit_journal_hint(lines)
|
||||||
File diff suppressed because it is too large
Load Diff
+14
-54
@@ -15,9 +15,7 @@ When run as a script, uses the fenris package from the installed wheel.
|
|||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import subprocess
|
|
||||||
import sys
|
import sys
|
||||||
import sqlite3
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -39,6 +37,11 @@ from fenris.monitoring_periods import (
|
|||||||
close_period,
|
close_period,
|
||||||
get_open_period,
|
get_open_period,
|
||||||
)
|
)
|
||||||
|
from fenris.init_system import (
|
||||||
|
enable_timer,
|
||||||
|
disable_timer,
|
||||||
|
collect_now,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_STORE_PATH = Path("/var/lib/fenris/observations.db")
|
DEFAULT_STORE_PATH = Path("/var/lib/fenris/observations.db")
|
||||||
@@ -66,29 +69,13 @@ def cmd_enable(args: argparse.Namespace) -> None:
|
|||||||
open_period = get_open_period(conn)
|
open_period = get_open_period(conn)
|
||||||
if open_period is None:
|
if open_period is None:
|
||||||
ensure_period_open(conn, now)
|
ensure_period_open(conn, now)
|
||||||
|
conn.commit()
|
||||||
print("Monitoring period opened at", now.isoformat())
|
print("Monitoring period opened at", now.isoformat())
|
||||||
else:
|
else:
|
||||||
print("Monitoring period already open (id=%d)" % open_period["id"])
|
print("Monitoring period already open (id=%d)" % open_period["id"])
|
||||||
|
|
||||||
# Enable and start the timer
|
# Enable the timer (init-system aware)
|
||||||
if args.now:
|
enable_timer(args.now)
|
||||||
result = subprocess.run(
|
|
||||||
["systemctl", "enable", "--now", "fenris-collect.timer"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
result = subprocess.run(
|
|
||||||
["systemctl", "enable", "fenris-collect.timer"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
if result.returncode != 0:
|
|
||||||
print("Error enabling timer:", result.stderr, file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
print("Timer enabled" + (" and started" if args.now else ""))
|
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@@ -117,25 +104,8 @@ def cmd_disable(args: argparse.Namespace) -> None:
|
|||||||
else:
|
else:
|
||||||
print("No open monitoring period (no-op)")
|
print("No open monitoring period (no-op)")
|
||||||
|
|
||||||
# Disable and stop the timer
|
# Disable the timer (init-system aware)
|
||||||
if args.now:
|
disable_timer(args.now)
|
||||||
result = subprocess.run(
|
|
||||||
["systemctl", "disable", "--now", "fenris-collect.timer"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
result = subprocess.run(
|
|
||||||
["systemctl", "disable", "fenris-collect.timer"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
if result.returncode != 0:
|
|
||||||
print("Error disabling timer:", result.stderr, file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
print("Timer disabled" + (" and stopped" if args.now else ""))
|
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@@ -143,22 +113,12 @@ def cmd_disable(args: argparse.Namespace) -> None:
|
|||||||
def cmd_collect(args: argparse.Namespace) -> None:
|
def cmd_collect(args: argparse.Namespace) -> None:
|
||||||
"""Trigger on-demand collection.
|
"""Trigger on-demand collection.
|
||||||
|
|
||||||
Starts fenris-collect.service, blocks until exit, reports outcome.
|
Starts fenris-collect, blocks until exit, reports outcome.
|
||||||
|
|
||||||
Spec §8.7: fenris sample routes through fenris-monitor → systemctl start,
|
Spec §8.7: fenris sample routes through fenris-monitor → collect,
|
||||||
which blocks until the oneshot exits; outcome reported synchronously.
|
which blocks until the collection exits; outcome reported synchronously.
|
||||||
"""
|
"""
|
||||||
result = subprocess.run(
|
collect_now()
|
||||||
["systemctl", "start", "fenris-collect.service"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
if result.returncode == 0:
|
|
||||||
print("Collection completed successfully")
|
|
||||||
else:
|
|
||||||
print("Collection failed:", result.stderr, file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_baseline_set(args: argparse.Namespace) -> None:
|
def cmd_baseline_set(args: argparse.Namespace) -> None:
|
||||||
|
|||||||
@@ -9,14 +9,14 @@ Key contracts:
|
|||||||
- End causes: user_disabled, migrated, unknown_gap
|
- End causes: user_disabled, migrated, unknown_gap
|
||||||
"""
|
"""
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
|
||||||
def ensure_period_open(conn: sqlite3.Connection, run_time: datetime) -> None:
|
def ensure_period_open(conn: sqlite3.Connection, run_time: datetime) -> None:
|
||||||
"""Ensure a monitoring period is open. If none exists, open one at run_time.
|
"""Ensure a monitoring period is open. If none exists, open one at run_time.
|
||||||
|
|
||||||
Spec §9.8: A collection run finding no open monitoring period opens one
|
Spec §9.8: A collection run finding no open monitoring period opens one
|
||||||
at the run moment, never backdated.
|
at the run moment, never backdated. Caller owns the transaction.
|
||||||
"""
|
"""
|
||||||
if get_open_period(conn) is not None:
|
if get_open_period(conn) is not None:
|
||||||
return # Already open — no-op
|
return # Already open — no-op
|
||||||
@@ -26,7 +26,6 @@ def ensure_period_open(conn: sqlite3.Connection, run_time: datetime) -> None:
|
|||||||
"INSERT INTO monitoring_periods (started_at) VALUES (?)",
|
"INSERT INTO monitoring_periods (started_at) VALUES (?)",
|
||||||
(ts,),
|
(ts,),
|
||||||
)
|
)
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def close_period(
|
def close_period(
|
||||||
@@ -83,6 +82,46 @@ def is_inside_period(conn: sqlite3.Connection, ts: datetime) -> bool:
|
|||||||
return cursor.fetchone() is not None
|
return cursor.fetchone() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def interval_within_one_monitoring_period(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
start: str | datetime,
|
||||||
|
end: str | datetime,
|
||||||
|
) -> bool:
|
||||||
|
"""Return whether one monitoring period contains the full interval.
|
||||||
|
|
||||||
|
Compare timestamps as UTC instants. Malformed timestamps fail closed.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
interval_start = as_utc_datetime(start)
|
||||||
|
interval_end = as_utc_datetime(end)
|
||||||
|
except (TypeError, ValueError, OverflowError):
|
||||||
|
return False
|
||||||
|
if interval_end <= interval_start:
|
||||||
|
return False
|
||||||
|
|
||||||
|
for started_at, ended_at in conn.execute(
|
||||||
|
"SELECT started_at, ended_at FROM monitoring_periods"
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
period_start = as_utc_datetime(started_at)
|
||||||
|
period_end = as_utc_datetime(ended_at) if ended_at is not None else None
|
||||||
|
except (TypeError, ValueError, OverflowError):
|
||||||
|
continue
|
||||||
|
if period_start <= interval_start and (
|
||||||
|
period_end is None or interval_end <= period_end
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def as_utc_datetime(value: str | datetime) -> datetime:
|
||||||
|
"""Parse a timestamp and normalize it to an aware UTC datetime."""
|
||||||
|
parsed = value if isinstance(value, datetime) else datetime.fromisoformat(value)
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||||
|
return parsed.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
def wall_clock_in_periods(
|
def wall_clock_in_periods(
|
||||||
conn: sqlite3.Connection,
|
conn: sqlite3.Connection,
|
||||||
start: datetime,
|
start: datetime,
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"""User-scoped TUI preferences (issue #80).
|
||||||
|
|
||||||
|
Persists theme preset and reduced-motion choice per unprivileged user.
|
||||||
|
Preferences live at XDG_CONFIG_HOME/fenris/preferences.json and must not
|
||||||
|
affect collection, projection, history evidence, helper state, package
|
||||||
|
config, or CLI status.
|
||||||
|
|
||||||
|
Safe failures: invalid/unreadable/unwritable data never crashes the
|
||||||
|
dashboard, corrupts previous preferences, or affects monitoring.
|
||||||
|
Failures are understandable rather than silently implying persistence
|
||||||
|
succeeded.
|
||||||
|
|
||||||
|
Criteria: TPH-10, AC80-2, AC80-3.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
|
||||||
|
PREFERENCE_FILE_NAME = "preferences.json"
|
||||||
|
VALID_THEMES = {"chalktone", "amber", "nord", "high_contrast"}
|
||||||
|
DEFAULT_THEME = "chalktone"
|
||||||
|
DEFAULT_REDUCED_MOTION = False
|
||||||
|
|
||||||
|
|
||||||
|
def get_preference_path() -> Path:
|
||||||
|
"""Return the user-scoped preference file path.
|
||||||
|
|
||||||
|
Uses XDG_CONFIG_HOME/fenris/preferences.json.
|
||||||
|
Falls back to ~/.config/fenris/preferences.json if unset.
|
||||||
|
"""
|
||||||
|
xdg = os.environ.get("XDG_CONFIG_HOME")
|
||||||
|
if xdg:
|
||||||
|
base = Path(xdg)
|
||||||
|
else:
|
||||||
|
base = Path.home() / ".config"
|
||||||
|
return base / "fenris" / PREFERENCE_FILE_NAME
|
||||||
|
|
||||||
|
|
||||||
|
def load_preferences() -> Dict[str, Any]:
|
||||||
|
"""Load user preferences with safe defaults.
|
||||||
|
|
||||||
|
Returns a dict with keys:
|
||||||
|
theme: str (one of VALID_THEMES)
|
||||||
|
reduced_motion: bool
|
||||||
|
|
||||||
|
If the file is missing, corrupt, unreadable, or contains invalid
|
||||||
|
values, returns safe defaults (Chalktone theme, normal motion).
|
||||||
|
"""
|
||||||
|
path = get_preference_path()
|
||||||
|
|
||||||
|
try:
|
||||||
|
text = path.read_text()
|
||||||
|
except (OSError, FileNotFoundError):
|
||||||
|
return _defaults()
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(text)
|
||||||
|
except (json.JSONDecodeError, ValueError):
|
||||||
|
return _defaults()
|
||||||
|
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return _defaults()
|
||||||
|
|
||||||
|
theme = data.get("theme", DEFAULT_THEME)
|
||||||
|
if theme not in VALID_THEMES:
|
||||||
|
theme = DEFAULT_THEME
|
||||||
|
|
||||||
|
reduced_motion = data.get("reduced_motion", DEFAULT_REDUCED_MOTION)
|
||||||
|
if not isinstance(reduced_motion, bool):
|
||||||
|
reduced_motion = DEFAULT_REDUCED_MOTION
|
||||||
|
|
||||||
|
return {"theme": theme, "reduced_motion": reduced_motion}
|
||||||
|
|
||||||
|
|
||||||
|
def save_preferences(theme: str = DEFAULT_THEME,
|
||||||
|
reduced_motion: bool = DEFAULT_REDUCED_MOTION) -> None:
|
||||||
|
"""Save user preferences.
|
||||||
|
|
||||||
|
Creates the config directory if needed. If the write fails
|
||||||
|
(read-only filesystem, permissions), the failure is swallowed —
|
||||||
|
the TUI continues with whatever was loaded, and the user sees
|
||||||
|
no crash or error.
|
||||||
|
"""
|
||||||
|
path = get_preference_path()
|
||||||
|
|
||||||
|
try:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
payload = json.dumps({"theme": theme, "reduced_motion": reduced_motion}, indent=2)
|
||||||
|
path.write_text(payload + "\n")
|
||||||
|
except (OSError, PermissionError):
|
||||||
|
# Best-effort persistence — failure must not crash the TUI
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _defaults() -> Dict[str, Any]:
|
||||||
|
return {"theme": DEFAULT_THEME, "reduced_motion": DEFAULT_REDUCED_MOTION}
|
||||||
+100
-12
@@ -28,6 +28,7 @@ from datetime import datetime, timedelta, timezone
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from .monitoring_periods import interval_within_one_monitoring_period
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Constants (spec §6)
|
# Constants (spec §6)
|
||||||
@@ -73,6 +74,7 @@ class ScenarioRange:
|
|||||||
rates: Dict[int, float]
|
rates: Dict[int, float]
|
||||||
min_days: int
|
min_days: int
|
||||||
max_days: int
|
max_days: int
|
||||||
|
horizon_reasons: Dict[int, str] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -91,6 +93,7 @@ class ProjectionResult:
|
|||||||
staleness_fact: Optional[str]
|
staleness_fact: Optional[str]
|
||||||
degraded_identity_fact: Optional[str]
|
degraded_identity_fact: Optional[str]
|
||||||
zero_rate_fact: Optional[str]
|
zero_rate_fact: Optional[str]
|
||||||
|
qualifying_days_progress: Optional[str] = None # Issue #77: honest qualifying-day progress
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -185,7 +188,7 @@ def _wall_clock_in_range(conn, start, end):
|
|||||||
|
|
||||||
def _resolve_baseline(conn, current_segment):
|
def _resolve_baseline(conn, current_segment):
|
||||||
baseline = _get_baseline(conn)
|
baseline = _get_baseline(conn)
|
||||||
facts = []
|
facts: list[str] = []
|
||||||
if baseline is None:
|
if baseline is None:
|
||||||
return BaselineTier.NONE, None, "no baseline", facts
|
return BaselineTier.NONE, None, "no baseline", facts
|
||||||
|
|
||||||
@@ -234,20 +237,39 @@ def _compute_regime_rate(days, conn, regime_start_day, clock_now):
|
|||||||
return regime_bytes / regime_wc, regime_bytes, regime_wc
|
return regime_bytes / regime_wc, regime_bytes, regime_wc
|
||||||
|
|
||||||
|
|
||||||
def _compute_horizon_rate(days, conn, horizon_days, clock_now):
|
def _compute_horizon_rate(days, conn, horizon_days, evidence_endpoint):
|
||||||
cutoff = (clock_now - timedelta(days=horizon_days)).strftime("%Y-%m-%d")
|
"""Compute horizon rate anchored at the latest evidence endpoint T.
|
||||||
|
|
||||||
|
Uses exact trailing horizon_days × 86400 seconds from T, not clock_now.
|
||||||
|
Reader refresh alone never moves T or dilutes rates.
|
||||||
|
|
||||||
|
Returns (rate, reason) where reason is None on success or a string
|
||||||
|
describing why the rate is unavailable.
|
||||||
|
"""
|
||||||
|
if not days:
|
||||||
|
return None, "no observation history"
|
||||||
|
|
||||||
|
# T is the latest evidence endpoint — the end of the last day aggregate
|
||||||
|
T = datetime.fromisoformat(days[-1]["day"] + "T23:59:59+00:00")
|
||||||
|
|
||||||
|
# Exact trailing start: T minus horizon_days × 86400 seconds
|
||||||
|
h_start = T - timedelta(days=horizon_days)
|
||||||
|
cutoff = h_start.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
# History must span the full horizon — no placeholders
|
# History must span the full horizon — no placeholders
|
||||||
if not days or days[0]["day"] > cutoff:
|
if days[0]["day"] > cutoff:
|
||||||
return None
|
return None, "%d-day window starts before earliest data" % horizon_days
|
||||||
|
|
||||||
h_bytes = sum(d["bytes_written"] for d in days if d["day"] >= cutoff)
|
h_bytes = sum(d["bytes_written"] for d in days if d["day"] >= cutoff)
|
||||||
covered = sum(1 for d in days if d["day"] >= cutoff)
|
covered = sum(1 for d in days if d["day"] >= cutoff)
|
||||||
if covered == 0:
|
if covered == 0:
|
||||||
return None
|
return None, "%d-day window has no data" % horizon_days
|
||||||
h_start = datetime.fromisoformat(cutoff + "T00:00:00+00:00")
|
|
||||||
h_wc = _wall_clock_in_range(conn, h_start, clock_now)
|
h_wc = _wall_clock_in_range(conn, h_start, T)
|
||||||
if h_wc <= 0:
|
if h_wc <= 0:
|
||||||
return None
|
return None, "%d-day window has no monitored wall-clock time" % horizon_days
|
||||||
return h_bytes / h_wc
|
|
||||||
|
return h_bytes / h_wc, None
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -462,6 +484,29 @@ def _build_pu_context_line(conn, rate, days, clock_now):
|
|||||||
return "Percentage Used: %d%%" % pu
|
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.
|
||||||
|
"""
|
||||||
|
local_days = conn.execute(
|
||||||
|
"SELECT utc_start, utc_end FROM local_days "
|
||||||
|
"WHERE complete = 1 "
|
||||||
|
"AND activity_precision IN ('measured', 'coarse') "
|
||||||
|
"AND activity_intervals > 0 ORDER BY id"
|
||||||
|
).fetchall()
|
||||||
|
return any(
|
||||||
|
interval_within_one_monitoring_period(conn, utc_start, utc_end)
|
||||||
|
for utc_start, utc_end in local_days
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Main projection function
|
# Main projection function
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -478,6 +523,30 @@ def compute_projection(conn, clock_now):
|
|||||||
tier, baseline, baseline_label, baseline_facts = _resolve_baseline(conn, current_segment)
|
tier, baseline, baseline_label, baseline_facts = _resolve_baseline(conn, current_segment)
|
||||||
facts.extend(baseline_facts)
|
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)
|
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)
|
all_days = _get_all_days(conn)
|
||||||
|
|
||||||
@@ -508,17 +577,35 @@ def compute_projection(conn, clock_now):
|
|||||||
|
|
||||||
scenario = None
|
scenario = None
|
||||||
horizon_rates = {}
|
horizon_rates = {}
|
||||||
|
horizon_reasons = {}
|
||||||
for h in HORIZON_DAYS:
|
for h in HORIZON_DAYS:
|
||||||
hr = _compute_horizon_rate(all_days, conn, h, clock_now)
|
hr, reason = _compute_horizon_rate(all_days, conn, h, clock_now)
|
||||||
if hr is not None:
|
if hr is not None:
|
||||||
horizon_rates[h] = hr
|
horizon_rates[h] = hr
|
||||||
|
else:
|
||||||
|
horizon_reasons[h] = reason
|
||||||
if horizon_rates:
|
if horizon_rates:
|
||||||
scenario = ScenarioRange(rates=horizon_rates, min_days=min(horizon_rates), max_days=max(horizon_rates))
|
scenario = ScenarioRange(
|
||||||
|
rates=horizon_rates,
|
||||||
|
min_days=min(horizon_rates),
|
||||||
|
max_days=max(horizon_rates),
|
||||||
|
horizon_reasons=horizon_reasons,
|
||||||
|
)
|
||||||
|
|
||||||
total_days_count = len(segment_days)
|
total_days_count = len(segment_days)
|
||||||
days_below_coverage = sum(1 for d in segment_days
|
days_below_coverage = sum(1 for d in segment_days
|
||||||
if d["coverage"] < WARMING_COVERAGE_FLOOR or d["sample_count"] == 0)
|
if d["coverage"] < WARMING_COVERAGE_FLOOR or d["sample_count"] == 0)
|
||||||
qualifying = total_days_count - days_below_coverage
|
qualifying = total_days_count - days_below_coverage
|
||||||
|
|
||||||
|
# Issue #77: Show honest qualifying-day progress
|
||||||
|
if total_days_count >= WARMING_MIN_DAYS:
|
||||||
|
# After warm-up, show qualifying day details for transparency
|
||||||
|
qualifying_days_progress = "%d of %d qualifying days" % (qualifying, total_days_count)
|
||||||
|
if days_below_coverage > 0:
|
||||||
|
qualifying_days_progress += " (%d below coverage)" % days_below_coverage
|
||||||
|
else:
|
||||||
|
qualifying_days_progress = None
|
||||||
|
|
||||||
if total_days_count < WARMING_MIN_DAYS or days_below_coverage > WARMING_MAX_LOW_COVERAGE:
|
if total_days_count < WARMING_MIN_DAYS or days_below_coverage > WARMING_MAX_LOW_COVERAGE:
|
||||||
warming_fact = "warming up: %d of %d qualifying days" % (qualifying, WARMING_MIN_DAYS)
|
warming_fact = "warming up: %d of %d qualifying days" % (qualifying, WARMING_MIN_DAYS)
|
||||||
facts.append(warming_fact)
|
facts.append(warming_fact)
|
||||||
@@ -577,4 +664,5 @@ def compute_projection(conn, clock_now):
|
|||||||
staleness_fact=staleness_fact,
|
staleness_fact=staleness_fact,
|
||||||
degraded_identity_fact=degraded_identity_fact,
|
degraded_identity_fact=degraded_identity_fact,
|
||||||
zero_rate_fact=zero_rate_fact,
|
zero_rate_fact=zero_rate_fact,
|
||||||
|
qualifying_days_progress=qualifying_days_progress,
|
||||||
)
|
)
|
||||||
|
|||||||
+358
-17
@@ -1,31 +1,372 @@
|
|||||||
"""Raw sample pruning per spec §3.4, ST-5.
|
"""Expire detail only after UTC and local_days replacement evidence is durable.
|
||||||
|
|
||||||
Raw samples are pruned opportunistically to 14 days.
|
The 14-day cutoff never overrides local-day preservation, shared boundary
|
||||||
Hour observations and day aggregates are retained indefinitely.
|
evidence, publication recovery, or the newest sample's successor-anchor role.
|
||||||
"""
|
"""
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import date, datetime, timedelta
|
||||||
|
|
||||||
|
from .monitoring_periods import (
|
||||||
|
as_utc_datetime,
|
||||||
|
interval_within_one_monitoring_period,
|
||||||
|
)
|
||||||
|
|
||||||
# Spec §3.4: Raw-sample retention
|
|
||||||
RAW_SAMPLE_RETENTION_DAYS = 14
|
RAW_SAMPLE_RETENTION_DAYS = 14
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_sample_time(value: str) -> datetime | None:
|
||||||
|
"""Parse old timestamps conservatively; malformed legacy values stay."""
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
return as_utc_datetime(parsed)
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_rows(conn: sqlite3.Connection) -> list[tuple]:
|
||||||
|
return conn.execute(
|
||||||
|
"SELECT id, ts, segment_id, bytes_written, bytes_read, local_tz "
|
||||||
|
"FROM samples ORDER BY id"
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
|
||||||
|
def _required_hour_window(
|
||||||
|
start: datetime,
|
||||||
|
end: datetime,
|
||||||
|
) -> tuple[datetime, datetime, int] | None:
|
||||||
|
"""Return first hour, exclusive end and count for [start, end)."""
|
||||||
|
if end <= start:
|
||||||
|
return None
|
||||||
|
first_hour = start.replace(minute=0, second=0, microsecond=0)
|
||||||
|
last_hour = end.replace(minute=0, second=0, microsecond=0)
|
||||||
|
try:
|
||||||
|
exclusive_end = last_hour if end == last_hour else last_hour + timedelta(hours=1)
|
||||||
|
except OverflowError:
|
||||||
|
return None
|
||||||
|
count = int((exclusive_end - first_hour).total_seconds() // 3600)
|
||||||
|
return first_hour, exclusive_end, count
|
||||||
|
|
||||||
|
|
||||||
|
def _has_utc_replacement(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
previous: tuple,
|
||||||
|
current: tuple,
|
||||||
|
start: datetime,
|
||||||
|
end: datetime,
|
||||||
|
) -> bool:
|
||||||
|
window = _required_hour_window(start, end)
|
||||||
|
if window is None:
|
||||||
|
return False
|
||||||
|
first_hour, exclusive_end, expected_hour_count = window
|
||||||
|
|
||||||
|
actual_hour_count = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM hour_observations WHERE hour >= ? AND hour < ?",
|
||||||
|
(
|
||||||
|
first_hour.strftime("%Y-%m-%dT%H:00:00+00:00"),
|
||||||
|
exclusive_end.strftime("%Y-%m-%dT%H:00:00+00:00"),
|
||||||
|
),
|
||||||
|
).fetchone()[0]
|
||||||
|
if actual_hour_count < expected_hour_count:
|
||||||
|
return False
|
||||||
|
|
||||||
|
start_written, end_written = previous[3], current[3]
|
||||||
|
start_read, end_read = previous[4], current[4]
|
||||||
|
if None in (start_written, end_written, start_read, end_read):
|
||||||
|
return False
|
||||||
|
if end_written < start_written or end_read < start_read:
|
||||||
|
return False
|
||||||
|
bytes_written = end_written - start_written
|
||||||
|
bytes_read = end_read - start_read
|
||||||
|
if expected_hour_count == 1:
|
||||||
|
totals = conn.execute(
|
||||||
|
"SELECT bytes_written_delta, bytes_read_delta FROM hour_observations "
|
||||||
|
"WHERE hour = ?",
|
||||||
|
(first_hour.strftime("%Y-%m-%dT%H:00:00+00:00"),),
|
||||||
|
).fetchone()
|
||||||
|
else:
|
||||||
|
totals = conn.execute(
|
||||||
|
"SELECT unattributed_bytes_written, unattributed_bytes_read "
|
||||||
|
"FROM day_aggregates WHERE day = ?",
|
||||||
|
(start.date().isoformat(),),
|
||||||
|
).fetchone()
|
||||||
|
if totals is None or totals[0] < bytes_written or totals[1] < bytes_read:
|
||||||
|
return False
|
||||||
|
|
||||||
|
last_day = (exclusive_end - timedelta(microseconds=1)).date()
|
||||||
|
expected_day_count = (last_day - first_hour.date()).days + 1
|
||||||
|
actual_day_count = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM day_aggregates WHERE day >= ? AND day <= ?",
|
||||||
|
(first_hour.date().isoformat(), last_day.isoformat()),
|
||||||
|
).fetchone()[0]
|
||||||
|
return actual_day_count >= expected_day_count
|
||||||
|
|
||||||
|
|
||||||
|
def _local_day_row_exists(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
local_date: str,
|
||||||
|
tz_name: str,
|
||||||
|
) -> bool:
|
||||||
|
return conn.execute(
|
||||||
|
"SELECT 1 FROM local_days WHERE local_date = ? AND tz_name = ?",
|
||||||
|
(local_date, tz_name),
|
||||||
|
).fetchone() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _local_day_bounds(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
local_date: str,
|
||||||
|
tz_name: str,
|
||||||
|
) -> tuple[datetime, datetime] | None:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT utc_start, utc_end FROM local_days "
|
||||||
|
"WHERE local_date = ? AND tz_name = ?",
|
||||||
|
(local_date, tz_name),
|
||||||
|
).fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
start = _parse_sample_time(row[0])
|
||||||
|
end = _parse_sample_time(row[1])
|
||||||
|
if start is None or end is None or end <= start:
|
||||||
|
return None
|
||||||
|
return start, end
|
||||||
|
|
||||||
|
|
||||||
|
def _contains_instant(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
local_date: str,
|
||||||
|
tz_name: str,
|
||||||
|
instant: datetime,
|
||||||
|
) -> bool:
|
||||||
|
bounds = _local_day_bounds(conn, local_date, tz_name)
|
||||||
|
return bounds is not None and bounds[0] <= instant < bounds[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _monitoring_period_covers(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
start: datetime,
|
||||||
|
end: datetime,
|
||||||
|
) -> bool:
|
||||||
|
"""Require continuous monitoring before a local-day total replaces detail."""
|
||||||
|
return interval_within_one_monitoring_period(conn, start, end)
|
||||||
|
|
||||||
|
|
||||||
|
def _has_local_replacement(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
previous: tuple,
|
||||||
|
current: tuple,
|
||||||
|
start: datetime,
|
||||||
|
end: datetime,
|
||||||
|
) -> bool:
|
||||||
|
start_tz = previous[5]
|
||||||
|
end_tz = current[5]
|
||||||
|
if not end_tz:
|
||||||
|
return False
|
||||||
|
|
||||||
|
start_id, end_id = previous[0], current[0]
|
||||||
|
segment_id = current[2]
|
||||||
|
if (
|
||||||
|
start_tz
|
||||||
|
and start_tz == end_tz
|
||||||
|
and segment_id is not None
|
||||||
|
and _monitoring_period_covers(conn, start, end)
|
||||||
|
):
|
||||||
|
known_days = conn.execute(
|
||||||
|
"SELECT local_days.utc_start, local_days.utc_end "
|
||||||
|
"FROM local_days JOIN local_day_segment_totals "
|
||||||
|
" ON local_day_segment_totals.local_day_id = local_days.id "
|
||||||
|
"WHERE local_days.tz_name = ? "
|
||||||
|
" AND local_days.activity_precision IN ('measured', 'coarse') "
|
||||||
|
" AND local_days.last_sample_id >= ? "
|
||||||
|
" AND local_days.activity_intervals > 0 "
|
||||||
|
" AND local_day_segment_totals.segment_id = ? "
|
||||||
|
" AND local_day_segment_totals.activity_intervals > 0",
|
||||||
|
(end_tz, end_id, segment_id),
|
||||||
|
).fetchall()
|
||||||
|
for row in known_days:
|
||||||
|
bounds_start = _parse_sample_time(row[0])
|
||||||
|
bounds_end = _parse_sample_time(row[1])
|
||||||
|
if (
|
||||||
|
bounds_start is not None
|
||||||
|
and bounds_end is not None
|
||||||
|
and bounds_start <= start
|
||||||
|
and end < bounds_end
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
|
||||||
|
evidence = conn.execute(
|
||||||
|
"SELECT bytes_written, bytes_read, reason, start_local_date, end_local_date, "
|
||||||
|
" start_tz_name, end_tz_name, started_at, ended_at "
|
||||||
|
"FROM local_day_unallocated_evidence "
|
||||||
|
"WHERE start_sample_id = ? AND end_sample_id = ?",
|
||||||
|
(start_id, end_id),
|
||||||
|
).fetchone()
|
||||||
|
if evidence is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
start_written, end_written = previous[3], current[3]
|
||||||
|
start_read, end_read = previous[4], current[4]
|
||||||
|
if None in (start_written, end_written, start_read, end_read):
|
||||||
|
return False
|
||||||
|
if end_written < start_written or end_read < start_read:
|
||||||
|
return False
|
||||||
|
# The source IDs uniquely identify this interval. Confirm its durable
|
||||||
|
# deltas and retain every recorded local-day boundary it touches.
|
||||||
|
if (end_written - start_written, end_read - start_read) != evidence[:2]:
|
||||||
|
return False
|
||||||
|
if evidence[5] != start_tz or evidence[6] != end_tz:
|
||||||
|
return False
|
||||||
|
if evidence[2] not in {
|
||||||
|
"counter_discontinuity",
|
||||||
|
"legacy_timezone_unknown",
|
||||||
|
"timezone_change",
|
||||||
|
"monitoring_period",
|
||||||
|
"local_midnight",
|
||||||
|
"segment_unknown",
|
||||||
|
}:
|
||||||
|
return False
|
||||||
|
if _parse_sample_time(evidence[7]) != start or _parse_sample_time(evidence[8]) != end:
|
||||||
|
return False
|
||||||
|
|
||||||
|
affected_days: set[tuple[str, str]] = set()
|
||||||
|
if start_tz:
|
||||||
|
affected_days.add((evidence[3], start_tz))
|
||||||
|
affected_days.add((evidence[4], end_tz))
|
||||||
|
if start_tz and start_tz == end_tz and evidence[3] < evidence[4]:
|
||||||
|
day = date.fromisoformat(evidence[3]) + timedelta(days=1)
|
||||||
|
last_day = date.fromisoformat(evidence[4])
|
||||||
|
while day < last_day:
|
||||||
|
affected_days.add((day.isoformat(), end_tz))
|
||||||
|
day += timedelta(days=1)
|
||||||
|
if not all(
|
||||||
|
_local_day_row_exists(conn, local_date, zone)
|
||||||
|
for local_date, zone in affected_days
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
if start_tz and not _contains_instant(conn, evidence[3], start_tz, start):
|
||||||
|
return False
|
||||||
|
return _contains_instant(conn, evidence[4], end_tz, end)
|
||||||
|
|
||||||
|
|
||||||
|
def _interval_has_replacement(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
previous: tuple,
|
||||||
|
current: tuple,
|
||||||
|
) -> bool:
|
||||||
|
start = _parse_sample_time(previous[1])
|
||||||
|
end = _parse_sample_time(current[1])
|
||||||
|
if start is None or end is None or end <= start:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return (
|
||||||
|
_has_utc_replacement(conn, previous, current, start, end)
|
||||||
|
and _has_local_replacement(conn, previous, current, start, end)
|
||||||
|
)
|
||||||
|
except (sqlite3.Error, ValueError, OverflowError, TypeError):
|
||||||
|
# Missing or unreadable derived evidence never authorizes deletion.
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_needs_preservation(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
rows: list[tuple],
|
||||||
|
index: int,
|
||||||
|
newest_id: int,
|
||||||
|
) -> bool:
|
||||||
|
sample = rows[index]
|
||||||
|
segment_id = sample[2]
|
||||||
|
if segment_id is None or conn.execute(
|
||||||
|
"SELECT 1 FROM controller_segments WHERE id = ?",
|
||||||
|
(segment_id,),
|
||||||
|
).fetchone() is None:
|
||||||
|
return True
|
||||||
|
if sample[0] == newest_id:
|
||||||
|
# Keep newest sample as the source anchor for the next collection.
|
||||||
|
return True
|
||||||
|
|
||||||
|
pairs = []
|
||||||
|
if index > 0 and rows[index - 1][2] == segment_id:
|
||||||
|
pairs.append((rows[index - 1], sample))
|
||||||
|
if index + 1 < len(rows) and rows[index + 1][2] == segment_id:
|
||||||
|
pairs.append((sample, rows[index + 1]))
|
||||||
|
return any(
|
||||||
|
not _interval_has_replacement(conn, previous, current)
|
||||||
|
for previous, current in pairs
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def needs_boundary_anchor(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
sample_ts: str,
|
||||||
|
now: datetime,
|
||||||
|
) -> bool:
|
||||||
|
"""Return whether an old sample still carries unreplaced evidence."""
|
||||||
|
sample_time = _parse_sample_time(sample_ts)
|
||||||
|
cutoff = as_utc_datetime(now) - timedelta(days=RAW_SAMPLE_RETENTION_DAYS)
|
||||||
|
if sample_time is None:
|
||||||
|
return True
|
||||||
|
if sample_time >= cutoff:
|
||||||
|
return False
|
||||||
|
|
||||||
|
rows = _sample_rows(conn)
|
||||||
|
matching = [index for index, row in enumerate(rows) if row[1] == sample_ts]
|
||||||
|
if not matching:
|
||||||
|
return False
|
||||||
|
newest_id = rows[-1][0] if rows else -1
|
||||||
|
return any(
|
||||||
|
_sample_needs_preservation(conn, rows, index, newest_id)
|
||||||
|
for index in matching
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def prune_old_samples(
|
def prune_old_samples(
|
||||||
conn: sqlite3.Connection,
|
conn: sqlite3.Connection,
|
||||||
now: datetime,
|
now: datetime,
|
||||||
retention_days: int = RAW_SAMPLE_RETENTION_DAYS,
|
retention_days: int = RAW_SAMPLE_RETENTION_DAYS,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Remove raw samples older than retention_days.
|
"""Delete expired samples only after every dependent fact is durable.
|
||||||
|
|
||||||
Args:
|
Pending publications are stored separately and never selected here. A
|
||||||
conn: Connection to the observation store.
|
sample stays when it is the newest successor anchor, has an unpublishable
|
||||||
now: Current UTC time.
|
neighbour interval, lacks UTC or local-day replacement evidence, or has a
|
||||||
retention_days: Number of days to retain (default 14).
|
timestamp that cannot be safely interpreted.
|
||||||
|
|
||||||
Returns:
|
|
||||||
Number of samples removed.
|
|
||||||
"""
|
"""
|
||||||
cutoff = (now - timedelta(days=retention_days)).isoformat()
|
cutoff = as_utc_datetime(now) - timedelta(days=retention_days)
|
||||||
cursor = conn.execute("DELETE FROM samples WHERE ts < ?", (cutoff,))
|
owns_transaction = not conn.in_transaction
|
||||||
conn.commit()
|
savepoint = "fenris_sample_retention"
|
||||||
return cursor.rowcount
|
if owns_transaction:
|
||||||
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
|
else:
|
||||||
|
conn.execute(f"SAVEPOINT {savepoint}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
rows = _sample_rows(conn)
|
||||||
|
if not rows:
|
||||||
|
if owns_transaction:
|
||||||
|
conn.commit()
|
||||||
|
else:
|
||||||
|
conn.execute(f"RELEASE SAVEPOINT {savepoint}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
newest_id = rows[-1][0]
|
||||||
|
expired = []
|
||||||
|
for index, row in enumerate(rows):
|
||||||
|
sample_time = _parse_sample_time(row[1])
|
||||||
|
if sample_time is None or sample_time >= cutoff:
|
||||||
|
continue
|
||||||
|
if _sample_needs_preservation(conn, rows, index, newest_id):
|
||||||
|
continue
|
||||||
|
expired.append(row[0])
|
||||||
|
|
||||||
|
conn.executemany("DELETE FROM samples WHERE id = ?", ((sample_id,) for sample_id in expired))
|
||||||
|
if owns_transaction:
|
||||||
|
conn.commit()
|
||||||
|
else:
|
||||||
|
conn.execute(f"RELEASE SAVEPOINT {savepoint}")
|
||||||
|
return len(expired)
|
||||||
|
except Exception:
|
||||||
|
if owns_transaction:
|
||||||
|
conn.rollback()
|
||||||
|
else:
|
||||||
|
conn.execute(f"ROLLBACK TO SAVEPOINT {savepoint}")
|
||||||
|
conn.execute(f"RELEASE SAVEPOINT {savepoint}")
|
||||||
|
raise
|
||||||
|
|||||||
@@ -0,0 +1,448 @@
|
|||||||
|
"""Historical repair and retention (issue #74, #99).
|
||||||
|
|
||||||
|
Re-derives hour observations and day aggregates from surviving raw samples,
|
||||||
|
rebuilds legacy local-day evidence from surviving samples or trustworthy UTC
|
||||||
|
hours, and preserves import markers, boundary anchors, and valid summaries.
|
||||||
|
Repair is idempotent and interruption-safe.
|
||||||
|
|
||||||
|
Contracts:
|
||||||
|
- Idempotent: running repair multiple times produces no duplicates
|
||||||
|
- Interruption-safe: partial repair preserves prior valid history
|
||||||
|
- Preserves existing valid data: never overwrites valid derived data
|
||||||
|
- Surfaces failures explicitly for retry
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import sqlite3
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Optional, List, Tuple
|
||||||
|
|
||||||
|
from .derive import find_previous_sample, derive_hours_from_interval, _parse_ts
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RepairResult:
|
||||||
|
"""Result of a repair operation."""
|
||||||
|
ok: bool
|
||||||
|
hours_created: int = 0
|
||||||
|
hours_updated: int = 0
|
||||||
|
days_created: int = 0
|
||||||
|
days_updated: int = 0
|
||||||
|
intervals_derived: int = 0
|
||||||
|
boundary_anchors_retained: int = 0
|
||||||
|
legacy_summaries_preserved: int = 0
|
||||||
|
error: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RepairStatus:
|
||||||
|
"""Current repair status for read-only views."""
|
||||||
|
last_repair: Optional[str] = None # ISO timestamp of last successful repair
|
||||||
|
repair_in_progress: bool = False
|
||||||
|
hours_derived: int = 0
|
||||||
|
days_derived: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_repair_metadata(conn: sqlite3.Connection) -> None:
|
||||||
|
"""Ensure metadata table exists for tracking repair state."""
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS store_metadata (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def is_repair_in_progress(conn: sqlite3.Connection) -> bool:
|
||||||
|
"""Check if a repair operation is currently in progress."""
|
||||||
|
_ensure_repair_metadata(conn)
|
||||||
|
cursor = conn.execute(
|
||||||
|
"SELECT value FROM store_metadata WHERE key = 'repair_in_progress'"
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
return row is not None and row[0] == "true"
|
||||||
|
|
||||||
|
|
||||||
|
def _set_repair_in_progress(conn: sqlite3.Connection, in_progress: bool) -> None:
|
||||||
|
"""Mark repair as in progress or complete."""
|
||||||
|
_ensure_repair_metadata(conn)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO store_metadata (key, value) VALUES (?, ?)",
|
||||||
|
("repair_in_progress", "true" if in_progress else "false"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _update_repair_status(conn: sqlite3.Connection, result: RepairResult) -> None:
|
||||||
|
"""Update repair status after successful completion."""
|
||||||
|
_ensure_repair_metadata(conn)
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
# Update last repair timestamp
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO store_metadata (key, value) VALUES (?, ?)",
|
||||||
|
("last_repair", now),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update derived counts
|
||||||
|
cursor = conn.execute("SELECT COUNT(*) FROM hour_observations")
|
||||||
|
hours = cursor.fetchone()[0]
|
||||||
|
|
||||||
|
cursor = conn.execute("SELECT COUNT(*) FROM day_aggregates")
|
||||||
|
days = cursor.fetchone()[0]
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO store_metadata (key, value) VALUES (?, ?)",
|
||||||
|
("hours_derived", str(hours)),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO store_metadata (key, value) VALUES (?, ?)",
|
||||||
|
("days_derived", str(days)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_repair_status(conn: sqlite3.Connection) -> RepairStatus:
|
||||||
|
"""Get current repair status for read-only views."""
|
||||||
|
_ensure_repair_metadata(conn)
|
||||||
|
|
||||||
|
last_repair = None
|
||||||
|
cursor = conn.execute(
|
||||||
|
"SELECT value FROM store_metadata WHERE key = 'last_repair'"
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
last_repair = row[0]
|
||||||
|
|
||||||
|
in_progress = is_repair_in_progress(conn)
|
||||||
|
|
||||||
|
hours_derived = 0
|
||||||
|
cursor = conn.execute(
|
||||||
|
"SELECT value FROM store_metadata WHERE key = 'hours_derived'"
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
hours_derived = int(row[0])
|
||||||
|
|
||||||
|
days_derived = 0
|
||||||
|
cursor = conn.execute(
|
||||||
|
"SELECT value FROM store_metadata WHERE key = 'days_derived'"
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
days_derived = int(row[0])
|
||||||
|
|
||||||
|
return RepairStatus(
|
||||||
|
last_repair=last_repair,
|
||||||
|
repair_in_progress=in_progress,
|
||||||
|
hours_derived=hours_derived,
|
||||||
|
days_derived=days_derived,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def needs_boundary_anchor(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
sample_ts: str,
|
||||||
|
now: datetime,
|
||||||
|
) -> bool:
|
||||||
|
"""Check if a sample is needed as a boundary anchor for derivation.
|
||||||
|
|
||||||
|
A sample is a boundary anchor if:
|
||||||
|
1. It's older than retention_days
|
||||||
|
2. It has no derived hour observation for its hour
|
||||||
|
3. It's the last sample before a gap that needs derivation (gap > 24 hours)
|
||||||
|
"""
|
||||||
|
from .pruning import RAW_SAMPLE_RETENTION_DAYS
|
||||||
|
|
||||||
|
sample_dt = _parse_ts(sample_ts)
|
||||||
|
retention_cutoff = now - timedelta(days=RAW_SAMPLE_RETENTION_DAYS)
|
||||||
|
|
||||||
|
# If sample is within retention, not an anchor (will be kept anyway)
|
||||||
|
if sample_dt >= retention_cutoff:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check if this sample's hour already has a derived observation
|
||||||
|
hour_start = sample_dt.replace(minute=0, second=0, microsecond=0).isoformat()
|
||||||
|
hour_end = (sample_dt + timedelta(hours=1)).replace(minute=0, second=0, microsecond=0).isoformat()
|
||||||
|
|
||||||
|
cursor = conn.execute(
|
||||||
|
"""SELECT COUNT(*) FROM hour_observations
|
||||||
|
WHERE hour >= ? AND hour < ?""",
|
||||||
|
(hour_start, hour_end),
|
||||||
|
)
|
||||||
|
|
||||||
|
# If there's an hour observation in this sample's hour, it's been derived
|
||||||
|
if cursor.fetchone()[0] > 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check if this sample is the last sample before a gap
|
||||||
|
# (i.e., the next sample is significantly later)
|
||||||
|
cursor = conn.execute(
|
||||||
|
"""SELECT ts FROM samples WHERE ts > ? ORDER BY ts LIMIT 1""",
|
||||||
|
(sample_ts,),
|
||||||
|
)
|
||||||
|
next_row = cursor.fetchone()
|
||||||
|
|
||||||
|
if next_row is None:
|
||||||
|
# No next sample - this is the last sample, might be needed
|
||||||
|
# But if it's old and fully derived, it's not needed
|
||||||
|
return False
|
||||||
|
|
||||||
|
next_ts = _parse_ts(next_row[0])
|
||||||
|
gap = (next_ts - sample_dt).total_seconds()
|
||||||
|
|
||||||
|
# If gap > 24 hours, this sample is a boundary anchor
|
||||||
|
# (needed to derive the interval spanning the gap)
|
||||||
|
return gap > 24 * 3600
|
||||||
|
|
||||||
|
|
||||||
|
def _get_unlinked_intervals(conn: sqlite3.Connection) -> List[Tuple[dict, dict]]:
|
||||||
|
"""Find sample pairs that form intervals but have no hour observations."""
|
||||||
|
cursor = conn.execute(
|
||||||
|
"""SELECT id, ts, bytes_written, bytes_read, power_on_hours,
|
||||||
|
temperature_c, data_units_written, data_units_read, segment_id
|
||||||
|
FROM samples ORDER BY ts"""
|
||||||
|
)
|
||||||
|
|
||||||
|
all_samples = []
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
all_samples.append({
|
||||||
|
"id": row[0], "ts": row[1], "bytes_written": row[2],
|
||||||
|
"bytes_read": row[3], "power_on_hours": row[4],
|
||||||
|
"temperature_c": row[5], "data_units_written": row[6],
|
||||||
|
"data_units_read": row[7], "segment_id": row[8],
|
||||||
|
})
|
||||||
|
|
||||||
|
intervals = []
|
||||||
|
for i in range(len(all_samples) - 1):
|
||||||
|
prev = all_samples[i]
|
||||||
|
next_s = all_samples[i + 1]
|
||||||
|
|
||||||
|
# Skip if different segments
|
||||||
|
if prev["segment_id"] != next_s["segment_id"]:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if the interval spans hours that need derivation
|
||||||
|
prev_dt = _parse_ts(prev["ts"])
|
||||||
|
next_dt = _parse_ts(next_s["ts"])
|
||||||
|
|
||||||
|
# Check if any hour in the span lacks an observation
|
||||||
|
current = prev_dt.replace(minute=0, second=0, microsecond=0)
|
||||||
|
end = next_dt.replace(minute=0, second=0, microsecond=0)
|
||||||
|
|
||||||
|
needs_derivation = False
|
||||||
|
while current <= end:
|
||||||
|
cursor2 = conn.execute(
|
||||||
|
"SELECT id FROM hour_observations WHERE hour = ?",
|
||||||
|
(current.isoformat(),),
|
||||||
|
)
|
||||||
|
if cursor2.fetchone() is None:
|
||||||
|
needs_derivation = True
|
||||||
|
break
|
||||||
|
current += timedelta(hours=1)
|
||||||
|
|
||||||
|
if needs_derivation:
|
||||||
|
intervals.append((prev, next_s))
|
||||||
|
|
||||||
|
return intervals
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_day_aggregate_from_hours(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
day: str,
|
||||||
|
) -> Optional[dict]:
|
||||||
|
"""Derive a day aggregate from its hour observations."""
|
||||||
|
cursor = conn.execute(
|
||||||
|
"""SELECT SUM(active_seconds), SUM(idle_seconds),
|
||||||
|
SUM(powered_off_seconds), SUM(unknown_seconds),
|
||||||
|
SUM(bytes_written_delta), SUM(bytes_read_delta),
|
||||||
|
SUM(sample_count)
|
||||||
|
FROM hour_observations WHERE hour LIKE ?""",
|
||||||
|
(day + "T%",),
|
||||||
|
)
|
||||||
|
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row is None or row[0] is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"day": day,
|
||||||
|
"active_seconds": row[0] or 0,
|
||||||
|
"idle_seconds": row[1] or 0,
|
||||||
|
"powered_off_seconds": row[2] or 0,
|
||||||
|
"unknown_seconds": row[3] or 0,
|
||||||
|
"bytes_written_delta": row[4] or 0,
|
||||||
|
"bytes_read_delta": row[5] or 0,
|
||||||
|
"sample_count": row[6] or 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _upsert_day_aggregate(conn: sqlite3.Connection, day_data: dict) -> bool:
|
||||||
|
"""Insert or update a day aggregate. Returns True if created."""
|
||||||
|
existing = conn.execute(
|
||||||
|
"SELECT id FROM day_aggregates WHERE day = ?",
|
||||||
|
(day_data["day"],),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if existing is None:
|
||||||
|
# Calculate coverage
|
||||||
|
total_seconds = (day_data["active_seconds"] + day_data["idle_seconds"] +
|
||||||
|
day_data["powered_off_seconds"] + day_data["unknown_seconds"])
|
||||||
|
coverage = (day_data["active_seconds"] + day_data["idle_seconds"] +
|
||||||
|
day_data["powered_off_seconds"]) / total_seconds if total_seconds > 0 else 0.0
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO day_aggregates
|
||||||
|
(day, active_seconds, idle_seconds, powered_off_seconds, unknown_seconds,
|
||||||
|
bytes_written_delta, bytes_read_delta, sample_count, coverage)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(day_data["day"], day_data["active_seconds"], day_data["idle_seconds"],
|
||||||
|
day_data["powered_off_seconds"], day_data["unknown_seconds"],
|
||||||
|
day_data["bytes_written_delta"], day_data["bytes_read_delta"],
|
||||||
|
day_data["sample_count"], coverage),
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
# Update existing (but only if new data is more complete)
|
||||||
|
# This implements the "don't overwrite valid older history" rule
|
||||||
|
cursor = conn.execute(
|
||||||
|
"""SELECT bytes_written_delta, sample_count
|
||||||
|
FROM day_aggregates WHERE day = ?""",
|
||||||
|
(day_data["day"],),
|
||||||
|
)
|
||||||
|
existing_data = cursor.fetchone()
|
||||||
|
|
||||||
|
# Only update if new data has more samples or more bytes
|
||||||
|
if (day_data["sample_count"] > existing_data[1] or
|
||||||
|
day_data["bytes_written_delta"] > existing_data[0]):
|
||||||
|
|
||||||
|
total_seconds = (day_data["active_seconds"] + day_data["idle_seconds"] +
|
||||||
|
day_data["powered_off_seconds"] + day_data["unknown_seconds"])
|
||||||
|
coverage = (day_data["active_seconds"] + day_data["idle_seconds"] +
|
||||||
|
day_data["powered_off_seconds"]) / total_seconds if total_seconds > 0 else 0.0
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"""UPDATE day_aggregates SET
|
||||||
|
active_seconds = ?, idle_seconds = ?, powered_off_seconds = ?,
|
||||||
|
unknown_seconds = ?, bytes_written_delta = ?, bytes_read_delta = ?,
|
||||||
|
sample_count = ?, coverage = ?
|
||||||
|
WHERE day = ?""",
|
||||||
|
(day_data["active_seconds"], day_data["idle_seconds"],
|
||||||
|
day_data["powered_off_seconds"], day_data["unknown_seconds"],
|
||||||
|
day_data["bytes_written_delta"], day_data["bytes_read_delta"],
|
||||||
|
day_data["sample_count"], coverage, day_data["day"]),
|
||||||
|
)
|
||||||
|
return False # Updated, not created
|
||||||
|
else:
|
||||||
|
return False # No update needed
|
||||||
|
|
||||||
|
|
||||||
|
def repair_derivation(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
clock=None,
|
||||||
|
) -> RepairResult:
|
||||||
|
"""Repair hour observations and day aggregates from surviving samples.
|
||||||
|
|
||||||
|
This is the main entry point for historical repair. It:
|
||||||
|
1. Finds sample pairs that need interval derivation
|
||||||
|
2. Derives hour observations from those intervals
|
||||||
|
3. Updates day aggregates from the hour observations
|
||||||
|
4. Preserves existing valid data
|
||||||
|
5. Is idempotent and interruption-safe
|
||||||
|
|
||||||
|
Args:
|
||||||
|
conn: Connection to the observation store
|
||||||
|
clock: Injected clock (for testing)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
RepairResult with operation details
|
||||||
|
"""
|
||||||
|
if clock is None:
|
||||||
|
clock = datetime.now(timezone.utc)
|
||||||
|
elif hasattr(clock, 'utcnow'):
|
||||||
|
clock = clock.utcnow()
|
||||||
|
|
||||||
|
result = RepairResult(ok=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# A committed in-progress marker may be stale after process death.
|
||||||
|
# SQLite's write lock serializes active repairs; retry idempotently.
|
||||||
|
if conn.in_transaction:
|
||||||
|
conn.commit()
|
||||||
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
|
_set_repair_in_progress(conn, True)
|
||||||
|
conn.commit()
|
||||||
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
|
|
||||||
|
# 1. Find and derive intervals from sample pairs
|
||||||
|
intervals = _get_unlinked_intervals(conn)
|
||||||
|
|
||||||
|
for prev, next_s in intervals:
|
||||||
|
try:
|
||||||
|
derived_hours = derive_hours_from_interval(conn, prev, next_s)
|
||||||
|
result.intervals_derived += 1
|
||||||
|
result.hours_created += len([h for h in derived_hours if h.get("attributed", True)])
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to derive interval %s -> %s: %s",
|
||||||
|
prev["ts"], next_s["ts"], e)
|
||||||
|
# Continue with other intervals (resilient)
|
||||||
|
|
||||||
|
# 2. Update day aggregates from hour observations
|
||||||
|
cursor = conn.execute(
|
||||||
|
"SELECT DISTINCT substr(hour, 1, 10) as day FROM hour_observations ORDER BY day"
|
||||||
|
)
|
||||||
|
days = [row[0] for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
for day in days:
|
||||||
|
day_data = _derive_day_aggregate_from_hours(conn, day)
|
||||||
|
if day_data is not None:
|
||||||
|
created = _upsert_day_aggregate(conn, day_data)
|
||||||
|
if created:
|
||||||
|
result.days_created += 1
|
||||||
|
else:
|
||||||
|
result.days_updated += 1
|
||||||
|
|
||||||
|
# Rebuild only legacy or unavailable local-day activity. This shares
|
||||||
|
# the migration derivation and leaves trustworthy published totals intact.
|
||||||
|
from .local_day import repair_legacy_local_day_evidence
|
||||||
|
repair_legacy_local_day_evidence(conn)
|
||||||
|
|
||||||
|
# 3. Count boundary anchors retained
|
||||||
|
now = clock if isinstance(clock, datetime) else datetime.now(timezone.utc)
|
||||||
|
cursor = conn.execute("SELECT ts FROM samples ORDER BY ts")
|
||||||
|
anchor_count = 0
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
if needs_boundary_anchor(conn, row[0], now):
|
||||||
|
anchor_count += 1
|
||||||
|
result.boundary_anchors_retained = anchor_count
|
||||||
|
|
||||||
|
# 4. Count preserved legacy summaries
|
||||||
|
# Legacy summaries are day aggregates without corresponding hour observations
|
||||||
|
cursor = conn.execute(
|
||||||
|
"""SELECT COUNT(*) FROM day_aggregates d
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM hour_observations h
|
||||||
|
WHERE h.hour LIKE d.day || 'T%'
|
||||||
|
)"""
|
||||||
|
)
|
||||||
|
result.legacy_summaries_preserved = cursor.fetchone()[0]
|
||||||
|
|
||||||
|
# Publish derived rows and completion status together. A process
|
||||||
|
# interruption rolls back the in-progress marker with the repair.
|
||||||
|
_update_repair_status(conn, result)
|
||||||
|
_set_repair_in_progress(conn, False)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
conn.rollback()
|
||||||
|
try:
|
||||||
|
_set_repair_in_progress(conn, False)
|
||||||
|
conn.commit()
|
||||||
|
except sqlite3.Error:
|
||||||
|
conn.rollback()
|
||||||
|
logger.error("Repair failed: %s", e)
|
||||||
|
return RepairResult(
|
||||||
|
ok=False,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
return result
|
||||||
+151
-255
@@ -1,8 +1,8 @@
|
|||||||
"""Read-only CLI status command: the CLI twin of the TUI (spec §8.8, LC-9, CI-2).
|
"""Read-only CLI status command: the CLI twin of the TUI (spec §8.8, LC-9, CI-2).
|
||||||
|
|
||||||
Composes from the observation store (read-only) and allow-listed systemctl
|
Composes from the observation store (read-only) and allow-listed service
|
||||||
properties: projection facts, four separate service facts (boot enablement,
|
properties: projection facts, four separate service facts (boot enablement,
|
||||||
runtime activity, last collect outcome, freshness), and a journalctl hint on
|
runtime activity, last collect outcome, freshness), and a journal/log hint on
|
||||||
failure or staleness. Never auto-samples, never prompts.
|
failure or staleness. Never auto-samples, never prompts.
|
||||||
|
|
||||||
Freshness constants are defined once here and shared with the TUI (§8.9):
|
Freshness constants are defined once here and shared with the TUI (§8.9):
|
||||||
@@ -14,23 +14,30 @@ 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.
|
Criteria: LC-9, CI-2, CI-4, FL-4, FL-5, FL-7.
|
||||||
"""
|
"""
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import subprocess
|
from contextlib import contextmanager
|
||||||
import sys
|
import sys
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
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 .projection import compute_projection, ConfidenceState, DISCLOSURES
|
||||||
from .store import SCHEMA_VERSION
|
from .store import SCHEMA_VERSION
|
||||||
|
from .init_system import (
|
||||||
|
query_service_state as _init_query_service_state,
|
||||||
|
journal_hint as _init_journal_hint,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Freshness constants (§8.9, §8.2)
|
# Freshness constants (§8.9, §8.2)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
CADENCE_DEFAULT_S = 300 # 5 min
|
CADENCE_DEFAULT_S = 180 # 3 min
|
||||||
ACCURACY_SEC = 30
|
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
|
STALENESS_THRESHOLD_S = 48 * 3600 # 48 h
|
||||||
|
|
||||||
|
|
||||||
@@ -95,7 +102,7 @@ def open_store_readonly(store_path: Path) -> sqlite3.Connection:
|
|||||||
# PermissionError before any StoreFault can be raised (issue #54).
|
# PermissionError before any StoreFault can be raised (issue #54).
|
||||||
raise StoreFault("observation store not readable: %s" % e)
|
raise StoreFault("observation store not readable: %s" % e)
|
||||||
if not exists:
|
if not exists:
|
||||||
raise StoreFault("observation store not found at %s" % store_path)
|
raise MissingStore("observation store not found at %s" % store_path)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
conn = sqlite3.connect("file:%s?mode=ro" % store_path, uri=True)
|
conn = sqlite3.connect("file:%s?mode=ro" % store_path, uri=True)
|
||||||
@@ -122,6 +129,10 @@ class StoreFault(Exception):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class MissingStore(StoreFault):
|
||||||
|
"""No observation history has been created yet."""
|
||||||
|
|
||||||
|
|
||||||
class NewerSchema(Exception):
|
class NewerSchema(Exception):
|
||||||
"""Store has a newer user_version (§9.5)."""
|
"""Store has a newer user_version (§9.5)."""
|
||||||
def __init__(self, version: int):
|
def __init__(self, version: int):
|
||||||
@@ -130,97 +141,12 @@ class NewerSchema(Exception):
|
|||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Service state queries (§8.8 — allow-listed systemctl properties)
|
# Service state queries (§8.8 — init-system agnostic)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def _systemctl_show(unit: str, *properties: str) -> Dict[str, str]:
|
# Re-export for backward compatibility with tests that import directly
|
||||||
"""Query systemctl show for specific properties. Returns empty dict on failure."""
|
query_service_state = _init_query_service_state
|
||||||
try:
|
_journalctl_hint = _init_journal_hint
|
||||||
result = subprocess.run(
|
|
||||||
["systemctl", "show", unit, "--property=" + ",".join(properties)],
|
|
||||||
capture_output=True, text=True, timeout=5,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
return {}
|
|
||||||
out = {}
|
|
||||||
for line in result.stdout.splitlines():
|
|
||||||
if "=" in line:
|
|
||||||
key, _, value = line.partition("=")
|
|
||||||
out[key.strip()] = value.strip()
|
|
||||||
return out
|
|
||||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def _journalctl_hint(unit: str, lines: int = 5) -> Optional[str]:
|
|
||||||
"""Get the last N journal lines for a unit. Returns None on failure."""
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
["journalctl", "-u", unit, "--no-pager", "-n", str(lines), "--output=short-iso"],
|
|
||||||
capture_output=True, text=True, timeout=5,
|
|
||||||
)
|
|
||||||
if result.returncode != 0 or not result.stdout.strip():
|
|
||||||
return None
|
|
||||||
return result.stdout.strip()
|
|
||||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def query_service_state() -> Dict[str, Any]:
|
|
||||||
"""Query systemctl for the four separate service facts (§7.3, LC-9).
|
|
||||||
|
|
||||||
Returns dict with keys:
|
|
||||||
boot_enabled: bool
|
|
||||||
timer_active: bool
|
|
||||||
last_collect_ok: Optional[bool]
|
|
||||||
last_collect_age_s: Optional[int]
|
|
||||||
last_collect_reason: Optional[str]
|
|
||||||
"""
|
|
||||||
timer_props = _systemctl_show(
|
|
||||||
"fenris-collect.timer",
|
|
||||||
"UnitFileState", "ActiveState", "LastTriggerUSec",
|
|
||||||
)
|
|
||||||
service_props = _systemctl_show(
|
|
||||||
"fenris-collect.service",
|
|
||||||
"ActiveState", "ExecMainStatus", "ExecMainExitTimestamp",
|
|
||||||
)
|
|
||||||
|
|
||||||
boot_enabled_str = timer_props.get("UnitFileState", "")
|
|
||||||
boot_enabled = boot_enabled_str == "enabled"
|
|
||||||
|
|
||||||
active_state = timer_props.get("ActiveState", "inactive")
|
|
||||||
timer_active = active_state == "active"
|
|
||||||
|
|
||||||
last_collect_ok = None
|
|
||||||
last_collect_age_s = None
|
|
||||||
last_collect_reason = None
|
|
||||||
|
|
||||||
last_trigger = timer_props.get("LastTriggerUSec", "")
|
|
||||||
if last_trigger and last_trigger != "n/a":
|
|
||||||
try:
|
|
||||||
trigger_dt = datetime.fromisoformat(last_trigger.replace("Z", "+00:00"))
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
last_collect_age_s = int((now - trigger_dt).total_seconds())
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
exec_status = service_props.get("ExecMainStatus", "")
|
|
||||||
if exec_status:
|
|
||||||
try:
|
|
||||||
exit_code = int(exec_status)
|
|
||||||
last_collect_ok = exit_code == 0
|
|
||||||
if exit_code != 0:
|
|
||||||
last_collect_reason = "exit code %d" % exit_code
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
return {
|
|
||||||
"boot_enabled": boot_enabled,
|
|
||||||
"timer_active": timer_active,
|
|
||||||
"last_collect_ok": last_collect_ok,
|
|
||||||
"last_collect_age_s": last_collect_age_s,
|
|
||||||
"last_collect_reason": last_collect_reason,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -328,26 +254,12 @@ def check_retired_flag(flag: str) -> Optional[str]:
|
|||||||
# Formatting
|
# Formatting
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def _format_projection(proj, freshness: str, service: Dict[str, Any],
|
def _format_projection(proj, freshness: str, drive_facts: List[str],
|
||||||
drive_facts: List[str], config_error: Optional[str],
|
config_error: Optional[str],
|
||||||
store_fault: Optional[str], newer_schema: Optional[str],
|
sample_count: int = 0, day_count: int = 0) -> str:
|
||||||
journal_hint: Optional[str]) -> str:
|
"""Format projection details; monitoring status has its own renderer."""
|
||||||
"""Format the complete status output."""
|
|
||||||
lines = []
|
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) ---
|
# --- Configuration error (§8.3) ---
|
||||||
if config_error:
|
if config_error:
|
||||||
lines.append("configuration error: %s" % config_error)
|
lines.append("configuration error: %s" % config_error)
|
||||||
@@ -358,7 +270,19 @@ def _format_projection(proj, freshness: str, service: Dict[str, Any],
|
|||||||
lines.append("no observations yet")
|
lines.append("no observations yet")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("Enable monitoring: fenris monitor resume")
|
lines.append("Enable monitoring: fenris monitor resume")
|
||||||
_append_service_facts(lines, service)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
# --- Single sample: awaiting another sample (issue #73 AC3) ---
|
||||||
|
# Only show awaiting state when there are no day aggregates (e.g., legacy import
|
||||||
|
# or hand-crafted stores can have 1 sample but sufficient day data for projection)
|
||||||
|
if sample_count <= 1 and day_count == 0:
|
||||||
|
lines.append("awaiting another sample")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("Collecting usage data — the first projection requires at least two samples.")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
if proj is None:
|
||||||
|
lines.append("no projection available")
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
# --- Projection headline ---
|
# --- Projection headline ---
|
||||||
@@ -368,21 +292,30 @@ def _format_projection(proj, freshness: str, service: Dict[str, Any],
|
|||||||
|
|
||||||
# --- Confidence state + contributing facts (§6.7, §6.11) ---
|
# --- Confidence state + contributing facts (§6.7, §6.11) ---
|
||||||
state_label = proj.confidence_state.value
|
state_label = proj.confidence_state.value
|
||||||
if proj.contributing_facts:
|
facts_list = list(proj.contributing_facts) if proj.contributing_facts else []
|
||||||
facts_str = " · ".join(proj.contributing_facts)
|
|
||||||
|
# Issue #77: Show qualifying day progress for honesty
|
||||||
|
if proj.qualifying_days_progress:
|
||||||
|
facts_list.insert(0, proj.qualifying_days_progress)
|
||||||
|
|
||||||
|
if facts_list:
|
||||||
|
facts_str = " · ".join(facts_list)
|
||||||
lines.append("%s evidence · %s" % (state_label, facts_str))
|
lines.append("%s evidence · %s" % (state_label, facts_str))
|
||||||
else:
|
else:
|
||||||
lines.append("%s evidence" % state_label)
|
lines.append("%s evidence" % state_label)
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
# --- Scenario range (§6.5) ---
|
# --- Scenario range (§6.5) with horizon reasons ---
|
||||||
if proj.scenario_range and proj.scenario_range.rates:
|
if proj.scenario_range:
|
||||||
parts = []
|
parts = []
|
||||||
for horizon in sorted(proj.scenario_range.rates.keys()):
|
for horizon in sorted(proj.scenario_range.rates.keys()):
|
||||||
rate_gb_day = proj.scenario_range.rates[horizon] * 86400 / 1e9
|
rate_gb_day = proj.scenario_range.rates[horizon] * 86400 / 1e9
|
||||||
parts.append("%dd: %.2f GB/day" % (horizon, rate_gb_day))
|
parts.append("%dd: %.2f GB/day" % (horizon, rate_gb_day))
|
||||||
lines.append("scenario range: %s" % " · ".join(parts))
|
for horizon, reason in sorted(proj.scenario_range.horizon_reasons.items()):
|
||||||
lines.append("")
|
parts.append("%dd: %s" % (horizon, reason))
|
||||||
|
if parts:
|
||||||
|
lines.append("scenario range: %s" % " · ".join(parts))
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
# --- PU context line (§6.1) ---
|
# --- PU context line (§6.1) ---
|
||||||
lines.append(proj.pu_context_line)
|
lines.append(proj.pu_context_line)
|
||||||
@@ -394,16 +327,6 @@ def _format_projection(proj, freshness: str, service: Dict[str, Any],
|
|||||||
lines.append(fact)
|
lines.append(fact)
|
||||||
lines.append("")
|
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)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
@@ -448,36 +371,6 @@ def _format_headline(proj) -> str:
|
|||||||
return headline
|
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)
|
# Dashboard clarity parity wording (DC-2, DC-3)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -492,7 +385,9 @@ _PAUSED_CONSEQUENCE = (
|
|||||||
|
|
||||||
def monitoring_continuity(service: Dict[str, Any]) -> str:
|
def monitoring_continuity(service: Dict[str, Any]) -> str:
|
||||||
"""Return the boot-persistence wording, independent of timer runtime."""
|
"""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]:
|
def deliberate_pause_lines() -> List[str]:
|
||||||
@@ -538,110 +433,92 @@ def format_disclosures() -> str:
|
|||||||
# Main status entry point
|
# Main status entry point
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def get_status(store_path: Optional[Path] = None, clock_now: Optional[datetime] = None,
|
@contextmanager
|
||||||
query_services: bool = True, query_journal: bool = True) -> str:
|
def read_status(
|
||||||
"""Render the complete read-only status (§8.8, LC-9).
|
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,
|
Own acquisition, fault classification, and connection lifetime for both
|
||||||
never prompts, and never writes to the store.
|
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:
|
from .status_composition import compose_status
|
||||||
clock_now = datetime.now(timezone.utc)
|
|
||||||
|
|
||||||
# --- Configuration (§8.3) ---
|
clock_now = clock_now or datetime.now(timezone.utc)
|
||||||
config_error = None
|
service = None
|
||||||
device = None
|
|
||||||
try:
|
|
||||||
config = read_config()
|
|
||||||
device = config["device"]
|
|
||||||
except ConfigError as e:
|
|
||||||
config_error = str(e)
|
|
||||||
|
|
||||||
# --- Service state ---
|
|
||||||
service = {}
|
|
||||||
if query_services:
|
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("fenris-collect.service")
|
|
||||||
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)
|
|
||||||
|
|
||||||
# Freshness age for the service fact
|
|
||||||
freshness_age_s = None
|
|
||||||
if newest_ts:
|
|
||||||
try:
|
try:
|
||||||
ts = datetime.fromisoformat(newest_ts)
|
service = query_service_state()
|
||||||
if ts.tzinfo is None:
|
except (OSError, RuntimeError):
|
||||||
ts = ts.replace(tzinfo=timezone.utc)
|
|
||||||
freshness_age_s = int((clock_now - ts).total_seconds())
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
service["freshness"] = freshness
|
conn = None
|
||||||
service["freshness_age_s"] = freshness_age_s
|
store_fault = newer_schema = None
|
||||||
try:
|
try:
|
||||||
service["deliberately_paused"] = is_deliberately_paused(conn, service)
|
try:
|
||||||
except sqlite3.Error:
|
conn = open_store_readonly(store_path or Path("/var/lib/fenris/observations.db"))
|
||||||
service["deliberately_paused"] = False
|
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) ---
|
comp = compose_status(
|
||||||
drive_facts = []
|
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:
|
try:
|
||||||
drive_facts = _query_drive_facts(conn)
|
read_config()
|
||||||
except sqlite3.Error:
|
except ConfigError as exc:
|
||||||
pass
|
config_error = str(exc)
|
||||||
|
|
||||||
# --- Projection (§6 — recomputed on read, never stored) ---
|
with read_status(store_path, clock_now, query_services) as (conn, comp):
|
||||||
try:
|
parts = [render_status_cli(comp)]
|
||||||
proj = compute_projection(conn, clock_now)
|
if not comp.store_fault and not comp.newer_schema:
|
||||||
except Exception:
|
drive_facts = []
|
||||||
proj = None
|
proj = None
|
||||||
|
if conn is not None:
|
||||||
# --- Journal hint on failure or staleness (§8.8) ---
|
try:
|
||||||
journal_hint = None
|
drive_facts = _query_drive_facts(conn)
|
||||||
if query_journal and freshness in ("missed", "stale"):
|
proj = compute_projection(conn, clock_now)
|
||||||
journal_hint = _journalctl_hint("fenris-collect.service")
|
except (sqlite3.Error, ValueError, TypeError):
|
||||||
|
pass
|
||||||
# --- Compose output ---
|
parts.append(_format_projection(
|
||||||
result = _format_projection(
|
proj, comp.freshness, drive_facts, config_error,
|
||||||
proj, freshness, service, drive_facts, config_error,
|
comp.sample_count, comp.day_count,
|
||||||
None, None, journal_hint,
|
))
|
||||||
)
|
if query_journal and (
|
||||||
|
comp.store_fault or comp.last_collect_ok is False
|
||||||
conn.close()
|
or comp.freshness in ("missed", "stale")
|
||||||
return result
|
):
|
||||||
|
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,
|
def render_status(store_path: Optional[Path] = None, clock_now: Optional[datetime] = None,
|
||||||
@@ -656,3 +533,22 @@ def render_status(store_path: Optional[Path] = None, clock_now: Optional[datetim
|
|||||||
parts.append("")
|
parts.append("")
|
||||||
parts.append(format_disclosures())
|
parts.append(format_disclosures())
|
||||||
return "\n\n".join(parts)
|
return "\n\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Shared status composition integration (issue #78)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def get_status_composition(
|
||||||
|
store_path: Optional[Path] = None,
|
||||||
|
clock_now: Optional[datetime] = None,
|
||||||
|
query_services: bool = True,
|
||||||
|
collecting: bool = False,
|
||||||
|
reduced_motion: bool = False,
|
||||||
|
) -> 'StatusComposition':
|
||||||
|
"""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
|
||||||
|
|||||||
@@ -0,0 +1,558 @@
|
|||||||
|
"""Shared status composition for TUI and CLI (issue #78, TPH-2/3).
|
||||||
|
|
||||||
|
Centralizes the monitoring status lattice consumed by both surfaces.
|
||||||
|
States: Monitoring, Collecting, Paused, Waiting, Interrupted, Error, Stale, Unknown.
|
||||||
|
|
||||||
|
Precedence: Error > Interrupted > Paused > Stale > Waiting > Monitoring > Unknown.
|
||||||
|
Collecting overlays every base except store fault.
|
||||||
|
|
||||||
|
Freshness grading uses shared constants from status.py.
|
||||||
|
"""
|
||||||
|
import enum
|
||||||
|
import sqlite3
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
# Status poll interval (AC78-6): lightweight 5s systemctl show poll
|
||||||
|
STATUS_POLL_INTERVAL_S = 5
|
||||||
|
|
||||||
|
from .status import (
|
||||||
|
FRESH_THRESHOLD_S,
|
||||||
|
STALENESS_THRESHOLD_S,
|
||||||
|
grade_freshness,
|
||||||
|
freshness_age_human,
|
||||||
|
is_deliberately_paused,
|
||||||
|
monitoring_continuity,
|
||||||
|
deliberate_pause_lines,
|
||||||
|
open_store_readonly,
|
||||||
|
StoreFault,
|
||||||
|
NewerSchema,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Status state enum with glyph and label
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class StatusState(enum.Enum):
|
||||||
|
"""The eight monitoring status states (TPH-2)."""
|
||||||
|
|
||||||
|
MONITORING = "monitoring"
|
||||||
|
COLLECTING = "collecting"
|
||||||
|
PAUSED = "paused"
|
||||||
|
WAITING = "waiting"
|
||||||
|
INTERRUPTED = "interrupted"
|
||||||
|
ERROR = "error"
|
||||||
|
STALE = "stale"
|
||||||
|
UNKNOWN = "unknown"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def glyph(self) -> str:
|
||||||
|
_glyphs = {
|
||||||
|
"monitoring": "●",
|
||||||
|
"collecting": "◐",
|
||||||
|
"paused": "‖",
|
||||||
|
"waiting": "○",
|
||||||
|
"interrupted": "⊘",
|
||||||
|
"error": "✖",
|
||||||
|
"stale": "◌",
|
||||||
|
"unknown": "?",
|
||||||
|
}
|
||||||
|
return _glyphs[self.value]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def label(self) -> str:
|
||||||
|
return self.value.capitalize()
|
||||||
|
|
||||||
|
|
||||||
|
# Precedence order: higher index = higher precedence
|
||||||
|
_PRECEDENCE = [
|
||||||
|
StatusState.UNKNOWN,
|
||||||
|
StatusState.MONITORING,
|
||||||
|
StatusState.WAITING,
|
||||||
|
StatusState.STALE,
|
||||||
|
StatusState.PAUSED,
|
||||||
|
StatusState.INTERRUPTED,
|
||||||
|
StatusState.ERROR,
|
||||||
|
]
|
||||||
|
|
||||||
|
_PRECEDENCE_RANK = {s: i for i, s in enumerate(_PRECEDENCE)}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Status composition result
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StatusComposition:
|
||||||
|
"""The composed status result shared between TUI and CLI."""
|
||||||
|
|
||||||
|
state: StatusState
|
||||||
|
glyph: str
|
||||||
|
label: str
|
||||||
|
explanation: str
|
||||||
|
|
||||||
|
# Separate facts (never folded into the status word)
|
||||||
|
freshness: str = "unknown"
|
||||||
|
freshness_age_s: Optional[int] = None
|
||||||
|
last_collect_ok: Optional[bool] = None
|
||||||
|
last_collect_age_s: Optional[int] = None
|
||||||
|
last_collect_reason: Optional[str] = None
|
||||||
|
boot_enabled: Optional[bool] = None
|
||||||
|
timer_active: Optional[bool] = None
|
||||||
|
deliberately_paused: bool = False
|
||||||
|
pause_age_s: Optional[int] = None
|
||||||
|
external_stop_reason: Optional[str] = None
|
||||||
|
|
||||||
|
# Store fault / newer schema (suppress store-dependent views)
|
||||||
|
store_fault: Optional[str] = None
|
||||||
|
newer_schema: Optional[str] = None
|
||||||
|
|
||||||
|
# Collecting overlay
|
||||||
|
overlay_base: Optional[StatusState] = None
|
||||||
|
|
||||||
|
# Sample counts for waiting explanations
|
||||||
|
sample_count: int = 0
|
||||||
|
day_count: int = 0
|
||||||
|
pending_publication_count: int = 0
|
||||||
|
|
||||||
|
# Whether the status dot should blink (only Monitoring)
|
||||||
|
should_blink: bool = False
|
||||||
|
|
||||||
|
# Continuity line
|
||||||
|
continuity: str = ""
|
||||||
|
|
||||||
|
# Paused lines (for TUI banner)
|
||||||
|
paused_lines: List[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Status composition logic
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _determine_base_state(
|
||||||
|
freshness: str,
|
||||||
|
sample_count: int,
|
||||||
|
day_count: int,
|
||||||
|
boot_enabled: Optional[bool],
|
||||||
|
timer_active: Optional[bool],
|
||||||
|
last_collect_ok: Optional[bool],
|
||||||
|
deliberately_paused: bool,
|
||||||
|
external_stop_reason: Optional[str],
|
||||||
|
service_available: bool,
|
||||||
|
) -> StatusState:
|
||||||
|
"""Determine the base status state from facts.
|
||||||
|
|
||||||
|
Precedence: Error > Interrupted > Paused > Stale > Waiting > Monitoring > Unknown.
|
||||||
|
"""
|
||||||
|
# Unknown: service query failed and no store-derived fact places us higher
|
||||||
|
if not service_available:
|
||||||
|
return StatusState.UNKNOWN
|
||||||
|
|
||||||
|
# Error: last collect failed
|
||||||
|
if last_collect_ok is False:
|
||||||
|
return StatusState.ERROR
|
||||||
|
|
||||||
|
# Interrupted: external stop (not user_disabled)
|
||||||
|
if external_stop_reason is not None:
|
||||||
|
return StatusState.INTERRUPTED
|
||||||
|
|
||||||
|
# Paused: deliberate disable
|
||||||
|
if deliberately_paused:
|
||||||
|
return StatusState.PAUSED
|
||||||
|
|
||||||
|
# Stale: timer active, no failure, but data ≥ 48h old
|
||||||
|
if freshness == "stale" and timer_active and last_collect_ok is not False:
|
||||||
|
return StatusState.STALE
|
||||||
|
|
||||||
|
# Waiting: empty store, single sample, or fresh data but not yet enough evidence
|
||||||
|
if sample_count == 0:
|
||||||
|
return StatusState.WAITING
|
||||||
|
if sample_count <= 1 and day_count == 0:
|
||||||
|
return StatusState.WAITING
|
||||||
|
|
||||||
|
# Monitoring: everything is fine
|
||||||
|
return StatusState.MONITORING
|
||||||
|
|
||||||
|
|
||||||
|
def _determine_explanation(
|
||||||
|
state: StatusState,
|
||||||
|
freshness: str,
|
||||||
|
freshness_age_s: Optional[int],
|
||||||
|
last_collect_ok: Optional[bool],
|
||||||
|
last_collect_reason: Optional[str],
|
||||||
|
sample_count: int,
|
||||||
|
deliberately_paused: bool,
|
||||||
|
store_fault: Optional[str],
|
||||||
|
newer_schema: Optional[str],
|
||||||
|
) -> str:
|
||||||
|
"""Determine the explanation line for the status state."""
|
||||||
|
if store_fault:
|
||||||
|
return "observation store unreadable — see collector logs"
|
||||||
|
|
||||||
|
if newer_schema:
|
||||||
|
return "observation store written by a newer Fenris — upgrade Fenris"
|
||||||
|
|
||||||
|
if state == StatusState.ERROR:
|
||||||
|
parts = []
|
||||||
|
if last_collect_ok is False:
|
||||||
|
parts.append("last run failed")
|
||||||
|
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" % freshness_age_human(freshness_age_s))
|
||||||
|
return " ".join(parts) if parts else "last run failed"
|
||||||
|
|
||||||
|
if state == StatusState.INTERRUPTED:
|
||||||
|
return "collection stopped outside Fenris — monitoring period still open"
|
||||||
|
|
||||||
|
if state == StatusState.PAUSED:
|
||||||
|
return "monitoring paused — paused time excluded from your usage habit"
|
||||||
|
|
||||||
|
if state == StatusState.STALE:
|
||||||
|
if freshness_age_s is not None:
|
||||||
|
return "last sample %s" % freshness_age_human(freshness_age_s)
|
||||||
|
return "data is stale"
|
||||||
|
|
||||||
|
if state == StatusState.WAITING:
|
||||||
|
if sample_count == 0:
|
||||||
|
return "awaiting first sample"
|
||||||
|
if sample_count <= 1:
|
||||||
|
return "awaiting another sample"
|
||||||
|
return "waiting for data"
|
||||||
|
|
||||||
|
if state == StatusState.MONITORING:
|
||||||
|
if freshness_age_s is not None:
|
||||||
|
return "last sample %s" % freshness_age_human(freshness_age_s)
|
||||||
|
return "monitoring active"
|
||||||
|
|
||||||
|
if state == StatusState.UNKNOWN:
|
||||||
|
return "service state unavailable"
|
||||||
|
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _determine_collecting_overlay(
|
||||||
|
base_state: StatusState,
|
||||||
|
last_collect_ok: Optional[bool],
|
||||||
|
deliberately_paused: bool,
|
||||||
|
store_fault: Optional[str],
|
||||||
|
newer_schema: Optional[str],
|
||||||
|
) -> str:
|
||||||
|
"""Determine the explanation line when Collecting overlays a base state."""
|
||||||
|
if store_fault or newer_schema:
|
||||||
|
return "run in flight — store fault"
|
||||||
|
|
||||||
|
if base_state == StatusState.PAUSED:
|
||||||
|
return "run in flight — paused"
|
||||||
|
if base_state == StatusState.INTERRUPTED:
|
||||||
|
return "run in flight — interrupted"
|
||||||
|
if base_state == StatusState.ERROR:
|
||||||
|
return "run in flight — retry"
|
||||||
|
if base_state == StatusState.STALE:
|
||||||
|
return "run in flight — stale data"
|
||||||
|
if base_state == StatusState.WAITING:
|
||||||
|
return "run in flight"
|
||||||
|
return "run in flight"
|
||||||
|
|
||||||
|
|
||||||
|
def compose_status(
|
||||||
|
conn: Optional[sqlite3.Connection],
|
||||||
|
service: Optional[Dict[str, Any]],
|
||||||
|
clock_now: datetime,
|
||||||
|
store_fault: Optional[str] = None,
|
||||||
|
newer_schema: Optional[str] = None,
|
||||||
|
collecting: bool = False,
|
||||||
|
reduced_motion: bool = False,
|
||||||
|
) -> StatusComposition:
|
||||||
|
"""Compose the shared status from service state and store data.
|
||||||
|
|
||||||
|
This is the single entry point consumed by both TUI and CLI.
|
||||||
|
"""
|
||||||
|
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 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"
|
||||||
|
freshness_age_s = None
|
||||||
|
sample_count = 0
|
||||||
|
day_count = 0
|
||||||
|
pending_publication_count = 0
|
||||||
|
deliberately_paused = False
|
||||||
|
|
||||||
|
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()
|
||||||
|
newest_ts = row[0] if row else None
|
||||||
|
freshness = grade_freshness(newest_ts, clock_now)
|
||||||
|
if newest_ts:
|
||||||
|
try:
|
||||||
|
ts = datetime.fromisoformat(newest_ts)
|
||||||
|
if ts.tzinfo is None:
|
||||||
|
ts = ts.replace(tzinfo=timezone.utc)
|
||||||
|
else:
|
||||||
|
ts = ts.astimezone(timezone.utc)
|
||||||
|
freshness_age_s = int((clock_now - ts).total_seconds())
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
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]
|
||||||
|
schema_version = conn.execute("PRAGMA user_version").fetchone()[0]
|
||||||
|
if schema_version >= 4:
|
||||||
|
cursor = conn.execute("SELECT COUNT(*) FROM pending_publications")
|
||||||
|
pending_publication_count = cursor.fetchone()[0]
|
||||||
|
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 as exc:
|
||||||
|
store_fault = str(exc)
|
||||||
|
|
||||||
|
if store_fault or newer_schema:
|
||||||
|
freshness = "unknown"
|
||||||
|
freshness_age_s = None
|
||||||
|
sample_count = day_count = 0
|
||||||
|
pending_publication_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 (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(
|
||||||
|
"SELECT 1 FROM monitoring_periods WHERE ended_at IS NULL LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
if open_period is not None:
|
||||||
|
external_stop_reason = "external_stop"
|
||||||
|
except sqlite3.Error:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# --- Determine base state ---
|
||||||
|
# Store fault and newer schema always override to ERROR
|
||||||
|
if store_fault is not None or newer_schema is not None:
|
||||||
|
base_state = StatusState.ERROR
|
||||||
|
else:
|
||||||
|
base_state = _determine_base_state(
|
||||||
|
freshness=freshness,
|
||||||
|
sample_count=sample_count,
|
||||||
|
day_count=day_count,
|
||||||
|
boot_enabled=boot_enabled,
|
||||||
|
timer_active=timer_active,
|
||||||
|
last_collect_ok=last_collect_ok,
|
||||||
|
deliberately_paused=deliberately_paused,
|
||||||
|
external_stop_reason=external_stop_reason,
|
||||||
|
service_available=service_available,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Apply Collecting overlay ---
|
||||||
|
state = base_state
|
||||||
|
overlay_base = None
|
||||||
|
explanation = ""
|
||||||
|
|
||||||
|
if collecting and store_fault is None and newer_schema is None:
|
||||||
|
# Collecting overlays every base except store fault
|
||||||
|
overlay_base = base_state
|
||||||
|
state = StatusState.COLLECTING
|
||||||
|
explanation = _determine_collecting_overlay(
|
||||||
|
base_state, last_collect_ok, deliberately_paused,
|
||||||
|
store_fault, newer_schema,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
explanation = _determine_explanation(
|
||||||
|
base_state, freshness, freshness_age_s,
|
||||||
|
last_collect_ok, last_collect_reason,
|
||||||
|
sample_count, deliberately_paused,
|
||||||
|
store_fault, newer_schema,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Continuity line ---
|
||||||
|
continuity = ""
|
||||||
|
if service_available:
|
||||||
|
continuity = monitoring_continuity(service)
|
||||||
|
|
||||||
|
# --- Paused lines ---
|
||||||
|
paused_lines = []
|
||||||
|
if deliberately_paused:
|
||||||
|
paused_lines = deliberate_pause_lines()
|
||||||
|
|
||||||
|
# Determine blink flag: only Monitoring dot blinks, never text or other states
|
||||||
|
should_blink = (state == StatusState.MONITORING and not reduced_motion)
|
||||||
|
|
||||||
|
return StatusComposition(
|
||||||
|
state=state,
|
||||||
|
glyph=state.glyph,
|
||||||
|
label=state.label,
|
||||||
|
explanation=explanation,
|
||||||
|
should_blink=should_blink,
|
||||||
|
freshness=freshness,
|
||||||
|
freshness_age_s=freshness_age_s,
|
||||||
|
last_collect_ok=last_collect_ok,
|
||||||
|
last_collect_age_s=last_collect_age_s,
|
||||||
|
last_collect_reason=last_collect_reason,
|
||||||
|
boot_enabled=boot_enabled,
|
||||||
|
timer_active=timer_active,
|
||||||
|
deliberately_paused=deliberately_paused,
|
||||||
|
external_stop_reason=external_stop_reason,
|
||||||
|
store_fault=store_fault,
|
||||||
|
newer_schema=newer_schema,
|
||||||
|
overlay_base=overlay_base,
|
||||||
|
sample_count=sample_count,
|
||||||
|
day_count=day_count,
|
||||||
|
pending_publication_count=pending_publication_count,
|
||||||
|
continuity=continuity,
|
||||||
|
paused_lines=paused_lines,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CLI rendering (static, no styling)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def render_status_cli(comp: StatusComposition) -> str:
|
||||||
|
"""Render the status composition as static CLI text."""
|
||||||
|
lines = []
|
||||||
|
|
||||||
|
# Status line
|
||||||
|
lines.append("%s %s" % (comp.glyph, comp.label))
|
||||||
|
|
||||||
|
# Explanation
|
||||||
|
if comp.explanation:
|
||||||
|
lines.append(comp.explanation)
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Separate facts
|
||||||
|
facts = []
|
||||||
|
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:
|
||||||
|
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")
|
||||||
|
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 comp.pending_publication_count:
|
||||||
|
facts.append(_pending_publication_text(comp.pending_publication_count))
|
||||||
|
|
||||||
|
if facts:
|
||||||
|
lines.append(" · ".join(facts))
|
||||||
|
|
||||||
|
# Continuity
|
||||||
|
if comp.continuity:
|
||||||
|
lines.append("")
|
||||||
|
lines.append("CONTINUITY: %s" % comp.continuity)
|
||||||
|
|
||||||
|
# Deliberate pause
|
||||||
|
if comp.paused_lines:
|
||||||
|
for pl in comp.paused_lines:
|
||||||
|
lines.append(pl)
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TUI rendering (with styling tokens)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def render_status_tui(comp: StatusComposition) -> str:
|
||||||
|
"""Render the status composition as TUI text with Textual markup."""
|
||||||
|
lines = []
|
||||||
|
|
||||||
|
# Status line with color
|
||||||
|
color = {
|
||||||
|
StatusState.MONITORING: "green",
|
||||||
|
StatusState.COLLECTING: "green",
|
||||||
|
StatusState.PAUSED: "yellow",
|
||||||
|
StatusState.WAITING: "yellow",
|
||||||
|
StatusState.INTERRUPTED: "red",
|
||||||
|
StatusState.ERROR: "red",
|
||||||
|
StatusState.STALE: "red",
|
||||||
|
StatusState.UNKNOWN: "dim",
|
||||||
|
}[comp.state]
|
||||||
|
|
||||||
|
lines.append("[%s]%s %s[/%s]" % (color, comp.glyph, comp.label, color))
|
||||||
|
|
||||||
|
# Explanation
|
||||||
|
if comp.explanation:
|
||||||
|
lines.append(comp.explanation[:1].upper() + comp.explanation[1:])
|
||||||
|
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Separate facts
|
||||||
|
facts = []
|
||||||
|
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:
|
||||||
|
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")
|
||||||
|
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 comp.pending_publication_count:
|
||||||
|
facts.append(_pending_publication_text(comp.pending_publication_count).capitalize())
|
||||||
|
|
||||||
|
if facts:
|
||||||
|
lines.append(" · ".join(facts))
|
||||||
|
|
||||||
|
# Continuity
|
||||||
|
if comp.continuity:
|
||||||
|
lines.append("")
|
||||||
|
lines.append("[bold]Continuity[/bold] %s" % comp.continuity)
|
||||||
|
|
||||||
|
# Deliberate pause
|
||||||
|
if comp.paused_lines:
|
||||||
|
for pl in comp.paused_lines:
|
||||||
|
lines.append(pl[:1].upper() + pl[1:])
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _pending_publication_text(count: int) -> str:
|
||||||
|
noun = "observation" if count == 1 else "observations"
|
||||||
|
return f"pending publication: {count} {noun} retained for retry"
|
||||||
+226
-22
@@ -3,8 +3,7 @@
|
|||||||
This module handles:
|
This module handles:
|
||||||
- Store initialization with WAL mode
|
- Store initialization with WAL mode
|
||||||
- Schema versioning with PRAGMA user_version
|
- Schema versioning with PRAGMA user_version
|
||||||
- The six entities: samples, hour_observations, day_aggregates,
|
- Observation records, derived activity, publication state, and metadata
|
||||||
monitoring_periods, controller_segments, endurance_baseline
|
|
||||||
"""
|
"""
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -12,7 +11,7 @@ from typing import Optional
|
|||||||
|
|
||||||
|
|
||||||
# Schema version - increment on each migration
|
# Schema version - increment on each migration
|
||||||
SCHEMA_VERSION = 1
|
SCHEMA_VERSION = 6
|
||||||
|
|
||||||
|
|
||||||
# Packaged default placement (spec §8.3). The config may override it, but a
|
# Packaged default placement (spec §8.3). The config may override it, but a
|
||||||
@@ -33,7 +32,7 @@ def get_store_path(config: dict) -> Path:
|
|||||||
def init_store(store_path: Path) -> sqlite3.Connection:
|
def init_store(store_path: Path) -> sqlite3.Connection:
|
||||||
"""Initialize the observation store if not present.
|
"""Initialize the observation store if not present.
|
||||||
|
|
||||||
Creates the database with WAL mode and all six entities.
|
Creates the observation schema, including private pending-publication storage.
|
||||||
Returns a connection to the store.
|
Returns a connection to the store.
|
||||||
"""
|
"""
|
||||||
conn = sqlite3.connect(str(store_path))
|
conn = sqlite3.connect(str(store_path))
|
||||||
@@ -73,9 +72,11 @@ def init_store(store_path: Path) -> sqlite3.Connection:
|
|||||||
)
|
)
|
||||||
elif current_version < SCHEMA_VERSION:
|
elif current_version < SCHEMA_VERSION:
|
||||||
# Older version - apply migrations
|
# Older version - apply migrations
|
||||||
_apply_migrations(conn, current_version)
|
try:
|
||||||
conn.execute(f"PRAGMA user_version={SCHEMA_VERSION}")
|
_apply_migrations(conn, current_version)
|
||||||
conn.commit()
|
except Exception:
|
||||||
|
conn.close()
|
||||||
|
raise
|
||||||
|
|
||||||
return conn
|
return conn
|
||||||
|
|
||||||
@@ -106,7 +107,9 @@ def _create_schema(conn: sqlite3.Connection):
|
|||||||
data_units_read INTEGER,
|
data_units_read INTEGER,
|
||||||
bytes_written INTEGER,
|
bytes_written INTEGER,
|
||||||
bytes_read INTEGER,
|
bytes_read INTEGER,
|
||||||
critical_warning INTEGER
|
critical_warning INTEGER,
|
||||||
|
segment_id INTEGER,
|
||||||
|
local_tz TEXT
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
|
||||||
@@ -141,7 +144,9 @@ def _create_schema(conn: sqlite3.Connection):
|
|||||||
bytes_written_delta INTEGER DEFAULT 0,
|
bytes_written_delta INTEGER DEFAULT 0,
|
||||||
bytes_read_delta INTEGER DEFAULT 0,
|
bytes_read_delta INTEGER DEFAULT 0,
|
||||||
sample_count INTEGER DEFAULT 0,
|
sample_count INTEGER DEFAULT 0,
|
||||||
coverage REAL DEFAULT 0.0
|
coverage REAL DEFAULT 0.0,
|
||||||
|
unattributed_bytes_written INTEGER DEFAULT 0,
|
||||||
|
unattributed_bytes_read INTEGER DEFAULT 0
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
|
||||||
@@ -190,6 +195,34 @@ 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,
|
||||||
|
activity_seconds INTEGER NOT NULL DEFAULT 0,
|
||||||
|
activity_intervals INTEGER NOT NULL DEFAULT 0,
|
||||||
|
activity_incomplete BOOLEAN NOT NULL DEFAULT 0,
|
||||||
|
activity_precision TEXT NOT NULL DEFAULT 'measured',
|
||||||
|
last_sample_id INTEGER,
|
||||||
|
UNIQUE(local_date, tz_name)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
_create_local_day_shared_evidence(conn)
|
||||||
|
_create_local_day_segment_totals(conn)
|
||||||
|
|
||||||
# Metadata table for store state (e.g., legacy import marker)
|
# Metadata table for store state (e.g., legacy import marker)
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS store_metadata (
|
CREATE TABLE IF NOT EXISTS store_metadata (
|
||||||
@@ -198,20 +231,181 @@ def _create_schema(conn: sqlite3.Connection):
|
|||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
_create_pending_publications(conn)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_pending_publications(conn: sqlite3.Connection) -> None:
|
||||||
|
"""Create private staging for valid observations awaiting derivation."""
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS pending_publications (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
sample_ts TEXT NOT NULL,
|
||||||
|
payload TEXT NOT NULL
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def _create_local_day_shared_evidence(conn: sqlite3.Connection) -> None:
|
||||||
|
"""Create once-only local activity evidence that cannot be day-allocated."""
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS local_day_unallocated_evidence (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
start_sample_id INTEGER NOT NULL,
|
||||||
|
end_sample_id INTEGER NOT NULL,
|
||||||
|
start_local_date TEXT NOT NULL,
|
||||||
|
end_local_date TEXT NOT NULL,
|
||||||
|
start_tz_name TEXT,
|
||||||
|
end_tz_name TEXT,
|
||||||
|
started_at TEXT NOT NULL,
|
||||||
|
ended_at TEXT NOT NULL,
|
||||||
|
bytes_written INTEGER NOT NULL DEFAULT 0,
|
||||||
|
bytes_read INTEGER NOT NULL DEFAULT 0,
|
||||||
|
reason TEXT NOT NULL,
|
||||||
|
segment_id INTEGER,
|
||||||
|
UNIQUE(start_sample_id, end_sample_id)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS local_day_evidence_start "
|
||||||
|
"ON local_day_unallocated_evidence(start_local_date, start_tz_name)"
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS local_day_evidence_end "
|
||||||
|
"ON local_day_unallocated_evidence(end_local_date, end_tz_name)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_local_day_segment_totals(conn: sqlite3.Connection) -> None:
|
||||||
|
"""Retain the controller-segment provenance behind known day totals."""
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS local_day_segment_totals (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
local_day_id INTEGER NOT NULL,
|
||||||
|
segment_id INTEGER NOT NULL,
|
||||||
|
bytes_written INTEGER NOT NULL DEFAULT 0,
|
||||||
|
bytes_read INTEGER NOT NULL DEFAULT 0,
|
||||||
|
activity_seconds INTEGER NOT NULL DEFAULT 0,
|
||||||
|
activity_intervals INTEGER NOT NULL DEFAULT 0,
|
||||||
|
UNIQUE(local_day_id, segment_id)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
def _apply_migrations(conn: sqlite3.Connection, current_version: int):
|
def _apply_migrations(conn: sqlite3.Connection, current_version: int):
|
||||||
"""Apply forward-only migrations from current_version to SCHEMA_VERSION.
|
"""Apply forward-only migrations from current_version to SCHEMA_VERSION.
|
||||||
|
|
||||||
Each migration step is a transactional block. Add new steps as sequential
|
Commit each version transition independently. A failed step rolls back in
|
||||||
elif branches when SCHEMA_VERSION increases.
|
full while earlier successful steps remain versioned and retryable.
|
||||||
|
|
||||||
Spec: §3.6, §10.2
|
|
||||||
"""
|
"""
|
||||||
# Migration 1→2: example placeholder
|
migrations = {
|
||||||
# if current_version < 2:
|
2: _migrate_1_to_2,
|
||||||
# conn.execute("ALTER TABLE ...")
|
3: _migrate_2_to_3,
|
||||||
# current_version = 2
|
4: _migrate_3_to_4,
|
||||||
pass
|
5: _migrate_4_to_5,
|
||||||
|
6: _migrate_5_to_6,
|
||||||
|
}
|
||||||
|
while current_version < SCHEMA_VERSION:
|
||||||
|
target_version = current_version + 1
|
||||||
|
migration = migrations.get(target_version)
|
||||||
|
if migration is None:
|
||||||
|
raise ValueError(f"No migration registered for schema {target_version}")
|
||||||
|
conn.execute("BEGIN IMMEDIATE")
|
||||||
|
try:
|
||||||
|
migration(conn)
|
||||||
|
conn.execute(f"PRAGMA user_version={target_version}")
|
||||||
|
conn.commit()
|
||||||
|
except Exception:
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
current_version = target_version
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_1_to_2(conn: sqlite3.Connection) -> None:
|
||||||
|
"""Add segment provenance and unattributed UTC byte tracking."""
|
||||||
|
tables = {row[0] for row in conn.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||||
|
).fetchall()}
|
||||||
|
if "samples" in tables:
|
||||||
|
cols = {row[1] for row in conn.execute(
|
||||||
|
"PRAGMA table_info(samples)"
|
||||||
|
).fetchall()}
|
||||||
|
if "segment_id" not in cols:
|
||||||
|
conn.execute("ALTER TABLE samples ADD COLUMN segment_id INTEGER")
|
||||||
|
if "day_aggregates" in tables:
|
||||||
|
cols = {row[1] for row in conn.execute(
|
||||||
|
"PRAGMA table_info(day_aggregates)"
|
||||||
|
).fetchall()}
|
||||||
|
for column in ("unattributed_bytes_written", "unattributed_bytes_read"):
|
||||||
|
if column not in cols:
|
||||||
|
conn.execute(
|
||||||
|
f"ALTER TABLE day_aggregates ADD COLUMN {column} INTEGER DEFAULT 0"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_2_to_3(conn: sqlite3.Connection) -> None:
|
||||||
|
"""Add local-day activity summaries."""
|
||||||
|
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 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)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_3_to_4(conn: sqlite3.Connection) -> None:
|
||||||
|
"""Add private publication staging for acquired observations."""
|
||||||
|
_create_pending_publications(conn)
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_4_to_5(conn: sqlite3.Connection) -> None:
|
||||||
|
"""Add measured local-day evidence storage and mark old totals legacy."""
|
||||||
|
tables = {row[0] for row in conn.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||||
|
).fetchall()}
|
||||||
|
if "samples" in tables:
|
||||||
|
sample_cols = {row[1] for row in conn.execute(
|
||||||
|
"PRAGMA table_info(samples)"
|
||||||
|
).fetchall()}
|
||||||
|
if "local_tz" not in sample_cols:
|
||||||
|
conn.execute("ALTER TABLE samples ADD COLUMN local_tz TEXT")
|
||||||
|
if "local_days" in tables:
|
||||||
|
local_cols = {row[1] for row in conn.execute(
|
||||||
|
"PRAGMA table_info(local_days)"
|
||||||
|
).fetchall()}
|
||||||
|
for column, declaration in (
|
||||||
|
("activity_seconds", "INTEGER NOT NULL DEFAULT 0"),
|
||||||
|
("activity_intervals", "INTEGER NOT NULL DEFAULT 0"),
|
||||||
|
("activity_incomplete", "BOOLEAN NOT NULL DEFAULT 0"),
|
||||||
|
("activity_precision", "TEXT NOT NULL DEFAULT 'legacy'"),
|
||||||
|
("last_sample_id", "INTEGER"),
|
||||||
|
):
|
||||||
|
if column not in local_cols:
|
||||||
|
conn.execute(
|
||||||
|
f"ALTER TABLE local_days ADD COLUMN {column} {declaration}"
|
||||||
|
)
|
||||||
|
_create_local_day_shared_evidence(conn)
|
||||||
|
_create_local_day_segment_totals(conn)
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_5_to_6(conn: sqlite3.Connection) -> None:
|
||||||
|
"""Rebuild local-day summaries from surviving trustworthy evidence."""
|
||||||
|
from .local_day import repair_legacy_local_day_evidence
|
||||||
|
|
||||||
|
repair_legacy_local_day_evidence(conn)
|
||||||
|
|
||||||
|
|
||||||
def migrate_to_latest(store_path: Path) -> int:
|
def migrate_to_latest(store_path: Path) -> int:
|
||||||
@@ -239,10 +433,20 @@ def migrate_to_latest(store_path: Path) -> int:
|
|||||||
conn.close()
|
conn.close()
|
||||||
return 0 # Already up to date
|
return 0 # Already up to date
|
||||||
|
|
||||||
|
# Version 0 means no schema — create fresh (issue #73)
|
||||||
|
if current_version == 0:
|
||||||
|
_create_schema(conn)
|
||||||
|
conn.execute(f"PRAGMA user_version={SCHEMA_VERSION}")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return SCHEMA_VERSION
|
||||||
|
|
||||||
steps = SCHEMA_VERSION - current_version
|
steps = SCHEMA_VERSION - current_version
|
||||||
_apply_migrations(conn, current_version)
|
try:
|
||||||
conn.execute(f"PRAGMA user_version={SCHEMA_VERSION}")
|
_apply_migrations(conn, current_version)
|
||||||
conn.commit()
|
except Exception:
|
||||||
|
conn.close()
|
||||||
|
raise
|
||||||
conn.close()
|
conn.close()
|
||||||
return steps
|
return steps
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
"""Fenris theme presets (issue #80).
|
||||||
|
|
||||||
|
Chalktone-inspired default plus Amber, Nord, and High Contrast.
|
||||||
|
Themes style chrome, borders, accents, muted text, and graph roles;
|
||||||
|
status semantic colours/glyphs/text always win.
|
||||||
|
|
||||||
|
Theme roles for graph rendering expose distinct colours per preset so
|
||||||
|
the bar graph can reflect the user's visual preference without depending
|
||||||
|
on graph-ticket completion.
|
||||||
|
|
||||||
|
Criteria: TPH-10, AC80-1, AC80-5.
|
||||||
|
"""
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
from textual.theme import Theme
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Status semantic colours — always win, never themed (AC80-1)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
STATUS_COLORS = {
|
||||||
|
"monitoring": "green",
|
||||||
|
"collecting": "green",
|
||||||
|
"paused": "yellow",
|
||||||
|
"waiting": "yellow",
|
||||||
|
"interrupted": "red",
|
||||||
|
"error": "red",
|
||||||
|
"stale": "red",
|
||||||
|
"unknown": "dim",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Amber theme — warm golden tones (default, amber graph role)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_AMBER = Theme(
|
||||||
|
name="fenris-amber",
|
||||||
|
primary="#d4a017", # warm amber
|
||||||
|
secondary="#c49b0a", # darker amber
|
||||||
|
accent="#ffd54f", # light amber highlight
|
||||||
|
warning="#e6a817", # amber warning
|
||||||
|
error="#e74c3c", # red error
|
||||||
|
success="#27ae60", # green success
|
||||||
|
foreground="#e8e0d0", # warm light
|
||||||
|
background="#1a1510", # warm dark
|
||||||
|
surface="#241f16", # warm surface
|
||||||
|
panel="#2a2318", # warm panel
|
||||||
|
boost="#332a1c", # warm boost
|
||||||
|
dark=True,
|
||||||
|
variables={
|
||||||
|
"graph-allocated": "#d4a017",
|
||||||
|
"graph-unallocated": "#8b6914",
|
||||||
|
"graph-gap": "#554422",
|
||||||
|
"graph-zero": "#665533",
|
||||||
|
"graph-partial": "#aa8822",
|
||||||
|
"graph-selection": "#ffd54f",
|
||||||
|
"border-default": "#554422",
|
||||||
|
"muted-text": "#887755",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Nord theme — cool blue-gray polar night palette
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_NORD = Theme(
|
||||||
|
name="fenris-nord",
|
||||||
|
primary="#88c0d0", # nord8 frost
|
||||||
|
secondary="#81a1c1", # nord9
|
||||||
|
accent="#8fbcbb", # nord7
|
||||||
|
warning="#ebcb8b", # nord13
|
||||||
|
error="#bf616a", # nord11
|
||||||
|
success="#a3be8c", # nord14
|
||||||
|
foreground="#eceff4", # nord6
|
||||||
|
background="#2e3440", # nord0
|
||||||
|
surface="#3b4252", # nord1
|
||||||
|
panel="#434c5e", # nord2
|
||||||
|
boost="#4c566a", # nord3
|
||||||
|
dark=True,
|
||||||
|
variables={
|
||||||
|
"graph-allocated": "#88c0d0",
|
||||||
|
"graph-unallocated": "#5e81ac",
|
||||||
|
"graph-gap": "#4c566a",
|
||||||
|
"graph-zero": "#616e88",
|
||||||
|
"graph-partial": "#81a1c1",
|
||||||
|
"graph-selection": "#8fbcbb",
|
||||||
|
"border-default": "#4c566a",
|
||||||
|
"muted-text": "#7b88a1",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# High Contrast — maximum readability, pure black and white
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_HIGH_CONTRAST = Theme(
|
||||||
|
name="fenris-high-contrast",
|
||||||
|
primary="#ffffff", # pure white
|
||||||
|
secondary="#dddddd", # light gray
|
||||||
|
accent="#ffff00", # bright yellow
|
||||||
|
warning="#ff8800", # bright orange
|
||||||
|
error="#ff0000", # pure red
|
||||||
|
success="#00ff00", # pure green
|
||||||
|
foreground="#ffffff", # pure white
|
||||||
|
background="#000000", # pure black
|
||||||
|
surface="#111111", # near-black surface
|
||||||
|
panel="#1a1a1a", # near-black panel
|
||||||
|
boost="#222222", # near-black boost
|
||||||
|
dark=True,
|
||||||
|
variables={
|
||||||
|
"graph-allocated": "#ffffff",
|
||||||
|
"graph-unallocated": "#aaaaaa",
|
||||||
|
"graph-gap": "#555555",
|
||||||
|
"graph-zero": "#666666",
|
||||||
|
"graph-partial": "#cccccc",
|
||||||
|
"graph-selection": "#ffff00",
|
||||||
|
"border-default": "#ffffff",
|
||||||
|
"muted-text": "#aaaaaa",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Theme registry
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_CHALKTONE = Theme(
|
||||||
|
name="fenris-chalktone",
|
||||||
|
primary="#abc4b3", secondary="#c9b69a", accent="#dfc49a",
|
||||||
|
warning="#e9bc79", error="#e58d89", success="#acd29c",
|
||||||
|
foreground="#e0d8c5", background="#202426", surface="#202426",
|
||||||
|
panel="#252a2c", boost="#333b3d", dark=True,
|
||||||
|
variables={
|
||||||
|
"graph-allocated": "#abc4b3", "graph-unallocated": "#dfc49a",
|
||||||
|
"graph-gap": "#e58d89", "graph-zero": "#a7b1a9",
|
||||||
|
"graph-partial": "#e9bc79", "graph-selection": "#f3dbb0",
|
||||||
|
"border-default": "#606d6a", "muted-text": "#a7b1a9",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
THEMES = {
|
||||||
|
"chalktone": _CHALKTONE,
|
||||||
|
"amber": _AMBER,
|
||||||
|
"nord": _NORD,
|
||||||
|
"high_contrast": _HIGH_CONTRAST,
|
||||||
|
}
|
||||||
|
|
||||||
|
THEME_NAMES = set(THEMES.keys())
|
||||||
|
|
||||||
|
|
||||||
|
def get_theme(name: str) -> Theme:
|
||||||
|
"""Return a registered theme by preset name.
|
||||||
|
|
||||||
|
Unknown names fall back to Chalktone.
|
||||||
|
"""
|
||||||
|
return THEMES.get(name, _CHALKTONE)
|
||||||
|
|
||||||
|
|
||||||
|
def get_graph_colors(theme_name: str) -> Dict[str, str]:
|
||||||
|
"""Return the graph colour roles for a theme preset.
|
||||||
|
|
||||||
|
Returns a dict with keys: allocated, unallocated, gap, zero, partial,
|
||||||
|
selection and muted text. Falls back to Chalktone for unknown names.
|
||||||
|
"""
|
||||||
|
theme = get_theme(theme_name)
|
||||||
|
variables = theme.variables or {}
|
||||||
|
return {
|
||||||
|
"allocated": variables.get("graph-allocated", "#d4a017"),
|
||||||
|
"unallocated": variables.get("graph-unallocated", "#8b6914"),
|
||||||
|
"gap": variables.get("graph-gap", "#554422"),
|
||||||
|
"zero": variables.get("graph-zero", "#665533"),
|
||||||
|
"partial": variables.get("graph-partial", "#aa8822"),
|
||||||
|
"selection": variables.get("graph-selection", "#ffd54f"),
|
||||||
|
"muted": variables.get("muted-text", "#a7b1a9"),
|
||||||
|
}
|
||||||
+2120
-324
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,52 @@
|
|||||||
|
"""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_path = Path(os.readlink(str(localtime)))
|
||||||
|
if not target_path.is_absolute():
|
||||||
|
target_path = localtime.parent / target_path
|
||||||
|
target = target_path.resolve().as_posix()
|
||||||
|
marker = "/zoneinfo/"
|
||||||
|
marker_index = target.find(marker)
|
||||||
|
if marker_index >= 0:
|
||||||
|
return target[marker_index + len(marker):]
|
||||||
|
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))
|
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, activity_intervals) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)",
|
||||||
|
(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,
|
def _setup_full_store(conn, *, baseline=True, segment=True, days=30,
|
||||||
bw=1024*1024*100, coverage=0.95, samples_per_day=24,
|
bw=1024*1024*100, coverage=0.95, samples_per_day=24,
|
||||||
sample_ts="2026-09-30T10:00:00+00:00",
|
sample_ts="2026-09-30T10:00:00+00:00",
|
||||||
period_start="2026-09-01T00:00:00+00:00",
|
period_start="2026-09-01T00:00:00+00:00",
|
||||||
segment_opened="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:
|
if baseline:
|
||||||
_insert_baseline(conn, **(baseline_kw or {}))
|
_insert_baseline(conn, **(baseline_kw or {}))
|
||||||
if segment:
|
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)
|
_insert_day(conn, d, bw=bw, coverage=coverage, samples=samples_per_day)
|
||||||
if sample_ts:
|
if sample_ts:
|
||||||
_insert_sample(conn, 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")
|
d = (datetime(2026, 9, 25) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
_insert_day(conn, d, bw=1024*1024*100)
|
_insert_day(conn, d, bw=1024*1024*100)
|
||||||
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
|
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
|
||||||
|
_insert_complete_local_days(conn, "2026-09-29", 1)
|
||||||
proj = compute_projection(conn, _clock())
|
proj = compute_projection(conn, _clock())
|
||||||
assert proj.confidence_state == ConfidenceState.LIMITED
|
assert proj.confidence_state == ConfidenceState.LIMITED
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -230,6 +262,7 @@ class TestCI1StateMatrix:
|
|||||||
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
|
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_day(conn, d, bw=1024*1024*100, coverage=0.95, samples=24)
|
||||||
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
|
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
|
||||||
|
_insert_complete_local_days(conn, "2026-09-29", 1)
|
||||||
proj = compute_projection(conn, _clock())
|
proj = compute_projection(conn, _clock())
|
||||||
assert proj.confidence_state == ConfidenceState.LIMITED
|
assert proj.confidence_state == ConfidenceState.LIMITED
|
||||||
assert proj.warming_fact is not None
|
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)
|
_insert_day(conn, d, bw=1024*1024*100, coverage=0.95, samples=24)
|
||||||
stale_ts = (_clock() - timedelta(days=5)).isoformat()
|
stale_ts = (_clock() - timedelta(days=5)).isoformat()
|
||||||
_insert_sample(conn, stale_ts)
|
_insert_sample(conn, stale_ts)
|
||||||
|
_insert_complete_local_days(conn, "2026-08-30", 1)
|
||||||
proj = compute_projection(conn, _clock())
|
proj = compute_projection(conn, _clock())
|
||||||
assert proj.confidence_state == ConfidenceState.LIMITED
|
assert proj.confidence_state == ConfidenceState.LIMITED
|
||||||
assert proj.staleness_fact is not None
|
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")
|
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_day(conn, d, bw=1024*1024*100, coverage=0.95, samples=24)
|
||||||
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
|
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
|
||||||
|
_insert_complete_local_days(conn, "2026-09-29", 1)
|
||||||
proj = compute_projection(conn, _clock())
|
proj = compute_projection(conn, _clock())
|
||||||
assert proj.confidence_state == ConfidenceState.LIMITED
|
assert proj.confidence_state == ConfidenceState.LIMITED
|
||||||
assert proj.degraded_identity_fact is not None
|
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")
|
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
_insert_day(conn, d, bw=0)
|
_insert_day(conn, d, bw=0)
|
||||||
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
|
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
|
||||||
|
_insert_complete_local_days(conn, "2026-09-29", 1)
|
||||||
proj = compute_projection(conn, _clock())
|
proj = compute_projection(conn, _clock())
|
||||||
assert proj.confidence_state == ConfidenceState.UNSUPPORTED
|
assert proj.confidence_state == ConfidenceState.UNSUPPORTED
|
||||||
assert proj.zero_rate_fact is not None
|
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(
|
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(
|
status = get_status(
|
||||||
store_path=db, clock_now=_clock(), query_services=True, query_journal=False
|
store_path=db, clock_now=_clock(), query_services=True, query_journal=False
|
||||||
@@ -502,18 +538,6 @@ class TestCI2Parity:
|
|||||||
query_services=True, query_journal=False)
|
query_services=True, query_journal=False)
|
||||||
assert "no observations yet" in status.lower()
|
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):
|
def test_status_never_prompts(self):
|
||||||
status_src = (FENRIS_PKG / "status.py").read_text()
|
status_src = (FENRIS_PKG / "status.py").read_text()
|
||||||
assert "input(" not in status_src
|
assert "input(" not in status_src
|
||||||
@@ -603,12 +627,6 @@ class TestCI3ProhibitionSet:
|
|||||||
table_names.append(m.group(1))
|
table_names.append(m.group(1))
|
||||||
assert "projection" not in [t.lower() for t in table_names]
|
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):
|
def test_polkit_authorizes_one_binary(self):
|
||||||
"""Polkit authorizes exactly one binary: fenris-monitor. [8.5]"""
|
"""Polkit authorizes exactly one binary: fenris-monitor. [8.5]"""
|
||||||
monitor_src = (FENRIS_PKG / "monitor.py").read_text()
|
monitor_src = (FENRIS_PKG / "monitor.py").read_text()
|
||||||
@@ -688,18 +706,6 @@ class TestCI4WordingAndDisclosures:
|
|||||||
proj_src = (FENRIS_PKG / "projection.py").read_text()
|
proj_src = (FENRIS_PKG / "projection.py").read_text()
|
||||||
assert phrase in proj_src
|
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):
|
def test_no_observations_phrase(self):
|
||||||
phrase = "no observations yet"
|
phrase = "no observations yet"
|
||||||
status_src = (FENRIS_PKG / "status.py").read_text()
|
status_src = (FENRIS_PKG / "status.py").read_text()
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""Visible geometry and evidence boundaries of the terminal volume plot."""
|
||||||
|
from fenris.activity_plot import VolumePoint, volume_plot
|
||||||
|
from fenris.themes import get_graph_colors
|
||||||
|
|
||||||
|
|
||||||
|
def plot(points, selected=-1, width=48, height=10):
|
||||||
|
return volume_plot(points, width, height, selected, get_graph_colors("chalktone"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_trace_fits_viewport_and_uses_time_not_sample_index():
|
||||||
|
points = [VolumePoint(t, v, str(t)) for t, v in ((0, 0), (3, 50), (30, 100))]
|
||||||
|
text, columns, unit = plot(points)
|
||||||
|
assert unit == "B"
|
||||||
|
assert len(text.plain.splitlines()) == 10
|
||||||
|
assert all(len(line) <= 48 for line in text.plain.splitlines())
|
||||||
|
assert columns[1] - columns[0] < (columns[2] - columns[0]) / 5
|
||||||
|
assert any(0x2801 <= ord(c) <= 0x28ff for c in text.plain)
|
||||||
|
assert not any(c in text.plain for c in "█▒░")
|
||||||
|
assert "100.00" in text.plain and "0.00" in text.plain
|
||||||
|
|
||||||
|
|
||||||
|
def test_gap_breaks_trace_while_measured_zero_stays_on_axis():
|
||||||
|
text, columns, _ = plot([
|
||||||
|
VolumePoint(0, 0, "00:00"), VolumePoint(1, None, "00:03", "gap"),
|
||||||
|
VolumePoint(2, 100, "00:06"),
|
||||||
|
])
|
||||||
|
rows = text.plain.splitlines()
|
||||||
|
assert "?" in rows[-2]
|
||||||
|
# No invented intermediate dots on either side of the missing measurement.
|
||||||
|
for row in rows[:-2]:
|
||||||
|
assert all(c == " " for c in row[columns[0] + 1:columns[-1]])
|
||||||
|
assert 0x2801 <= ord(rows[-3][columns[0]]) <= 0x28ff
|
||||||
|
|
||||||
|
|
||||||
|
def test_partial_and_unallocated_totals_are_isolated_and_labelled():
|
||||||
|
text, columns, unit = plot([
|
||||||
|
VolumePoint(0, 1_000_000, "01", "partial"),
|
||||||
|
VolumePoint(1, 2_000_000, "02", "unallocated"),
|
||||||
|
VolumePoint(2, 3_000_000, "03"),
|
||||||
|
])
|
||||||
|
assert unit == "MB"
|
||||||
|
assert "~" in text.plain and "u" in text.plain
|
||||||
|
for row in text.plain.splitlines()[:-2]:
|
||||||
|
assert all(c == " " for c in row[columns[0] + 1:columns[1]])
|
||||||
|
|
||||||
|
|
||||||
|
def test_many_intervals_and_selection_fit_small_plot_without_losing_points():
|
||||||
|
points = [VolumePoint(i, i % 9 * 1_000_000, str(i)) for i in range(180)]
|
||||||
|
text, columns, _ = plot(points, selected=100, width=32, height=6)
|
||||||
|
assert len(columns) == len(points)
|
||||||
|
assert text.plain.splitlines()[-2][columns[100]] == "▼"
|
||||||
|
assert all(len(row) == 32 for row in text.plain.splitlines())
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""Activity-selection transitions independent from Textual widgets."""
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||||
|
|
||||||
|
from fenris.activity_selection import ActivitySelection, IntervalIdentity
|
||||||
|
|
||||||
|
|
||||||
|
def _interval(n):
|
||||||
|
return IntervalIdentity(f"start-{n}", f"end-{n}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_selection_starts_and_stays_on_newest_interval():
|
||||||
|
selection = ActivitySelection()
|
||||||
|
first = [_interval(1), _interval(2)]
|
||||||
|
|
||||||
|
selection.update_live(first)
|
||||||
|
assert selection.following_live
|
||||||
|
assert selection.selected_live_interval == first[-1]
|
||||||
|
|
||||||
|
second = first + [_interval(3)]
|
||||||
|
selection.update_live(second)
|
||||||
|
assert selection.selected_live_interval == second[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_keyboard_and_mouse_inspection_pin_interval_identity():
|
||||||
|
intervals = [_interval(1), _interval(2), _interval(3)]
|
||||||
|
keyboard = ActivitySelection()
|
||||||
|
mouse = ActivitySelection()
|
||||||
|
|
||||||
|
keyboard.update_live(intervals)
|
||||||
|
keyboard.move_live(intervals, -1)
|
||||||
|
mouse.update_live(intervals)
|
||||||
|
mouse.inspect_live(intervals, intervals[1])
|
||||||
|
|
||||||
|
assert keyboard.selected_live_interval == mouse.selected_live_interval == intervals[1]
|
||||||
|
assert not keyboard.following_live
|
||||||
|
assert not mouse.following_live
|
||||||
|
|
||||||
|
refreshed = [_interval(2), _interval(3), _interval(4)]
|
||||||
|
keyboard.update_live(refreshed)
|
||||||
|
mouse.update_live(refreshed)
|
||||||
|
assert keyboard.selected_live_interval == mouse.selected_live_interval == intervals[1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_expired_pin_explains_expiration_and_resumes_following():
|
||||||
|
selection = ActivitySelection()
|
||||||
|
intervals = [_interval(1), _interval(2), _interval(3)]
|
||||||
|
selection.update_live(intervals)
|
||||||
|
selection.inspect_live(intervals, intervals[0])
|
||||||
|
|
||||||
|
newest = _interval(4)
|
||||||
|
selection.update_live([_interval(2), _interval(3), newest])
|
||||||
|
|
||||||
|
assert selection.following_live
|
||||||
|
assert selection.selected_live_interval == newest
|
||||||
|
assert selection.live_interval_expired
|
||||||
|
|
||||||
|
|
||||||
|
def test_today_resets_expired_pin_and_read_write_toggle_keeps_identity():
|
||||||
|
selection = ActivitySelection()
|
||||||
|
intervals = [_interval(1), _interval(2)]
|
||||||
|
selection.update_live(intervals)
|
||||||
|
selection.inspect_live(intervals, intervals[0])
|
||||||
|
selection.update_live([intervals[1]])
|
||||||
|
newest = _interval(3)
|
||||||
|
selection.update_live([intervals[1], newest])
|
||||||
|
assert selection.live_interval_expired
|
||||||
|
|
||||||
|
selection.toggle_measure()
|
||||||
|
assert selection.measure == "read"
|
||||||
|
assert selection.selected_live_interval == newest
|
||||||
|
|
||||||
|
selection.select_history_date("2026-09-27")
|
||||||
|
selection.set_view("history")
|
||||||
|
selection.set_view("live")
|
||||||
|
assert selection.browse_date is None
|
||||||
|
assert selection.following_live
|
||||||
|
assert not selection.live_interval_expired
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_identity_uses_both_interval_boundaries():
|
||||||
|
first = IntervalIdentity("2026-09-28T10:00:00Z", "2026-09-28T10:03:00Z")
|
||||||
|
changed_start = IntervalIdentity("2026-09-28T09:57:00Z", "2026-09-28T10:03:00Z")
|
||||||
|
selection = ActivitySelection()
|
||||||
|
selection.update_live([first])
|
||||||
|
selection.inspect_live([first], first)
|
||||||
|
|
||||||
|
selection.update_live([changed_start])
|
||||||
|
|
||||||
|
assert selection.following_live
|
||||||
|
assert selection.selected_live_interval == changed_start
|
||||||
|
assert selection.live_interval_expired
|
||||||
@@ -79,6 +79,20 @@ def test_release_footer_verifies_the_clearsigned_checksum_asset():
|
|||||||
assert "sha256sum -c SHA256SUMS" in footer
|
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(
|
@pytest.mark.parametrize(
|
||||||
("changelog", "expected_error"),
|
("changelog", "expected_error"),
|
||||||
[
|
[
|
||||||
@@ -209,3 +223,86 @@ def test_release_request_command_reports_create_or_patch_decisions(tmp_path):
|
|||||||
"path": "/releases/17",
|
"path": "/releases/17",
|
||||||
"payload": {"body": "## [1.4.0] - 2026-09-10\n"},
|
"payload": {"body": "## [1.4.0] - 2026-09-10\n"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_availability_section_with_both():
|
||||||
|
extractor = _extractor_module()
|
||||||
|
result = extractor.format_availability_section(
|
||||||
|
available=["Debian/Ubuntu (deb)", "Fedora/openSUSE (rpm)"],
|
||||||
|
withheld=["Void Linux (xbps) — pending host acceptance"],
|
||||||
|
)
|
||||||
|
assert "Available: Debian/Ubuntu (deb), Fedora/openSUSE (rpm)" in result
|
||||||
|
assert "Withheld: Void Linux (xbps) — pending host acceptance" in result
|
||||||
|
assert "## Package formats" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_availability_section_available_only():
|
||||||
|
extractor = _extractor_module()
|
||||||
|
result = extractor.format_availability_section(
|
||||||
|
available=["Debian/Ubuntu (deb)", "Fedora/openSUSE (rpm)", "Void Linux (xbps)"],
|
||||||
|
withheld=[],
|
||||||
|
)
|
||||||
|
assert "Available: Debian/Ubuntu (deb), Fedora/openSUSE (rpm), Void Linux (xbps)" in result
|
||||||
|
assert "Withheld" not in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_availability_section_empty():
|
||||||
|
extractor = _extractor_module()
|
||||||
|
result = extractor.format_availability_section(available=[], withheld=[])
|
||||||
|
assert result == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_command_includes_format_availability(tmp_path):
|
||||||
|
changelog = tmp_path / "CHANGELOG.md"
|
||||||
|
changelog.write_text(
|
||||||
|
"# Changelog\n\n## [Unreleased]\n\n## [1.4.0] - 2026-09-10\n\n"
|
||||||
|
"### Added\n\n- Show a release summary.\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
str(EXTRACTOR_PATH),
|
||||||
|
str(changelog),
|
||||||
|
"1.4.0",
|
||||||
|
"--available",
|
||||||
|
"Debian/Ubuntu (deb)",
|
||||||
|
"--available",
|
||||||
|
"Fedora/openSUSE (rpm)",
|
||||||
|
"--withheld",
|
||||||
|
"Void Linux (xbps)",
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.returncode == 0
|
||||||
|
assert "## Package formats" in result.stdout
|
||||||
|
assert "Available: Debian/Ubuntu (deb), Fedora/openSUSE (rpm)" in result.stdout
|
||||||
|
assert "Withheld: Void Linux (xbps)" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_command_without_format_args_has_no_formats_section(tmp_path):
|
||||||
|
changelog = tmp_path / "CHANGELOG.md"
|
||||||
|
changelog.write_text(
|
||||||
|
"# Changelog\n\n## [Unreleased]\n\n## [1.4.0] - 2026-09-10\n\n"
|
||||||
|
"### Added\n\n- Show a release summary.\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
str(EXTRACTOR_PATH),
|
||||||
|
str(changelog),
|
||||||
|
"1.4.0",
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.returncode == 0
|
||||||
|
assert "## Package formats" not in result.stdout
|
||||||
|
|||||||
@@ -0,0 +1,873 @@
|
|||||||
|
"""Collector history tracer tests (issue #73).
|
||||||
|
|
||||||
|
Tests the end-to-end history pipeline: sample acquisition → interval
|
||||||
|
derivation → hour observation → day aggregate, with concurrent-read
|
||||||
|
safety, cross-hour handling, and display states.
|
||||||
|
|
||||||
|
Seams:
|
||||||
|
- write side: run_collection() → observation store
|
||||||
|
- read side: get_status(), compute_projection() → observation store
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||||
|
|
||||||
|
from fenris.collector import run_collection, normalize_identity
|
||||||
|
from fenris.store import init_store, get_store_path, SCHEMA_VERSION
|
||||||
|
from fenris.monitoring_periods import ensure_period_open, close_period, get_open_period
|
||||||
|
from fenris.day_aggregate import derive_day, derive_all_days
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def smartctl_fixture() -> Dict[str, Any]:
|
||||||
|
"""Minimal smartctl -a -j output with required fields."""
|
||||||
|
return {
|
||||||
|
"json_format_version": [1, 0],
|
||||||
|
"smartctl": {"version": [7, 3], "svn_revision": "5155", "build_info": "(local build)"},
|
||||||
|
"nvme_smart_health_information_log": {
|
||||||
|
"critical_warning": 0,
|
||||||
|
"temperature": 35,
|
||||||
|
"available_spare": 100,
|
||||||
|
"available_spare_threshold": 10,
|
||||||
|
"percentage_used": 5,
|
||||||
|
"data_units_written": 12345678,
|
||||||
|
"data_units_read": 9876543,
|
||||||
|
"power_on_hours": 8765,
|
||||||
|
"power_cycles": 1234,
|
||||||
|
"unsafe_shutdowns": 5,
|
||||||
|
"media_errors": 0,
|
||||||
|
"num_err_log_entries": 0,
|
||||||
|
},
|
||||||
|
"user_capacity": {"bytes": 1024000000000, "units": "bytes"},
|
||||||
|
"model_name": "Samsung SSD 970 EVO Plus 1TB",
|
||||||
|
"serial_number": "S4EWNX0N123456",
|
||||||
|
"firmware_version": "2B2QEXM7",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sysfs_fixture_tree(tmp_path: Path) -> Path:
|
||||||
|
"""Create a minimal sysfs fixture tree with controller identity."""
|
||||||
|
ctrl_dir = tmp_path / "sys" / "class" / "nvme" / "nvme0"
|
||||||
|
ctrl_dir.mkdir(parents=True)
|
||||||
|
(ctrl_dir / "subsysnqn").write_text("nqn.2014-08.org.nvmexpress:uuid:12345678-1234-1234-1234-123456789abc\n")
|
||||||
|
(ctrl_dir / "model").write_text("Samsung SSD 970 EVO Plus 1TB\n")
|
||||||
|
(ctrl_dir / "serial").write_text("S4EWNX0N123456\n")
|
||||||
|
(ctrl_dir / "firmware_rev").write_text("2B2QEXM7\n")
|
||||||
|
transport_dir = ctrl_dir / "transport"
|
||||||
|
transport_dir.mkdir()
|
||||||
|
(transport_dir / "address").write_text("0000:03:00.0")
|
||||||
|
(transport_dir / "trstring").write_text("pcie")
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def config_fixture(tmp_path: Path) -> Dict[str, Any]:
|
||||||
|
"""Configuration fixture naming the device."""
|
||||||
|
return {
|
||||||
|
"device": "/dev/nvme0",
|
||||||
|
"store_path": str(tmp_path / "observations.db"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeClock:
|
||||||
|
"""Injected clock returning controlled time."""
|
||||||
|
def __init__(self, initial: datetime):
|
||||||
|
self.now = initial
|
||||||
|
def utcnow(self):
|
||||||
|
return self.now
|
||||||
|
def advance(self, **kwargs):
|
||||||
|
self.now = self.now + timedelta(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Schema migration: existing data readable at real precision
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestSchemaMigration:
|
||||||
|
"""Schema migration 1→2 preserves existing data (issue #73 AC1)."""
|
||||||
|
|
||||||
|
def test_migration_bumps_version(self, tmp_path):
|
||||||
|
"""Migration from v1 to v2 succeeds."""
|
||||||
|
from fenris.store import migrate_to_latest, SCHEMA_VERSION
|
||||||
|
|
||||||
|
# Create a v1 store directly (simulating pre-migration state)
|
||||||
|
db = tmp_path / "test.db"
|
||||||
|
conn = sqlite3.connect(str(db))
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
# Create v1 schema manually
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE samples (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ts TEXT NOT NULL,
|
||||||
|
device TEXT NOT NULL,
|
||||||
|
subnqn TEXT, sn TEXT, mn TEXT, fr TEXT,
|
||||||
|
capacity_bytes INTEGER, percentage_used INTEGER,
|
||||||
|
available_spare INTEGER, media_errors INTEGER,
|
||||||
|
power_on_hours INTEGER, power_cycles INTEGER,
|
||||||
|
unsafe_shutdowns INTEGER, temperature_c INTEGER,
|
||||||
|
data_units_written INTEGER, data_units_read INTEGER,
|
||||||
|
bytes_written INTEGER, bytes_read INTEGER,
|
||||||
|
critical_warning INTEGER
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE hour_observations (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
hour TEXT NOT NULL UNIQUE,
|
||||||
|
active_seconds INTEGER DEFAULT 0, idle_seconds INTEGER DEFAULT 0,
|
||||||
|
powered_off_seconds INTEGER DEFAULT 0, unknown_seconds INTEGER DEFAULT 0,
|
||||||
|
bytes_written_delta INTEGER DEFAULT 0, bytes_read_delta INTEGER DEFAULT 0,
|
||||||
|
temperature_min INTEGER, temperature_avg REAL, temperature_max INTEGER,
|
||||||
|
sample_count INTEGER DEFAULT 0, coverage REAL DEFAULT 0.0
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE day_aggregates (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
day TEXT NOT NULL UNIQUE,
|
||||||
|
active_seconds INTEGER DEFAULT 0, idle_seconds INTEGER DEFAULT 0,
|
||||||
|
powered_off_seconds INTEGER DEFAULT 0, unknown_seconds INTEGER DEFAULT 0,
|
||||||
|
bytes_written_delta INTEGER DEFAULT 0, bytes_read_delta INTEGER DEFAULT 0,
|
||||||
|
sample_count INTEGER DEFAULT 0, coverage REAL DEFAULT 0.0
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("CREATE TABLE monitoring_periods (id INTEGER PRIMARY KEY AUTOINCREMENT, started_at TEXT NOT NULL, ended_at TEXT, end_cause TEXT)")
|
||||||
|
conn.execute("CREATE TABLE controller_segments (id INTEGER PRIMARY KEY AUTOINCREMENT, opened_at TEXT NOT NULL, identity_key TEXT, identity_degraded BOOLEAN DEFAULT 0, subnqn TEXT, sn TEXT, mn TEXT, fr TEXT, vid TEXT, ssvid TEXT, transport TEXT)")
|
||||||
|
conn.execute("CREATE TABLE endurance_baseline (id INTEGER PRIMARY KEY AUTOINCREMENT, tbw_terabytes REAL NOT NULL, source_url TEXT, document_revision TEXT, entry_date TEXT, model_string TEXT, nominal_capacity_bytes INTEGER, validated_by TEXT, verified BOOLEAN DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)")
|
||||||
|
conn.execute("CREATE TABLE store_metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL)")
|
||||||
|
conn.execute("PRAGMA user_version=1")
|
||||||
|
conn.execute("INSERT INTO samples (ts, device, mn, sn, fr, capacity_bytes, percentage_used, available_spare, media_errors, power_on_hours, power_cycles, unsafe_shutdowns, temperature_c, data_units_written, data_units_read, bytes_written, bytes_read, critical_warning) VALUES ('2026-09-01T12:00:00+00:00', '/dev/nvme0', 'Test', 'SN', 'FR', 1000000000000, 5, 100, 0, 1000, 100, 0, 35, 1000000, 500000, 512000000000, 256000000000, 0)")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Migrate
|
||||||
|
steps = migrate_to_latest(db)
|
||||||
|
assert steps == 5 # v1→v2→v3→v4→v5→v6
|
||||||
|
|
||||||
|
# Verify data preserved
|
||||||
|
conn = sqlite3.connect(str(db))
|
||||||
|
row = conn.execute("SELECT ts, mn FROM samples").fetchone()
|
||||||
|
version = conn.execute("PRAGMA user_version").fetchone()[0]
|
||||||
|
conn.close()
|
||||||
|
assert row[0] == "2026-09-01T12:00:00+00:00"
|
||||||
|
assert row[1] == "Test"
|
||||||
|
assert version == SCHEMA_VERSION
|
||||||
|
|
||||||
|
def test_newer_schema_refused(self, tmp_path):
|
||||||
|
"""Store with user_version > SCHEMA_VERSION is refused."""
|
||||||
|
db = tmp_path / "test.db"
|
||||||
|
conn = sqlite3.connect(str(db))
|
||||||
|
conn.execute("PRAGMA user_version=%d" % (SCHEMA_VERSION + 1))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="newer Fenris"):
|
||||||
|
init_store(db)
|
||||||
|
|
||||||
|
def test_existing_data_preserved_after_migration(self, config_fixture,
|
||||||
|
smartctl_fixture,
|
||||||
|
sysfs_fixture_tree):
|
||||||
|
"""Existing sample data is not lost or modified by migration."""
|
||||||
|
clock = FakeClock(datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc))
|
||||||
|
|
||||||
|
# Write first sample
|
||||||
|
run_collection(smartctl_fixture, sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture, clock)
|
||||||
|
|
||||||
|
conn = sqlite3.connect(config_fixture["store_path"])
|
||||||
|
row = conn.execute("SELECT ts, device, bytes_written FROM samples").fetchone()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
assert row[0] == "2026-09-01T12:00:00+00:00"
|
||||||
|
assert row[1] == "/dev/nvme0"
|
||||||
|
assert row[2] == 12345678 * 512000
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Collector publishes samples, intervals, hour observations, day aggregates
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestCollectorDerivation:
|
||||||
|
"""Collector derives hour observations and day aggregates (issue #73 AC2)."""
|
||||||
|
|
||||||
|
def _make_sample(self, duw_units: int, ts: str) -> Dict[str, Any]:
|
||||||
|
"""Build a smartctl fixture with specific DUW."""
|
||||||
|
return {
|
||||||
|
"json_format_version": [1, 0],
|
||||||
|
"smartctl": {"version": [7, 3], "svn_revision": "5155", "build_info": "(local build)"},
|
||||||
|
"nvme_smart_health_information_log": {
|
||||||
|
"critical_warning": 0,
|
||||||
|
"temperature": 35,
|
||||||
|
"available_spare": 100,
|
||||||
|
"available_spare_threshold": 10,
|
||||||
|
"percentage_used": 5,
|
||||||
|
"data_units_written": duw_units,
|
||||||
|
"data_units_read": 9876543,
|
||||||
|
"power_on_hours": 8765,
|
||||||
|
"power_cycles": 1234,
|
||||||
|
"unsafe_shutdowns": 5,
|
||||||
|
"media_errors": 0,
|
||||||
|
"num_err_log_entries": 0,
|
||||||
|
},
|
||||||
|
"user_capacity": {"bytes": 1024000000000, "units": "bytes"},
|
||||||
|
"model_name": "Samsung SSD 970 EVO Plus 1TB",
|
||||||
|
"serial_number": "S4EWNX0N123456",
|
||||||
|
"firmware_version": "2B2QEXM7",
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_same_hour_20mb_derives_hour_obs(self, config_fixture, sysfs_fixture_tree):
|
||||||
|
"""Two samples in same hour with 20 MB delta → hour_obs gets 20 MB."""
|
||||||
|
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
# DUW units: 12345678 * 512000 = ~6.3 TB; 20 MB = 20*1024*1024 / 512000 ≈ 40 units
|
||||||
|
duw1 = 12345678
|
||||||
|
duw2 = duw1 + 40 # ~20 MB more
|
||||||
|
|
||||||
|
clock1 = FakeClock(t1)
|
||||||
|
s1 = self._make_sample(duw1, t1.isoformat())
|
||||||
|
r1 = run_collection(s1, sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture, clock1)
|
||||||
|
assert r1["ok"]
|
||||||
|
|
||||||
|
clock2 = FakeClock(t2)
|
||||||
|
s2 = self._make_sample(duw2, t2.isoformat())
|
||||||
|
r2 = run_collection(s2, sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture, clock2)
|
||||||
|
assert r2["ok"]
|
||||||
|
|
||||||
|
# Check hour_observation was derived
|
||||||
|
conn = sqlite3.connect(config_fixture["store_path"])
|
||||||
|
hour = conn.execute(
|
||||||
|
"SELECT bytes_written_delta, sample_count FROM hour_observations WHERE hour LIKE '2026-09-01T12%'"
|
||||||
|
).fetchone()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
assert hour is not None, "Hour observation should exist for 12:00"
|
||||||
|
assert hour[1] >= 2 # at least 2 samples contributed
|
||||||
|
# bytes_written_delta should be the 20 MB delta (40 * 512000 = 20480000)
|
||||||
|
assert hour[0] == 40 * 512000
|
||||||
|
|
||||||
|
def test_zero_delta_derives_hour_obs(self, config_fixture, sysfs_fixture_tree):
|
||||||
|
"""Two samples in same hour with no DUW change → 0 B written."""
|
||||||
|
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
duw = 12345678 # same for both
|
||||||
|
|
||||||
|
clock1 = FakeClock(t1)
|
||||||
|
s1 = self._make_sample(duw, t1.isoformat())
|
||||||
|
run_collection(s1, sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture, clock1)
|
||||||
|
|
||||||
|
clock2 = FakeClock(t2)
|
||||||
|
s2 = self._make_sample(duw, t2.isoformat())
|
||||||
|
run_collection(s2, sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture, clock2)
|
||||||
|
|
||||||
|
conn = sqlite3.connect(config_fixture["store_path"])
|
||||||
|
hour = conn.execute(
|
||||||
|
"SELECT bytes_written_delta FROM hour_observations WHERE hour LIKE '2026-09-01T12%'"
|
||||||
|
).fetchone()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
assert hour is not None
|
||||||
|
assert hour[0] == 0
|
||||||
|
|
||||||
|
def test_cross_hour_100mb_unattributed(self, config_fixture, sysfs_fixture_tree):
|
||||||
|
"""Samples in different hours → delta is unattributed to any hour."""
|
||||||
|
t1 = datetime(2026, 9, 1, 11, 55, 0, tzinfo=timezone.utc)
|
||||||
|
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
duw1 = 12345678
|
||||||
|
duw2 = duw1 + 200 # ~100 MB
|
||||||
|
|
||||||
|
clock1 = FakeClock(t1)
|
||||||
|
s1 = self._make_sample(duw1, t1.isoformat())
|
||||||
|
run_collection(s1, sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture, clock1)
|
||||||
|
|
||||||
|
clock2 = FakeClock(t2)
|
||||||
|
s2 = self._make_sample(duw2, t2.isoformat())
|
||||||
|
run_collection(s2, sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture, clock2)
|
||||||
|
|
||||||
|
conn = sqlite3.connect(config_fixture["store_path"])
|
||||||
|
|
||||||
|
# Hour observations should NOT contain the cross-hour delta
|
||||||
|
hour11 = conn.execute(
|
||||||
|
"SELECT bytes_written_delta FROM hour_observations WHERE hour LIKE '2026-09-01T11%'"
|
||||||
|
).fetchone()
|
||||||
|
hour12 = conn.execute(
|
||||||
|
"SELECT bytes_written_delta FROM hour_observations WHERE hour LIKE '2026-09-01T12%'"
|
||||||
|
).fetchone()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Neither hour should have the full 100 MB delta attributed
|
||||||
|
# (they may have 0 or partial, but not 200*512000)
|
||||||
|
full_delta = 200 * 512000
|
||||||
|
if hour11 is not None:
|
||||||
|
assert hour11[0] != full_delta, "Hour 11 should not have full cross-hour delta"
|
||||||
|
if hour12 is not None:
|
||||||
|
assert hour12[0] != full_delta, "Hour 12 should not have full cross-hour delta"
|
||||||
|
|
||||||
|
def test_readonly_sees_consistent_snapshot(self, config_fixture, sysfs_fixture_tree):
|
||||||
|
"""A read-only reader sees valid pre- or post-publication snapshot."""
|
||||||
|
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
clock1 = FakeClock(t1)
|
||||||
|
run_collection(self._make_sample(12345678, t1.isoformat()),
|
||||||
|
sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture, clock1)
|
||||||
|
|
||||||
|
# Open read-only
|
||||||
|
ro_conn = sqlite3.connect(
|
||||||
|
"file:%s?mode=ro" % config_fixture["store_path"], uri=True
|
||||||
|
)
|
||||||
|
count_before = ro_conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0]
|
||||||
|
ro_conn.close()
|
||||||
|
assert count_before == 1
|
||||||
|
|
||||||
|
# Write second sample
|
||||||
|
clock2 = FakeClock(t2)
|
||||||
|
run_collection(self._make_sample(12345718, t2.isoformat()),
|
||||||
|
sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture, clock2)
|
||||||
|
|
||||||
|
# Read-only reader sees 2 samples now
|
||||||
|
ro_conn2 = sqlite3.connect(
|
||||||
|
"file:%s?mode=ro" % config_fixture["store_path"], uri=True
|
||||||
|
)
|
||||||
|
count_after = ro_conn2.execute("SELECT COUNT(*) FROM samples").fetchone()[0]
|
||||||
|
ro_conn2.close()
|
||||||
|
assert count_after == 2
|
||||||
|
|
||||||
|
|
||||||
|
class TestCollectionAtomicity:
|
||||||
|
"""Collection exposes sample and derived evidence as one publication."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_collection_is_visible_through_cli_and_tui_readers(
|
||||||
|
self, config_fixture, smartctl_fixture, sysfs_fixture_tree, monkeypatch
|
||||||
|
):
|
||||||
|
"""Ordinary readers observe matching published sample and activity evidence."""
|
||||||
|
from fenris.status import get_status, read_status
|
||||||
|
from fenris.tui import FenrisTuiApp
|
||||||
|
|
||||||
|
monkeypatch.setenv("TZ", "UTC")
|
||||||
|
service = {
|
||||||
|
"boot_enabled": True,
|
||||||
|
"timer_active": True,
|
||||||
|
"last_collect_ok": True,
|
||||||
|
}
|
||||||
|
monkeypatch.setattr("fenris.status.query_service_state", lambda: service)
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc).replace(
|
||||||
|
minute=35, second=0, microsecond=0,
|
||||||
|
)
|
||||||
|
first = {
|
||||||
|
**smartctl_fixture,
|
||||||
|
"nvme_smart_health_information_log": {
|
||||||
|
**smartctl_fixture["nvme_smart_health_information_log"],
|
||||||
|
"data_units_written": 12345678,
|
||||||
|
"data_units_read": 9876543,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
second = {
|
||||||
|
**smartctl_fixture,
|
||||||
|
"nvme_smart_health_information_log": {
|
||||||
|
**smartctl_fixture["nvme_smart_health_information_log"],
|
||||||
|
"data_units_written": 12345698,
|
||||||
|
"data_units_read": 9876553,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
sysfs_path = sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0"
|
||||||
|
|
||||||
|
assert run_collection(
|
||||||
|
first, sysfs_path, config_fixture, FakeClock(now - timedelta(minutes=5))
|
||||||
|
)["ok"] is True
|
||||||
|
assert run_collection(
|
||||||
|
second, sysfs_path, config_fixture, FakeClock(now)
|
||||||
|
)["ok"] is True
|
||||||
|
|
||||||
|
store_path = Path(config_fixture["store_path"])
|
||||||
|
with read_status(store_path, now, query_services=False) as (reader, composition):
|
||||||
|
assert reader is not None
|
||||||
|
assert composition.sample_count == 2
|
||||||
|
assert composition.day_count == 1
|
||||||
|
utc_day = reader.execute(
|
||||||
|
"SELECT bytes_written_delta, bytes_read_delta FROM day_aggregates"
|
||||||
|
).fetchone()
|
||||||
|
local_day = reader.execute(
|
||||||
|
"SELECT bytes_written, bytes_read FROM local_days"
|
||||||
|
).fetchone()
|
||||||
|
assert tuple(utc_day) == (10_240_000, 5_120_000)
|
||||||
|
assert tuple(local_day) == (10_240_000, 5_120_000)
|
||||||
|
|
||||||
|
cli_output = get_status(
|
||||||
|
store_path, now, query_services=True, query_journal=False
|
||||||
|
)
|
||||||
|
assert "Monitoring" in cli_output
|
||||||
|
|
||||||
|
app = FenrisTuiApp(store_path=store_path)
|
||||||
|
async with app.run_test(size=(100, 30)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
live_readout = str(app.query_one("#live-readout").render())
|
||||||
|
assert "W 0.010 GB" in live_readout
|
||||||
|
assert "R 0.005 GB" in live_readout
|
||||||
|
|
||||||
|
repeated_result = run_collection(
|
||||||
|
second, sysfs_path, config_fixture, FakeClock(now + timedelta(minutes=5))
|
||||||
|
)
|
||||||
|
assert repeated_result["ok"] is True
|
||||||
|
with read_status(
|
||||||
|
store_path, now + timedelta(minutes=5), query_services=False
|
||||||
|
) as (reader, composition):
|
||||||
|
assert reader is not None
|
||||||
|
assert composition.sample_count == 3
|
||||||
|
utc_day = reader.execute(
|
||||||
|
"SELECT bytes_written_delta, bytes_read_delta FROM day_aggregates"
|
||||||
|
).fetchone()
|
||||||
|
local_day = reader.execute(
|
||||||
|
"SELECT bytes_written, bytes_read FROM local_days"
|
||||||
|
).fetchone()
|
||||||
|
assert tuple(utc_day) == (10_240_000, 5_120_000)
|
||||||
|
assert tuple(local_day) == (10_240_000, 5_120_000)
|
||||||
|
|
||||||
|
def test_failed_local_day_publication_keeps_previous_publication(
|
||||||
|
self, config_fixture, smartctl_fixture, sysfs_fixture_tree, monkeypatch
|
||||||
|
):
|
||||||
|
"""A failed final derivation step leaves all prior reader state intact."""
|
||||||
|
from fenris.status import read_status
|
||||||
|
|
||||||
|
monkeypatch.setenv("TZ", "UTC")
|
||||||
|
now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
|
||||||
|
first_clock = FakeClock(now)
|
||||||
|
first = {
|
||||||
|
**smartctl_fixture,
|
||||||
|
"nvme_smart_health_information_log": {
|
||||||
|
**smartctl_fixture["nvme_smart_health_information_log"],
|
||||||
|
"data_units_written": 12345678,
|
||||||
|
"data_units_read": 9876543,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
first_result = run_collection(
|
||||||
|
first,
|
||||||
|
sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture,
|
||||||
|
first_clock,
|
||||||
|
)
|
||||||
|
assert first_result["ok"] is True, first_result
|
||||||
|
|
||||||
|
writer = sqlite3.connect(config_fixture["store_path"])
|
||||||
|
writer.execute(
|
||||||
|
"CREATE TRIGGER fail_local_day_publication "
|
||||||
|
"BEFORE INSERT ON local_days "
|
||||||
|
"BEGIN SELECT RAISE(ABORT, 'injected local-day publication failure'); END"
|
||||||
|
)
|
||||||
|
writer.commit()
|
||||||
|
writer.close()
|
||||||
|
|
||||||
|
next_time = now + timedelta(minutes=5)
|
||||||
|
second = {
|
||||||
|
**smartctl_fixture,
|
||||||
|
"nvme_smart_health_information_log": {
|
||||||
|
**smartctl_fixture["nvme_smart_health_information_log"],
|
||||||
|
"data_units_written": 12345698,
|
||||||
|
"data_units_read": 9876548,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
failed_result = run_collection(
|
||||||
|
second,
|
||||||
|
sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture,
|
||||||
|
FakeClock(next_time),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert failed_result["ok"] is False
|
||||||
|
assert "injected local-day publication failure" in failed_result["error"]
|
||||||
|
|
||||||
|
with read_status(
|
||||||
|
Path(config_fixture["store_path"]), next_time, query_services=False
|
||||||
|
) as (reader, composition):
|
||||||
|
assert reader is not None
|
||||||
|
assert composition.sample_count == 1
|
||||||
|
public_counts = reader.execute(
|
||||||
|
"SELECT (SELECT COUNT(*) FROM samples), "
|
||||||
|
"(SELECT COUNT(*) FROM controller_segments), "
|
||||||
|
"(SELECT COUNT(*) FROM monitoring_periods), "
|
||||||
|
"(SELECT COUNT(*) FROM hour_observations), "
|
||||||
|
"(SELECT COUNT(*) FROM day_aggregates), "
|
||||||
|
"(SELECT COUNT(*) FROM local_days)"
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
assert tuple(public_counts) == (1, 1, 1, 0, 0, 0)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Display states: awaiting first sample, awaiting another sample
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestDisplayStates:
|
||||||
|
"""Display states for zero/one/two+ samples (issue #73 AC3)."""
|
||||||
|
|
||||||
|
def test_zero_samples_awaiting_first(self, tmp_path):
|
||||||
|
"""Zero samples → 'awaiting first sample' state."""
|
||||||
|
from fenris.status import get_status
|
||||||
|
db = tmp_path / "test.db"
|
||||||
|
init_store(db)
|
||||||
|
now = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
from unittest.mock import patch
|
||||||
|
with patch("fenris.status.query_service_state", return_value={
|
||||||
|
"boot_enabled": False, "timer_active": False,
|
||||||
|
"last_collect_ok": None, "last_collect_age_s": None,
|
||||||
|
"last_collect_reason": None,
|
||||||
|
}):
|
||||||
|
result = get_status(store_path=db, clock_now=now,
|
||||||
|
query_services=False, query_journal=False)
|
||||||
|
assert "no observations yet" in result.lower() or "awaiting" in result.lower()
|
||||||
|
|
||||||
|
def test_one_sample_awaiting_another(self, tmp_path):
|
||||||
|
"""One sample → 'awaiting another sample' state."""
|
||||||
|
from fenris.status import get_status
|
||||||
|
db = tmp_path / "test.db"
|
||||||
|
conn = init_store(db)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO samples (ts, device, mn, sn, fr, capacity_bytes, "
|
||||||
|
"percentage_used, available_spare, media_errors, power_on_hours, "
|
||||||
|
"power_cycles, unsafe_shutdowns, temperature_c, "
|
||||||
|
"data_units_written, data_units_read, bytes_written, bytes_read, "
|
||||||
|
"critical_warning) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
("2026-09-01T12:00:00+00:00", "/dev/nvme0", "Test", "SN", "FR",
|
||||||
|
1000000000000, 5, 100, 0, 1000, 100, 0, 35,
|
||||||
|
1000000, 500000, 512000000000, 256000000000, 0),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
now = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
from unittest.mock import patch
|
||||||
|
with patch("fenris.status.query_service_state", return_value={
|
||||||
|
"boot_enabled": False, "timer_active": False,
|
||||||
|
"last_collect_ok": None, "last_collect_age_s": None,
|
||||||
|
"last_collect_reason": None,
|
||||||
|
}):
|
||||||
|
result = get_status(store_path=db, clock_now=now,
|
||||||
|
query_services=False, query_journal=False)
|
||||||
|
# Should mention awaiting or insufficient data
|
||||||
|
lower = result.lower()
|
||||||
|
assert "awaiting" in lower or "another sample" in lower or "no projection" in lower
|
||||||
|
|
||||||
|
def test_one_sample_awaiting_another_in_tui(self, tmp_path):
|
||||||
|
"""One sample → TUI shows awaiting state."""
|
||||||
|
from fenris.tui import FenrisTuiApp
|
||||||
|
db = tmp_path / "test.db"
|
||||||
|
conn = init_store(db)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO samples (ts, device, mn, sn, fr, capacity_bytes, "
|
||||||
|
"percentage_used, available_spare, media_errors, power_on_hours, "
|
||||||
|
"power_cycles, unsafe_shutdowns, temperature_c, "
|
||||||
|
"data_units_written, data_units_read, bytes_written, bytes_read, "
|
||||||
|
"critical_warning) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
("2026-09-01T12:00:00+00:00", "/dev/nvme0", "Test", "SN", "FR",
|
||||||
|
1000000000000, 5, 100, 0, 1000, 100, 0, 35,
|
||||||
|
1000000, 500000, 512000000000, 256000000000, 0),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
now = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
# Test the projection handles single sample
|
||||||
|
from fenris.projection import compute_projection, ConfidenceState
|
||||||
|
conn = sqlite3.connect(db)
|
||||||
|
proj = compute_projection(conn, now)
|
||||||
|
conn.close()
|
||||||
|
# With only one sample, projection should be unavailable
|
||||||
|
assert proj.confidence_state == ConfidenceState.UNSUPPORTED
|
||||||
|
|
||||||
|
def test_two_same_hour_samples_show_measured_usage(self, config_fixture, sysfs_fixture_tree):
|
||||||
|
"""Two compatible same-hour samples show measured usage."""
|
||||||
|
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
clock1 = FakeClock(t1)
|
||||||
|
run_collection(self._make_sample_helper(12345678), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture, clock1)
|
||||||
|
|
||||||
|
clock2 = FakeClock(t2)
|
||||||
|
run_collection(self._make_sample_helper(12345718), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture, clock2)
|
||||||
|
|
||||||
|
from fenris.status import get_status
|
||||||
|
from unittest.mock import patch
|
||||||
|
now = datetime(2026, 9, 1, 12, 10, 0, tzinfo=timezone.utc)
|
||||||
|
with patch("fenris.status.query_service_state", return_value={
|
||||||
|
"boot_enabled": False, "timer_active": False,
|
||||||
|
"last_collect_ok": None, "last_collect_age_s": None,
|
||||||
|
"last_collect_reason": None,
|
||||||
|
}):
|
||||||
|
result = get_status(store_path=Path(config_fixture["store_path"]),
|
||||||
|
clock_now=now, query_services=False, query_journal=False)
|
||||||
|
# Should not say "no observations" or "awaiting"
|
||||||
|
lower = result.lower()
|
||||||
|
assert "no observations yet" not in lower
|
||||||
|
|
||||||
|
def _make_sample_helper(self, duw_units: int) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"json_format_version": [1, 0],
|
||||||
|
"smartctl": {"version": [7, 3], "svn_revision": "5155", "build_info": "(local build)"},
|
||||||
|
"nvme_smart_health_information_log": {
|
||||||
|
"critical_warning": 0, "temperature": 35,
|
||||||
|
"available_spare": 100, "available_spare_threshold": 10,
|
||||||
|
"percentage_used": 5, "data_units_written": duw_units,
|
||||||
|
"data_units_read": 9876543, "power_on_hours": 8765,
|
||||||
|
"power_cycles": 1234, "unsafe_shutdowns": 5,
|
||||||
|
"media_errors": 0, "num_err_log_entries": 0,
|
||||||
|
},
|
||||||
|
"user_capacity": {"bytes": 1024000000000, "units": "bytes"},
|
||||||
|
"model_name": "Samsung SSD 970 EVO Plus 1TB",
|
||||||
|
"serial_number": "S4EWNX0N123456",
|
||||||
|
"firmware_version": "2B2QEXM7",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Pause crossing and counter reset
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestPauseCrossing:
|
||||||
|
"""Delta across monitoring period gap (issue #73 AC4)."""
|
||||||
|
|
||||||
|
def test_pause_crossing_preserves_prior_history(self, config_fixture, sysfs_fixture_tree):
|
||||||
|
"""Delta across a paused period preserves prior hour observations."""
|
||||||
|
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
t_pause = datetime(2026, 9, 1, 13, 0, 0, tzinfo=timezone.utc)
|
||||||
|
t_resume = datetime(2026, 9, 1, 14, 0, 0, tzinfo=timezone.utc)
|
||||||
|
t2 = datetime(2026, 9, 1, 14, 5, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
duw1 = 12345678
|
||||||
|
duw2 = duw1 + 100
|
||||||
|
|
||||||
|
# First sample (opens period)
|
||||||
|
clock1 = FakeClock(t1)
|
||||||
|
run_collection(self._make_sample_for_pause(duw1), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture, clock1)
|
||||||
|
|
||||||
|
# Pause
|
||||||
|
conn = sqlite3.connect(config_fixture["store_path"])
|
||||||
|
close_period(conn, t_pause, "user_disabled")
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Resume with new sample
|
||||||
|
clock_resume = FakeClock(t_resume)
|
||||||
|
run_collection(self._make_sample_for_pause(duw1), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture, clock_resume)
|
||||||
|
|
||||||
|
# Second sample after resume
|
||||||
|
clock2 = FakeClock(t2)
|
||||||
|
run_collection(self._make_sample_for_pause(duw2), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture, clock2)
|
||||||
|
|
||||||
|
# Verify prior hour observations are intact
|
||||||
|
conn = sqlite3.connect(config_fixture["store_path"])
|
||||||
|
hour_12 = conn.execute(
|
||||||
|
"SELECT bytes_written_delta FROM hour_observations WHERE hour LIKE '2026-09-01T12%'"
|
||||||
|
).fetchone()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Hour 12 should still have data from the first collection
|
||||||
|
assert hour_12 is not None, "Prior hour observation should be preserved"
|
||||||
|
|
||||||
|
def _make_sample_for_pause(self, duw_units: int) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"json_format_version": [1, 0],
|
||||||
|
"smartctl": {"version": [7, 3], "svn_revision": "5155", "build_info": "(local build)"},
|
||||||
|
"nvme_smart_health_information_log": {
|
||||||
|
"critical_warning": 0, "temperature": 35,
|
||||||
|
"available_spare": 100, "available_spare_threshold": 10,
|
||||||
|
"percentage_used": 5, "data_units_written": duw_units,
|
||||||
|
"data_units_read": 9876543, "power_on_hours": 8765,
|
||||||
|
"power_cycles": 1234, "unsafe_shutdowns": 5,
|
||||||
|
"media_errors": 0, "num_err_log_entries": 0,
|
||||||
|
},
|
||||||
|
"user_capacity": {"bytes": 1024000000000, "units": "bytes"},
|
||||||
|
"model_name": "Samsung SSD 970 EVO Plus 1TB",
|
||||||
|
"serial_number": "S4EWNX0N123456",
|
||||||
|
"firmware_version": "2B2QEXM7",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestCounterReset:
|
||||||
|
"""Counter reset / replacement opens new segment (issue #73 AC6)."""
|
||||||
|
|
||||||
|
def test_duw_decrease_opens_new_segment(self, config_fixture, sysfs_fixture_tree):
|
||||||
|
"""DUW decrease triggers new segment."""
|
||||||
|
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
duw1 = 12345678
|
||||||
|
duw2 = duw1 - 100 # decrease = reset
|
||||||
|
|
||||||
|
clock1 = FakeClock(t1)
|
||||||
|
run_collection(self._make_sample_for_reset(duw1), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture, clock1)
|
||||||
|
|
||||||
|
clock2 = FakeClock(t2)
|
||||||
|
run_collection(self._make_sample_for_reset(duw2), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture, clock2)
|
||||||
|
|
||||||
|
conn = sqlite3.connect(config_fixture["store_path"])
|
||||||
|
segments = conn.execute("SELECT COUNT(*) FROM controller_segments").fetchone()[0]
|
||||||
|
samples = conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0]
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Should have 2 segments (new one opened for DUW decrease)
|
||||||
|
assert segments == 2
|
||||||
|
# Should have 2 samples
|
||||||
|
assert samples == 2
|
||||||
|
|
||||||
|
def _make_sample_for_reset(self, duw_units: int) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"json_format_version": [1, 0],
|
||||||
|
"smartctl": {"version": [7, 3], "svn_revision": "5155", "build_info": "(local build)"},
|
||||||
|
"nvme_smart_health_information_log": {
|
||||||
|
"critical_warning": 0, "temperature": 35,
|
||||||
|
"available_spare": 100, "available_spare_threshold": 10,
|
||||||
|
"percentage_used": 5, "data_units_written": duw_units,
|
||||||
|
"data_units_read": 9876543, "power_on_hours": 8765,
|
||||||
|
"power_cycles": 1234, "unsafe_shutdowns": 5,
|
||||||
|
"media_errors": 0, "num_err_log_entries": 0,
|
||||||
|
},
|
||||||
|
"user_capacity": {"bytes": 1024000000000, "units": "bytes"},
|
||||||
|
"model_name": "Samsung SSD 970 EVO Plus 1TB",
|
||||||
|
"serial_number": "S4EWNX0N123456",
|
||||||
|
"firmware_version": "2B2QEXM7",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Derivation failure preserves prior history
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestDerivationFailure:
|
||||||
|
"""Injected derivation failure preserves prior history (issue #73 AC6)."""
|
||||||
|
|
||||||
|
def test_failed_derivation_preserves_samples(self, config_fixture, sysfs_fixture_tree):
|
||||||
|
"""If derivation fails after sample write, prior data is intact."""
|
||||||
|
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
clock1 = FakeClock(t1)
|
||||||
|
run_collection(self._make_sample_for_failure(12345678),
|
||||||
|
sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture, clock1)
|
||||||
|
|
||||||
|
# Verify first sample exists
|
||||||
|
conn = sqlite3.connect(config_fixture["store_path"])
|
||||||
|
count = conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0]
|
||||||
|
conn.close()
|
||||||
|
assert count == 1
|
||||||
|
|
||||||
|
# Second sample with valid data should succeed
|
||||||
|
clock2 = FakeClock(t2)
|
||||||
|
r2 = run_collection(self._make_sample_for_failure(12345718),
|
||||||
|
sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture, clock2)
|
||||||
|
assert r2["ok"]
|
||||||
|
|
||||||
|
# Both samples should exist
|
||||||
|
conn = sqlite3.connect(config_fixture["store_path"])
|
||||||
|
count = conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0]
|
||||||
|
conn.close()
|
||||||
|
assert count == 2
|
||||||
|
|
||||||
|
def _make_sample_for_failure(self, duw_units: int) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"json_format_version": [1, 0],
|
||||||
|
"smartctl": {"version": [7, 3], "svn_revision": "5155", "build_info": "(local build)"},
|
||||||
|
"nvme_smart_health_information_log": {
|
||||||
|
"critical_warning": 0, "temperature": 35,
|
||||||
|
"available_spare": 100, "available_spare_threshold": 10,
|
||||||
|
"percentage_used": 5, "data_units_written": duw_units,
|
||||||
|
"data_units_read": 9876543, "power_on_hours": 8765,
|
||||||
|
"power_cycles": 1234, "unsafe_shutdowns": 5,
|
||||||
|
"media_errors": 0, "num_err_log_entries": 0,
|
||||||
|
},
|
||||||
|
"user_capacity": {"bytes": 1024000000000, "units": "bytes"},
|
||||||
|
"model_name": "Samsung SSD 970 EVO Plus 1TB",
|
||||||
|
"serial_number": "S4EWNX0N123456",
|
||||||
|
"firmware_version": "2B2QEXM7",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Monitoring period is opened by collector
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestMonitoringPeriod:
|
||||||
|
"""Collector ensures monitoring period is open (issue #73 AC2)."""
|
||||||
|
|
||||||
|
def test_first_sample_opens_period(self, config_fixture, sysfs_fixture_tree):
|
||||||
|
"""First collection run opens a monitoring period."""
|
||||||
|
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
clock1 = FakeClock(t1)
|
||||||
|
run_collection(self._make_sample_simple(), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture, clock1)
|
||||||
|
|
||||||
|
conn = sqlite3.connect(config_fixture["store_path"])
|
||||||
|
period = get_open_period(conn)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
assert period is not None, "A monitoring period should be open"
|
||||||
|
|
||||||
|
def test_subsequent_sample_keeps_period_open(self, config_fixture, sysfs_fixture_tree):
|
||||||
|
"""Subsequent collection runs keep the period open."""
|
||||||
|
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
clock1 = FakeClock(t1)
|
||||||
|
run_collection(self._make_sample_simple(), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture, clock1)
|
||||||
|
|
||||||
|
clock2 = FakeClock(t2)
|
||||||
|
run_collection(self._make_sample_simple(), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture, clock2)
|
||||||
|
|
||||||
|
conn = sqlite3.connect(config_fixture["store_path"])
|
||||||
|
period = get_open_period(conn)
|
||||||
|
periods_count = conn.execute("SELECT COUNT(*) FROM monitoring_periods").fetchone()[0]
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
assert period is not None
|
||||||
|
assert periods_count == 1 # Still only one period
|
||||||
|
|
||||||
|
def _make_sample_simple(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"json_format_version": [1, 0],
|
||||||
|
"smartctl": {"version": [7, 3], "svn_revision": "5155", "build_info": "(local build)"},
|
||||||
|
"nvme_smart_health_information_log": {
|
||||||
|
"critical_warning": 0, "temperature": 35,
|
||||||
|
"available_spare": 100, "available_spare_threshold": 10,
|
||||||
|
"percentage_used": 5, "data_units_written": 12345678,
|
||||||
|
"data_units_read": 9876543, "power_on_hours": 8765,
|
||||||
|
"power_cycles": 1234, "unsafe_shutdowns": 5,
|
||||||
|
"media_errors": 0, "num_err_log_entries": 0,
|
||||||
|
},
|
||||||
|
"user_capacity": {"bytes": 1024000000000, "units": "bytes"},
|
||||||
|
"model_name": "Samsung SSD 970 EVO Plus 1TB",
|
||||||
|
"serial_number": "S4EWNX0N123456",
|
||||||
|
"firmware_version": "2B2QEXM7",
|
||||||
|
}
|
||||||
@@ -232,7 +232,7 @@ def test_store_initialization(config_fixture: Dict[str, Any]):
|
|||||||
# Initialize store
|
# Initialize store
|
||||||
conn = init_store(store_path)
|
conn = init_store(store_path)
|
||||||
|
|
||||||
# Verify all six entities exist
|
# Verify the observation, derived-history, and metadata tables exist.
|
||||||
cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||||
tables = {row[0] for row in cursor.fetchall()}
|
tables = {row[0] for row in cursor.fetchall()}
|
||||||
|
|
||||||
@@ -244,10 +244,14 @@ def test_store_initialization(config_fixture: Dict[str, Any]):
|
|||||||
"controller_segments",
|
"controller_segments",
|
||||||
"endurance_baseline",
|
"endurance_baseline",
|
||||||
}
|
}
|
||||||
|
|
||||||
# sqlite_sequence is a system table created by AUTOINCREMENT
|
# sqlite_sequence is a system table created by AUTOINCREMENT
|
||||||
expected_tables.add("sqlite_sequence")
|
expected_tables.add("sqlite_sequence")
|
||||||
expected_tables.add("store_metadata")
|
expected_tables.add("store_metadata")
|
||||||
|
expected_tables.add("local_days")
|
||||||
|
expected_tables.add("pending_publications")
|
||||||
|
expected_tables.add("local_day_unallocated_evidence")
|
||||||
|
expected_tables.add("local_day_segment_totals")
|
||||||
assert expected_tables == tables
|
assert expected_tables == tables
|
||||||
|
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|||||||
@@ -0,0 +1,332 @@
|
|||||||
|
"""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, activity_intervals) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)",
|
||||||
|
(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)
|
||||||
|
|
||||||
|
def test_complete_legacy_day_without_trusted_activity_does_not_open_gate(
|
||||||
|
self, store,
|
||||||
|
):
|
||||||
|
"""A complete flag cannot make unavailable local activity qualify."""
|
||||||
|
_insert_baseline(store)
|
||||||
|
_insert_segment(store)
|
||||||
|
_open_period(store)
|
||||||
|
for i in range(3):
|
||||||
|
day = (datetime(2026, 9, 27) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_day(store, day, 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)
|
||||||
|
store.execute(
|
||||||
|
"UPDATE local_days SET activity_precision = 'measured', activity_intervals = 0 "
|
||||||
|
"WHERE local_date = '2026-09-29'"
|
||||||
|
)
|
||||||
|
result = compute_projection(store, _clock())
|
||||||
|
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
||||||
|
assert any("full local observation day" in fact
|
||||||
|
for fact in result.contributing_facts)
|
||||||
|
|
||||||
|
def test_day_split_by_deliberate_pause_does_not_open_gate(self, store):
|
||||||
|
"""A complete-looking summary cannot span separate monitoring periods."""
|
||||||
|
_insert_baseline(store)
|
||||||
|
_insert_segment(store)
|
||||||
|
for i in range(20):
|
||||||
|
day = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_day(store, day, bw=1024 * 1024 * 100)
|
||||||
|
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||||
|
|
||||||
|
store.executemany(
|
||||||
|
"INSERT INTO monitoring_periods (started_at, ended_at, end_cause) "
|
||||||
|
"VALUES (?, ?, ?)",
|
||||||
|
[
|
||||||
|
("2026-09-29T00:00:00+00:00", "2026-09-29T12:00:00+00:00", "user_disabled"),
|
||||||
|
("2026-09-29T13:00:00+00:00", "2026-09-30T00:00:00+00:00", "user_disabled"),
|
||||||
|
("2026-09-30T00:00:00+00:00", None, None),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
store.commit()
|
||||||
|
_insert_local_day(
|
||||||
|
store,
|
||||||
|
"2026-09-29",
|
||||||
|
utc_start="2026-09-29T00:00:00+00:00",
|
||||||
|
utc_end="2026-09-30T00:00:00+00:00",
|
||||||
|
complete=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = compute_projection(store, _clock())
|
||||||
|
|
||||||
|
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
||||||
|
assert any("full local observation day" in fact
|
||||||
|
for fact in result.contributing_facts)
|
||||||
|
|
||||||
|
def test_partial_but_trusted_local_activity_opens_gate(self, store):
|
||||||
|
"""Known local intervals can coexist with an incomplete day total."""
|
||||||
|
_insert_baseline(store)
|
||||||
|
_insert_segment(store)
|
||||||
|
_open_period(store)
|
||||||
|
for i in range(3):
|
||||||
|
day = (datetime(2026, 9, 27) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_day(store, day, 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)
|
||||||
|
store.execute(
|
||||||
|
"UPDATE local_days SET activity_incomplete = 1 "
|
||||||
|
"WHERE local_date = '2026-09-29'"
|
||||||
|
)
|
||||||
|
|
||||||
|
result = compute_projection(store, _clock())
|
||||||
|
|
||||||
|
assert not any("full local observation day" in fact
|
||||||
|
for fact 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()
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
"""User-facing navigation, zoom, and plotted volume in the redesigned dashboard."""
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from xml.etree import ElementTree
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from test_tui import (
|
||||||
|
_insert_baseline,
|
||||||
|
_insert_day,
|
||||||
|
_insert_local_day,
|
||||||
|
_insert_segment,
|
||||||
|
_open_period,
|
||||||
|
)
|
||||||
|
|
||||||
|
from fenris.store import init_store
|
||||||
|
from fenris.tui import FenrisTuiApp
|
||||||
|
|
||||||
|
NOW = datetime(2026, 9, 19, 12, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def dashboard(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "prefs"))
|
||||||
|
|
||||||
|
class Clock(datetime):
|
||||||
|
@classmethod
|
||||||
|
def now(cls, tz=None):
|
||||||
|
return NOW
|
||||||
|
|
||||||
|
monkeypatch.setattr("fenris.tui.datetime", Clock)
|
||||||
|
monkeypatch.setattr("fenris.status.query_service_state", lambda: {
|
||||||
|
"boot_enabled": True, "timer_active": True, "last_collect_ok": True,
|
||||||
|
"last_collect_age_s": 0, "last_collect_reason": None,
|
||||||
|
})
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
_insert_segment(conn)
|
||||||
|
_insert_baseline(conn)
|
||||||
|
_open_period(conn)
|
||||||
|
for offset in range(18):
|
||||||
|
date = (NOW - timedelta(days=offset)).date().isoformat()
|
||||||
|
_insert_day(conn, date, bw=(offset + 1) * 1_000_000_000)
|
||||||
|
_insert_local_day(conn, date, bw=(offset + 1) * 1_000_000_000, br=2_000_000_000)
|
||||||
|
for index in range(61):
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO samples (ts, device, bytes_written, bytes_read, segment_id) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
((NOW - timedelta(minutes=(60 - index) * 3)).isoformat(),
|
||||||
|
"/dev/test", index * 1_000_000, index * 2_000_000, 1),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO hour_observations (hour, bytes_written_delta, bytes_read_delta, coverage, sample_count) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?)", ("2026-09-18T12:00:00+00:00", 4_000_000, 8_000_000, 1, 20),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
||||||
|
|
||||||
|
|
||||||
|
def visible(app):
|
||||||
|
return " ".join("".join(ElementTree.fromstring(app.export_screenshot()).itertext()).split())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("size", [(140, 44), (80, 24)])
|
||||||
|
async def test_live_plot_inspection_zoom_refresh_and_resize(dashboard, size):
|
||||||
|
app = dashboard
|
||||||
|
async with app.run_test(size=size) as pilot:
|
||||||
|
app.on_refresh_tick()
|
||||||
|
await pilot.pause()
|
||||||
|
assert app.theme == "fenris-chalktone"
|
||||||
|
graph = app.query_one("#live-activity")
|
||||||
|
assert app.focused is graph
|
||||||
|
await pilot.press("left", "w")
|
||||||
|
selected = str(app.query_one("#live-readout").render())
|
||||||
|
assert "11:54" in selected and "W 0.001 GB" in selected and "R 0.002 GB" in selected
|
||||||
|
assert "Reads" in str(app.query_one("#live-legend").render())
|
||||||
|
await pilot.press("z")
|
||||||
|
app.on_refresh_tick()
|
||||||
|
await pilot.pause()
|
||||||
|
assert str(app.query_one("#live-readout").render()) == selected
|
||||||
|
assert app.query_one("#activity-panel").region.width == size[0]
|
||||||
|
for fact in ("Freshness:", "Last collect:", "Boot:", "Timer:", "q Quit TUI"):
|
||||||
|
assert fact in visible(app)
|
||||||
|
await pilot.resize_terminal(100, 30)
|
||||||
|
await pilot.press("escape")
|
||||||
|
assert str(app.query_one("#live-readout").render()) == selected
|
||||||
|
assert app._zoomed_panel is None
|
||||||
|
assert app.query_one("#action-rail").region.bottom <= 30
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tabs_date_entry_and_hourly_inspection_keep_context(dashboard):
|
||||||
|
app = dashboard
|
||||||
|
async with app.run_test(size=(100, 36)) as pilot:
|
||||||
|
await pilot.click("#view-history")
|
||||||
|
await pilot.press("left", "enter")
|
||||||
|
assert app.query_one("#activity-tabs").active == "view-day"
|
||||||
|
assert "2026-09-18" in str(app.query_one("#local-day").render())
|
||||||
|
# Hour 23 is selected initially; move to the known hour 12.
|
||||||
|
await pilot.press(*(["left"] * 11))
|
||||||
|
readout = str(app.query_one("#bar-readout").render())
|
||||||
|
assert "12:00 UTC" in readout and "W 0.004 GB" in readout
|
||||||
|
await pilot.press("z", "w")
|
||||||
|
app.on_refresh_tick()
|
||||||
|
await pilot.pause()
|
||||||
|
assert str(app.query_one("#bar-readout").render()) == readout
|
||||||
|
assert "Reads" in str(app.query_one("#bar-legend").render())
|
||||||
|
await pilot.press("g")
|
||||||
|
await pilot.press(*list("2026-09-17"))
|
||||||
|
await pilot.press("escape")
|
||||||
|
assert str(app.query_one("#bar-readout").render()) == readout
|
||||||
|
assert app._zoomed_panel == "activity-panel"
|
||||||
|
await pilot.press("escape", "t")
|
||||||
|
assert app.query_one("#activity-tabs").active == "view-live"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mouse_inspection_matches_time_axis(dashboard):
|
||||||
|
app = dashboard
|
||||||
|
async with app.run_test(size=(100, 36)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
graph = app.query_one("#live-activity")
|
||||||
|
x = graph._point_columns[0]
|
||||||
|
await pilot.click("#live-render", offset=(x, 1))
|
||||||
|
text = str(app.query_one("#live-readout").render())
|
||||||
|
assert "09:00 → 09:03 UTC" in text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_theme_control_persists_choice_and_quit_never_pauses(dashboard, monkeypatch):
|
||||||
|
calls = []
|
||||||
|
monkeypatch.setattr(dashboard, "_run_helper", lambda *args: calls.append(args))
|
||||||
|
async with dashboard.run_test(size=(80, 24)) as pilot:
|
||||||
|
await pilot.press("s")
|
||||||
|
assert dashboard.theme == "fenris-amber"
|
||||||
|
await pilot.press("q")
|
||||||
|
assert calls == []
|
||||||
|
from fenris.preferences import load_preferences
|
||||||
|
assert load_preferences()["theme"] == "amber"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("size", [(100, 36), (70, 20)])
|
||||||
|
async def test_unallocated_volume_survives_day_with_missing_coverage(dashboard, size):
|
||||||
|
async with dashboard.run_test(size=size) as pilot:
|
||||||
|
await pilot.click("#view-history")
|
||||||
|
graph = dashboard.query_one("#usage-history")
|
||||||
|
graph.set_data([{
|
||||||
|
"day": "2026-09-18", "total_bytes": 2_000_000_000,
|
||||||
|
"unallocated_bytes": 2_000_000_000, "is_gap": True,
|
||||||
|
"is_partial": True,
|
||||||
|
}])
|
||||||
|
readout = str(dashboard.query_one("#bar-readout").render())
|
||||||
|
assert "W 2.000 GB unallocated" in readout
|
||||||
|
assert "R unavailable" in readout and "gap" in readout
|
||||||
|
if size[0] >= 80:
|
||||||
|
plot = str(dashboard.query_one("#bar-render").render())
|
||||||
|
assert any(0x2801 <= ord(c) <= 0x28ff for c in plot)
|
||||||
|
graph.measure = "read"
|
||||||
|
graph._refresh()
|
||||||
|
plot = str(dashboard.query_one("#bar-render").render())
|
||||||
|
assert not any(0x2801 <= ord(c) <= 0x28ff for c in plot)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("state", ["gap", "future"])
|
||||||
|
async def test_small_terminal_hour_readout_does_not_invent_zero(dashboard, state):
|
||||||
|
async with dashboard.run_test(size=(70, 20)) as pilot:
|
||||||
|
await pilot.click("#view-day")
|
||||||
|
graph = dashboard.query_one("#usage-history")
|
||||||
|
graph.set_hour_data([{
|
||||||
|
"hour": "2026-09-18T12:00:00+00:00", "local_label": "12",
|
||||||
|
"is_gap": state == "gap", "is_future": state == "future",
|
||||||
|
}])
|
||||||
|
graph._hourly_selected = 0
|
||||||
|
graph._refresh_hourly()
|
||||||
|
readout = str(dashboard.query_one("#bar-readout").render())
|
||||||
|
assert "W unavailable · R unavailable" in readout
|
||||||
|
assert state in readout and "12:00 UTC" in readout
|
||||||
@@ -0,0 +1,708 @@
|
|||||||
|
"""Tests for the init system abstraction layer (issue #84).
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- Init system detection (systemd vs runit)
|
||||||
|
- systemd backend functions (enable, disable, collect, query state)
|
||||||
|
- runit backend functions (enable, disable, collect, query state)
|
||||||
|
- Public API dispatching to correct backend
|
||||||
|
- Edge cases (already enabled/disabled, missing files, timeouts)
|
||||||
|
|
||||||
|
Spec: ADR 0008, §8.4, §8.5, §8.6, §8.7, §8.8
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import tempfile
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch, MagicMock, PropertyMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||||
|
|
||||||
|
from fenris.init_system import (
|
||||||
|
InitSystem,
|
||||||
|
detect_init_system,
|
||||||
|
get_init_system,
|
||||||
|
reset_init_system_cache,
|
||||||
|
_systemd_enable,
|
||||||
|
_systemd_disable,
|
||||||
|
_systemd_collect,
|
||||||
|
_systemd_query_state,
|
||||||
|
_runit_enable,
|
||||||
|
_runit_disable,
|
||||||
|
_runit_collect,
|
||||||
|
_runit_query_state,
|
||||||
|
_runit_is_enabled,
|
||||||
|
_runit_is_running,
|
||||||
|
enable_timer,
|
||||||
|
disable_timer,
|
||||||
|
collect_now,
|
||||||
|
query_service_state,
|
||||||
|
journal_hint,
|
||||||
|
FENRIS_SV_DIR,
|
||||||
|
FENRIS_SERVICE_LINK,
|
||||||
|
COLLECT_TIMEOUT_S,
|
||||||
|
)
|
||||||
|
from fenris.store import init_store
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def reset_cache():
|
||||||
|
"""Reset the init system cache before each test."""
|
||||||
|
reset_init_system_cache()
|
||||||
|
yield
|
||||||
|
reset_init_system_cache()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Init system detection
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestInitSystemDetection:
|
||||||
|
"""Test init system detection logic."""
|
||||||
|
|
||||||
|
def test_detect_systemd_by_run_directory(self):
|
||||||
|
"""Systemd detected via /run/systemd/system directory."""
|
||||||
|
with patch("pathlib.Path.exists") as mock_exists:
|
||||||
|
mock_exists.return_value = True
|
||||||
|
reset_init_system_cache()
|
||||||
|
result = detect_init_system()
|
||||||
|
assert result == InitSystem.SYSTEMD
|
||||||
|
|
||||||
|
def test_detect_systemd_by_pid1(self):
|
||||||
|
"""Systemd detected via PID 1 name."""
|
||||||
|
original_exists = Path.exists
|
||||||
|
original_read_text = Path.read_text
|
||||||
|
|
||||||
|
def mock_exists(self_path):
|
||||||
|
if str(self_path) == "/run/systemd/system":
|
||||||
|
return False
|
||||||
|
return original_exists(self_path)
|
||||||
|
|
||||||
|
def mock_read_text(self_path):
|
||||||
|
if str(self_path) == "/proc/1/comm":
|
||||||
|
return "systemd"
|
||||||
|
return original_read_text(self_path)
|
||||||
|
|
||||||
|
with patch("pathlib.Path.exists", mock_exists), \
|
||||||
|
patch("pathlib.Path.read_text", mock_read_text):
|
||||||
|
reset_init_system_cache()
|
||||||
|
result = detect_init_system()
|
||||||
|
assert result == InitSystem.SYSTEMD
|
||||||
|
|
||||||
|
def test_detect_runit_by_pid1(self):
|
||||||
|
"""Runit detected via PID 1 name."""
|
||||||
|
original_exists = Path.exists
|
||||||
|
original_read_text = Path.read_text
|
||||||
|
|
||||||
|
def mock_exists(self_path):
|
||||||
|
if str(self_path) == "/run/systemd/system":
|
||||||
|
return False
|
||||||
|
return original_exists(self_path)
|
||||||
|
|
||||||
|
def mock_read_text(self_path):
|
||||||
|
if str(self_path) == "/proc/1/comm":
|
||||||
|
return "runsv"
|
||||||
|
return original_read_text(self_path)
|
||||||
|
|
||||||
|
with patch("pathlib.Path.exists", mock_exists), \
|
||||||
|
patch("pathlib.Path.read_text", mock_read_text):
|
||||||
|
reset_init_system_cache()
|
||||||
|
result = detect_init_system()
|
||||||
|
assert result == InitSystem.RUNIT
|
||||||
|
|
||||||
|
def test_detect_runit_by_etc_sv(self):
|
||||||
|
"""Runit detected via /etc/sv directory."""
|
||||||
|
original_exists = Path.exists
|
||||||
|
original_read_text = Path.read_text
|
||||||
|
|
||||||
|
def mock_exists(self_path):
|
||||||
|
if str(self_path) == "/run/systemd/system":
|
||||||
|
return False
|
||||||
|
if str(self_path) == "/etc/sv":
|
||||||
|
return True
|
||||||
|
return original_exists(self_path)
|
||||||
|
|
||||||
|
def mock_read_text(self_path):
|
||||||
|
if str(self_path) == "/proc/1/comm":
|
||||||
|
raise OSError("no such file")
|
||||||
|
return original_read_text(self_path)
|
||||||
|
|
||||||
|
with patch("pathlib.Path.exists", mock_exists), \
|
||||||
|
patch("pathlib.Path.read_text", mock_read_text):
|
||||||
|
reset_init_system_cache()
|
||||||
|
result = detect_init_system()
|
||||||
|
assert result == InitSystem.RUNIT
|
||||||
|
|
||||||
|
def test_default_to_systemd(self):
|
||||||
|
"""Default to systemd when no detection matches."""
|
||||||
|
original_exists = Path.exists
|
||||||
|
original_read_text = Path.read_text
|
||||||
|
original_is_dir = Path.is_dir
|
||||||
|
|
||||||
|
def mock_exists(self_path):
|
||||||
|
if str(self_path) == "/run/systemd/system":
|
||||||
|
return False
|
||||||
|
return original_exists(self_path)
|
||||||
|
|
||||||
|
def mock_is_dir(self_path):
|
||||||
|
if str(self_path) == "/etc/sv":
|
||||||
|
return False
|
||||||
|
return original_is_dir(self_path)
|
||||||
|
|
||||||
|
def mock_read_text(self_path):
|
||||||
|
if str(self_path) == "/proc/1/comm":
|
||||||
|
raise OSError("no such file")
|
||||||
|
return original_read_text(self_path)
|
||||||
|
|
||||||
|
with patch("pathlib.Path.exists", mock_exists), \
|
||||||
|
patch("pathlib.Path.is_dir", mock_is_dir), \
|
||||||
|
patch("pathlib.Path.read_text", mock_read_text):
|
||||||
|
reset_init_system_cache()
|
||||||
|
result = detect_init_system()
|
||||||
|
assert result == InitSystem.SYSTEMD
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# systemd backend
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestSystemdEnable:
|
||||||
|
"""Test systemd enable function."""
|
||||||
|
|
||||||
|
def test_enable_now(self):
|
||||||
|
"""Enable with --now flag."""
|
||||||
|
with patch("fenris.init_system.subprocess") as mock_sub:
|
||||||
|
mock_sub.run.return_value = MagicMock(returncode=0)
|
||||||
|
_systemd_enable(now=True)
|
||||||
|
mock_sub.run.assert_called_once_with(
|
||||||
|
["systemctl", "enable", "--now", "fenris-collect.timer"],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_enable_without_now(self):
|
||||||
|
"""Enable without --now flag."""
|
||||||
|
with patch("fenris.init_system.subprocess") as mock_sub:
|
||||||
|
mock_sub.run.return_value = MagicMock(returncode=0)
|
||||||
|
_systemd_enable(now=False)
|
||||||
|
mock_sub.run.assert_called_once_with(
|
||||||
|
["systemctl", "enable", "fenris-collect.timer"],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_enable_failure_exits(self):
|
||||||
|
"""Enable failure exits with error."""
|
||||||
|
with patch("fenris.init_system.subprocess") as mock_sub:
|
||||||
|
mock_sub.run.return_value = MagicMock(
|
||||||
|
returncode=1, stderr="Unit not found"
|
||||||
|
)
|
||||||
|
with pytest.raises(SystemExit) as exc_info:
|
||||||
|
_systemd_enable(now=True)
|
||||||
|
assert exc_info.value.code == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestSystemdDisable:
|
||||||
|
"""Test systemd disable function."""
|
||||||
|
|
||||||
|
def test_disable_now(self):
|
||||||
|
"""Disable with --now flag."""
|
||||||
|
with patch("fenris.init_system.subprocess") as mock_sub:
|
||||||
|
mock_sub.run.return_value = MagicMock(returncode=0)
|
||||||
|
_systemd_disable(now=True)
|
||||||
|
mock_sub.run.assert_called_once_with(
|
||||||
|
["systemctl", "disable", "--now", "fenris-collect.timer"],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_disable_without_now(self):
|
||||||
|
"""Disable without --now flag."""
|
||||||
|
with patch("fenris.init_system.subprocess") as mock_sub:
|
||||||
|
mock_sub.run.return_value = MagicMock(returncode=0)
|
||||||
|
_systemd_disable(now=False)
|
||||||
|
mock_sub.run.assert_called_once_with(
|
||||||
|
["systemctl", "disable", "fenris-collect.timer"],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSystemdCollect:
|
||||||
|
"""Test systemd collect function."""
|
||||||
|
|
||||||
|
def test_collect_success(self):
|
||||||
|
"""Successful collection."""
|
||||||
|
with patch("fenris.init_system.subprocess") as mock_sub:
|
||||||
|
mock_sub.run.return_value = MagicMock(returncode=0)
|
||||||
|
_systemd_collect()
|
||||||
|
mock_sub.run.assert_called_once_with(
|
||||||
|
["systemctl", "start", "fenris-collect.service"],
|
||||||
|
capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_collect_failure_exits(self):
|
||||||
|
"""Collection failure exits with error."""
|
||||||
|
with patch("fenris.init_system.subprocess") as mock_sub:
|
||||||
|
mock_sub.run.return_value = MagicMock(
|
||||||
|
returncode=1, stderr="Unit not found"
|
||||||
|
)
|
||||||
|
with pytest.raises(SystemExit) as exc_info:
|
||||||
|
_systemd_collect()
|
||||||
|
assert exc_info.value.code == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestSystemdQueryState:
|
||||||
|
"""Test systemd query state function."""
|
||||||
|
|
||||||
|
def test_query_state_enabled_active(self):
|
||||||
|
"""Query state for enabled and active timer."""
|
||||||
|
with patch("fenris.init_system._systemctl_show") as mock_show:
|
||||||
|
def mock_show_fn(unit, *props):
|
||||||
|
if unit == "fenris-collect.timer":
|
||||||
|
return {
|
||||||
|
"UnitFileState": "enabled",
|
||||||
|
"ActiveState": "active",
|
||||||
|
"LastTriggerUSec": "2026-09-01T12:00:00Z",
|
||||||
|
}
|
||||||
|
elif unit == "fenris-collect.service":
|
||||||
|
return {
|
||||||
|
"ActiveState": "inactive",
|
||||||
|
"ExecMainStatus": "0",
|
||||||
|
"ExecMainExitTimestamp": "2026-09-01T12:00:30Z",
|
||||||
|
}
|
||||||
|
return {}
|
||||||
|
mock_show.side_effect = mock_show_fn
|
||||||
|
|
||||||
|
result = _systemd_query_state()
|
||||||
|
|
||||||
|
assert result["boot_enabled"] is True
|
||||||
|
assert result["timer_active"] is True
|
||||||
|
assert result["last_collect_ok"] is True
|
||||||
|
assert result["last_collect_age_s"] is not None
|
||||||
|
|
||||||
|
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 = {"UnitFileState": "disabled", "ActiveState": "inactive"}
|
||||||
|
result = _systemd_query_state()
|
||||||
|
assert result["boot_enabled"] is False
|
||||||
|
assert result["timer_active"] is False
|
||||||
|
assert result["last_collect_ok"] is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# runit backend
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestRunitEnable:
|
||||||
|
"""Test runit enable function."""
|
||||||
|
|
||||||
|
def test_enable_creates_symlink(self, tmp_path):
|
||||||
|
"""Enable creates symlink to service directory."""
|
||||||
|
sv_dir = tmp_path / "sv" / "fenris-collect"
|
||||||
|
sv_dir.mkdir(parents=True)
|
||||||
|
service_link = tmp_path / "service" / "fenris-collect"
|
||||||
|
service_link.parent.mkdir(parents=True)
|
||||||
|
|
||||||
|
with patch("fenris.init_system.FENRIS_SV_DIR", sv_dir), \
|
||||||
|
patch("fenris.init_system.FENRIS_SERVICE_LINK", service_link):
|
||||||
|
_runit_enable(_now=True)
|
||||||
|
assert service_link.exists()
|
||||||
|
assert service_link.is_symlink()
|
||||||
|
assert service_link.resolve() == sv_dir
|
||||||
|
|
||||||
|
def test_enable_removes_down_file(self, tmp_path):
|
||||||
|
"""Enable removes the 'down' file if present."""
|
||||||
|
sv_dir = tmp_path / "sv" / "fenris-collect"
|
||||||
|
sv_dir.mkdir(parents=True)
|
||||||
|
(sv_dir / "down").touch()
|
||||||
|
service_link = tmp_path / "service" / "fenris-collect"
|
||||||
|
service_link.parent.mkdir(parents=True)
|
||||||
|
|
||||||
|
with patch("fenris.init_system.FENRIS_SV_DIR", sv_dir), \
|
||||||
|
patch("fenris.init_system.FENRIS_SERVICE_LINK", service_link):
|
||||||
|
_runit_enable(_now=True)
|
||||||
|
assert not (sv_dir / "down").exists()
|
||||||
|
|
||||||
|
def test_enable_idempotent(self, tmp_path):
|
||||||
|
"""Enable is idempotent when already enabled."""
|
||||||
|
sv_dir = tmp_path / "sv" / "fenris-collect"
|
||||||
|
sv_dir.mkdir(parents=True)
|
||||||
|
service_link = tmp_path / "service" / "fenris-collect"
|
||||||
|
service_link.parent.mkdir(parents=True)
|
||||||
|
service_link.symlink_to(sv_dir)
|
||||||
|
|
||||||
|
with patch("fenris.init_system.FENRIS_SV_DIR", sv_dir), \
|
||||||
|
patch("fenris.init_system.FENRIS_SERVICE_LINK", service_link):
|
||||||
|
_runit_enable(_now=True)
|
||||||
|
assert service_link.exists()
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunitDisable:
|
||||||
|
"""Test runit disable function."""
|
||||||
|
|
||||||
|
def test_disable_removes_symlink(self, tmp_path):
|
||||||
|
"""Disable removes the service symlink."""
|
||||||
|
sv_dir = tmp_path / "sv" / "fenris-collect"
|
||||||
|
sv_dir.mkdir(parents=True)
|
||||||
|
service_link = tmp_path / "service" / "fenris-collect"
|
||||||
|
service_link.parent.mkdir(parents=True)
|
||||||
|
service_link.symlink_to(sv_dir)
|
||||||
|
|
||||||
|
with patch("fenris.init_system.FENRIS_SV_DIR", sv_dir), \
|
||||||
|
patch("fenris.init_system.FENRIS_SERVICE_LINK", service_link):
|
||||||
|
_runit_disable(_now=True)
|
||||||
|
assert not service_link.exists()
|
||||||
|
assert (sv_dir / "down").exists()
|
||||||
|
|
||||||
|
def test_disable_idempotent(self, tmp_path):
|
||||||
|
"""Disable is idempotent when already disabled."""
|
||||||
|
sv_dir = tmp_path / "sv" / "fenris-collect"
|
||||||
|
sv_dir.mkdir(parents=True)
|
||||||
|
service_link = tmp_path / "service" / "fenris-collect"
|
||||||
|
service_link.parent.mkdir(parents=True)
|
||||||
|
|
||||||
|
with patch("fenris.init_system.FENRIS_SV_DIR", sv_dir), \
|
||||||
|
patch("fenris.init_system.FENRIS_SERVICE_LINK", service_link):
|
||||||
|
_runit_disable(_now=True)
|
||||||
|
assert not service_link.exists()
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunitCollect:
|
||||||
|
"""Test runit collect function."""
|
||||||
|
|
||||||
|
def test_collect_uses_flock(self):
|
||||||
|
"""Collect uses flock for serialization."""
|
||||||
|
with patch("fenris.init_system.subprocess") as mock_sub, \
|
||||||
|
patch("fenris.init_system.Path") as mock_path:
|
||||||
|
mock_path.return_value.exists.return_value = True
|
||||||
|
mock_sub.run.return_value = MagicMock(returncode=0)
|
||||||
|
_runit_collect()
|
||||||
|
# Verify flock was used
|
||||||
|
call_args = mock_sub.run.call_args[0][0]
|
||||||
|
assert "flock" in call_args
|
||||||
|
|
||||||
|
def test_collect_timeout_exits(self):
|
||||||
|
"""Collection timeout exits with error."""
|
||||||
|
with patch("fenris.init_system.subprocess") as mock_sub, \
|
||||||
|
patch("fenris.init_system.Path") as mock_path:
|
||||||
|
mock_path.return_value.exists.return_value = True
|
||||||
|
mock_sub.run.return_value = MagicMock(returncode=124)
|
||||||
|
with pytest.raises(SystemExit) as exc_info:
|
||||||
|
_runit_collect()
|
||||||
|
assert exc_info.value.code == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunitQueryState:
|
||||||
|
"""Test runit query state function."""
|
||||||
|
|
||||||
|
def test_query_state_enabled_running(self, tmp_path):
|
||||||
|
"""Query state for enabled and running service."""
|
||||||
|
sv_dir = tmp_path / "sv" / "fenris-collect"
|
||||||
|
sv_dir.mkdir(parents=True)
|
||||||
|
supervise_dir = sv_dir / "supervise"
|
||||||
|
supervise_dir.mkdir(parents=True)
|
||||||
|
(supervise_dir / "pid").write_text("12345")
|
||||||
|
service_link = tmp_path / "service" / "fenris-collect"
|
||||||
|
service_link.parent.mkdir(parents=True)
|
||||||
|
service_link.symlink_to(sv_dir)
|
||||||
|
|
||||||
|
with patch("fenris.init_system.FENRIS_SV_DIR", sv_dir), \
|
||||||
|
patch("fenris.init_system.FENRIS_SERVICE_LINK", service_link), \
|
||||||
|
patch("os.kill") as mock_kill:
|
||||||
|
mock_kill.return_value = True # Process exists
|
||||||
|
result = _runit_query_state()
|
||||||
|
assert result["boot_enabled"] is True
|
||||||
|
assert result["timer_active"] is True
|
||||||
|
|
||||||
|
def test_query_state_disabled_not_running(self, tmp_path):
|
||||||
|
"""Query state for disabled and not running service."""
|
||||||
|
sv_dir = tmp_path / "sv" / "fenris-collect"
|
||||||
|
sv_dir.mkdir(parents=True)
|
||||||
|
service_link = tmp_path / "service" / "fenris-collect"
|
||||||
|
service_link.parent.mkdir(parents=True)
|
||||||
|
|
||||||
|
with patch("fenris.init_system.FENRIS_SV_DIR", sv_dir), \
|
||||||
|
patch("fenris.init_system.FENRIS_SERVICE_LINK", service_link):
|
||||||
|
result = _runit_query_state()
|
||||||
|
assert result["boot_enabled"] is False
|
||||||
|
assert result["timer_active"] is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunitIsEnabled:
|
||||||
|
"""Test runit is_enabled check."""
|
||||||
|
|
||||||
|
def test_is_enabled_true(self, tmp_path):
|
||||||
|
"""Service is enabled when symlink exists."""
|
||||||
|
sv_dir = tmp_path / "sv" / "fenris-collect"
|
||||||
|
sv_dir.mkdir(parents=True)
|
||||||
|
service_link = tmp_path / "service" / "fenris-collect"
|
||||||
|
service_link.parent.mkdir(parents=True)
|
||||||
|
service_link.symlink_to(sv_dir)
|
||||||
|
|
||||||
|
with patch("fenris.init_system.FENRIS_SERVICE_LINK", service_link):
|
||||||
|
assert _runit_is_enabled() is True
|
||||||
|
|
||||||
|
def test_is_enabled_false(self, tmp_path):
|
||||||
|
"""Service is disabled when symlink does not exist."""
|
||||||
|
service_link = tmp_path / "service" / "fenris-collect"
|
||||||
|
|
||||||
|
with patch("fenris.init_system.FENRIS_SERVICE_LINK", service_link):
|
||||||
|
assert _runit_is_enabled() is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunitIsRunning:
|
||||||
|
"""Test runit is_running check."""
|
||||||
|
|
||||||
|
def test_is_running_true(self, tmp_path):
|
||||||
|
"""Service is running when PID file exists and process is alive."""
|
||||||
|
sv_dir = tmp_path / "sv" / "fenris-collect"
|
||||||
|
supervise_dir = sv_dir / "supervise"
|
||||||
|
supervise_dir.mkdir(parents=True)
|
||||||
|
(supervise_dir / "pid").write_text("12345")
|
||||||
|
|
||||||
|
with patch("fenris.init_system.FENRIS_SV_DIR", sv_dir), \
|
||||||
|
patch("os.kill") as mock_kill:
|
||||||
|
mock_kill.return_value = True
|
||||||
|
assert _runit_is_running() is True
|
||||||
|
|
||||||
|
def test_is_running_false_no_pid(self, tmp_path):
|
||||||
|
"""Service is not running when PID file does not exist."""
|
||||||
|
sv_dir = tmp_path / "sv" / "fenris-collect"
|
||||||
|
sv_dir.mkdir(parents=True)
|
||||||
|
|
||||||
|
with patch("fenris.init_system.FENRIS_SV_DIR", sv_dir), \
|
||||||
|
patch("subprocess.run") as mock_run:
|
||||||
|
mock_run.return_value.returncode = 1
|
||||||
|
assert _runit_is_running() is False
|
||||||
|
|
||||||
|
def test_is_running_false_dead_process(self, tmp_path):
|
||||||
|
"""Service is not running when process is dead."""
|
||||||
|
sv_dir = tmp_path / "sv" / "fenris-collect"
|
||||||
|
supervise_dir = sv_dir / "supervise"
|
||||||
|
supervise_dir.mkdir(parents=True)
|
||||||
|
(supervise_dir / "pid").write_text("12345")
|
||||||
|
|
||||||
|
with patch("fenris.init_system.FENRIS_SV_DIR", sv_dir), \
|
||||||
|
patch("os.kill", side_effect=OSError("No such process")):
|
||||||
|
assert _runit_is_running() is False
|
||||||
|
|
||||||
|
def test_is_running_uses_process_table_when_supervise_is_unreadable(self, tmp_path):
|
||||||
|
"""Void keeps runit's supervise directory root-only for normal users."""
|
||||||
|
sv_dir = tmp_path / "sv" / "fenris-collect"
|
||||||
|
supervise_dir = sv_dir / "supervise"
|
||||||
|
supervise_dir.mkdir(parents=True)
|
||||||
|
pid_file = supervise_dir / "pid"
|
||||||
|
pid_file.write_text("12345")
|
||||||
|
service_link = tmp_path / "service" / "fenris-collect"
|
||||||
|
service_link.parent.mkdir(parents=True)
|
||||||
|
service_link.symlink_to(sv_dir)
|
||||||
|
|
||||||
|
original_read_text = Path.read_text
|
||||||
|
|
||||||
|
def deny_pid(path, *args, **kwargs):
|
||||||
|
if path == pid_file:
|
||||||
|
raise PermissionError("supervise is root-only")
|
||||||
|
return original_read_text(path, *args, **kwargs)
|
||||||
|
|
||||||
|
with patch("fenris.init_system.FENRIS_SV_DIR", sv_dir), \
|
||||||
|
patch("fenris.init_system.FENRIS_SERVICE_LINK", service_link), \
|
||||||
|
patch.object(Path, "read_text", autospec=True, side_effect=deny_pid), \
|
||||||
|
patch("subprocess.run") as mock_run:
|
||||||
|
mock_run.return_value.returncode = 0
|
||||||
|
assert _runit_is_running() is True
|
||||||
|
mock_run.assert_called_once_with(
|
||||||
|
["pgrep", "-f", "^runsv fenris-collect$"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API dispatching
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestPublicAPI:
|
||||||
|
"""Test public API dispatches to correct backend."""
|
||||||
|
|
||||||
|
def test_enable_dispatches_to_systemd(self):
|
||||||
|
"""enable_timer dispatches to systemd on systemd system."""
|
||||||
|
with patch("fenris.init_system.get_init_system", return_value=InitSystem.SYSTEMD), \
|
||||||
|
patch("fenris.init_system._systemd_enable") as mock_enable:
|
||||||
|
enable_timer(now=True)
|
||||||
|
mock_enable.assert_called_once_with(True)
|
||||||
|
|
||||||
|
def test_enable_dispatches_to_runit(self):
|
||||||
|
"""enable_timer dispatches to runit on runit system."""
|
||||||
|
with patch("fenris.init_system.get_init_system", return_value=InitSystem.RUNIT), \
|
||||||
|
patch("fenris.init_system._runit_enable") as mock_enable:
|
||||||
|
enable_timer(now=True)
|
||||||
|
mock_enable.assert_called_once_with(True)
|
||||||
|
|
||||||
|
def test_disable_dispatches_to_systemd(self):
|
||||||
|
"""disable_timer dispatches to systemd on systemd system."""
|
||||||
|
with patch("fenris.init_system.get_init_system", return_value=InitSystem.SYSTEMD), \
|
||||||
|
patch("fenris.init_system._systemd_disable") as mock_disable:
|
||||||
|
disable_timer(now=False)
|
||||||
|
mock_disable.assert_called_once_with(False)
|
||||||
|
|
||||||
|
def test_disable_dispatches_to_runit(self):
|
||||||
|
"""disable_timer dispatches to runit on runit system."""
|
||||||
|
with patch("fenris.init_system.get_init_system", return_value=InitSystem.RUNIT), \
|
||||||
|
patch("fenris.init_system._runit_disable") as mock_disable:
|
||||||
|
disable_timer(now=False)
|
||||||
|
mock_disable.assert_called_once_with(False)
|
||||||
|
|
||||||
|
def test_collect_dispatches_to_systemd(self):
|
||||||
|
"""collect_now dispatches to systemd on systemd system."""
|
||||||
|
with patch("fenris.init_system.get_init_system", return_value=InitSystem.SYSTEMD), \
|
||||||
|
patch("fenris.init_system._systemd_collect") as mock_collect:
|
||||||
|
collect_now()
|
||||||
|
mock_collect.assert_called_once()
|
||||||
|
|
||||||
|
def test_collect_dispatches_to_runit(self):
|
||||||
|
"""collect_now dispatches to runit on runit system."""
|
||||||
|
with patch("fenris.init_system.get_init_system", return_value=InitSystem.RUNIT), \
|
||||||
|
patch("fenris.init_system._runit_collect") as mock_collect:
|
||||||
|
collect_now()
|
||||||
|
mock_collect.assert_called_once()
|
||||||
|
|
||||||
|
def test_query_state_dispatches_to_systemd(self):
|
||||||
|
"""query_service_state dispatches to systemd on systemd system."""
|
||||||
|
with patch("fenris.init_system.get_init_system", return_value=InitSystem.SYSTEMD), \
|
||||||
|
patch("fenris.init_system._systemd_query_state") as mock_query:
|
||||||
|
query_service_state()
|
||||||
|
mock_query.assert_called_once()
|
||||||
|
|
||||||
|
def test_query_state_dispatches_to_runit(self):
|
||||||
|
"""query_service_state dispatches to runit on runit system."""
|
||||||
|
with patch("fenris.init_system.get_init_system", return_value=InitSystem.RUNIT), \
|
||||||
|
patch("fenris.init_system._runit_query_state") as mock_query:
|
||||||
|
query_service_state()
|
||||||
|
mock_query.assert_called_once()
|
||||||
|
|
||||||
|
def test_journal_hint_dispatches_to_systemd(self):
|
||||||
|
"""journal_hint dispatches to systemd on systemd system."""
|
||||||
|
with patch("fenris.init_system.get_init_system", return_value=InitSystem.SYSTEMD), \
|
||||||
|
patch("fenris.init_system._systemd_journal_hint") as mock_hint:
|
||||||
|
journal_hint(lines=3, unit="fenris-collect.service")
|
||||||
|
mock_hint.assert_called_once_with(3)
|
||||||
|
|
||||||
|
def test_journal_hint_dispatches_to_runit(self):
|
||||||
|
"""journal_hint dispatches to runit on runit system."""
|
||||||
|
with patch("fenris.init_system.get_init_system", return_value=InitSystem.RUNIT), \
|
||||||
|
patch("fenris.init_system._runit_journal_hint") as mock_hint:
|
||||||
|
journal_hint(lines=3, unit="fenris-collect.service")
|
||||||
|
mock_hint.assert_called_once_with(3)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Constants and configuration
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestConstants:
|
||||||
|
"""Test constants match spec requirements."""
|
||||||
|
|
||||||
|
def test_collect_timeout(self):
|
||||||
|
"""Collection timeout is 90 seconds (bounded execution)."""
|
||||||
|
assert COLLECT_TIMEOUT_S == 90
|
||||||
|
|
||||||
|
def test_init_system_enum(self):
|
||||||
|
"""InitSystem enum has systemd and runit variants."""
|
||||||
|
assert InitSystem.SYSTEMD.value == "systemd"
|
||||||
|
assert InitSystem.RUNIT.value == "runit"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Integration with monitor.py
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestMonitorIntegration:
|
||||||
|
"""Test that monitor.py uses the abstraction layer correctly."""
|
||||||
|
|
||||||
|
def test_cmd_enable_uses_init_system(self, tmp_path):
|
||||||
|
"""cmd_enable uses init_system.enable_timer."""
|
||||||
|
from fenris.monitor import cmd_enable
|
||||||
|
from argparse import Namespace
|
||||||
|
|
||||||
|
store_path = tmp_path / "observations.db"
|
||||||
|
init_store(store_path)
|
||||||
|
args = Namespace(now=True, store_path=store_path)
|
||||||
|
|
||||||
|
with patch("fenris.monitor.enable_timer") as mock_enable:
|
||||||
|
cmd_enable(args)
|
||||||
|
mock_enable.assert_called_once_with(True)
|
||||||
|
|
||||||
|
conn = sqlite3.connect(store_path)
|
||||||
|
try:
|
||||||
|
assert conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM monitoring_periods WHERE ended_at IS NULL"
|
||||||
|
).fetchone()[0] == 1
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_cmd_disable_uses_init_system(self, tmp_path):
|
||||||
|
"""cmd_disable uses init_system.disable_timer."""
|
||||||
|
from fenris.monitor import cmd_disable
|
||||||
|
from argparse import Namespace
|
||||||
|
|
||||||
|
store_path = tmp_path / "observations.db"
|
||||||
|
init_store(store_path)
|
||||||
|
args = Namespace(now=True, store_path=store_path)
|
||||||
|
|
||||||
|
with patch("fenris.monitor.disable_timer") as mock_disable:
|
||||||
|
cmd_disable(args)
|
||||||
|
mock_disable.assert_called_once_with(True)
|
||||||
|
|
||||||
|
def test_cmd_collect_uses_init_system(self):
|
||||||
|
"""cmd_collect uses init_system.collect_now."""
|
||||||
|
from fenris.monitor import cmd_collect
|
||||||
|
from argparse import Namespace
|
||||||
|
|
||||||
|
args = Namespace()
|
||||||
|
|
||||||
|
with patch("fenris.monitor.collect_now") as mock_collect:
|
||||||
|
cmd_collect(args)
|
||||||
|
mock_collect.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Edge cases
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestEdgeCases:
|
||||||
|
"""Test edge cases and error handling."""
|
||||||
|
|
||||||
|
def test_systemd_enable_failure_produces_stderr(self):
|
||||||
|
"""Systemd enable failure produces error message on stderr."""
|
||||||
|
with patch("fenris.init_system.subprocess") as mock_sub:
|
||||||
|
mock_sub.run.return_value = MagicMock(
|
||||||
|
returncode=1, stderr="Permission denied"
|
||||||
|
)
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
_systemd_enable(now=True)
|
||||||
|
|
||||||
|
def test_runit_collect_missing_script(self):
|
||||||
|
"""Runit collect exits when fenris-collect script not found."""
|
||||||
|
with patch("fenris.init_system.subprocess") as mock_sub, \
|
||||||
|
patch("fenris.init_system.Path") as mock_path:
|
||||||
|
missing = MagicMock()
|
||||||
|
missing.exists.return_value = False
|
||||||
|
# Fallback path is built via Path(...).parent... / "src" / ...
|
||||||
|
missing.parent = missing
|
||||||
|
missing.__truediv__.return_value = missing
|
||||||
|
mock_path.return_value = missing
|
||||||
|
mock_sub.run.return_value = MagicMock(returncode=1)
|
||||||
|
with pytest.raises(SystemExit) as exc_info:
|
||||||
|
_runit_collect()
|
||||||
|
assert exc_info.value.code == 1
|
||||||
|
mock_sub.run.assert_not_called()
|
||||||
|
|
||||||
|
def test_systemd_query_state_timeout(self):
|
||||||
|
"""Systemd query state handles subprocess timeout."""
|
||||||
|
with patch("fenris.init_system._systemctl_show") as mock_show:
|
||||||
|
mock_show.return_value = {}
|
||||||
|
result = _systemd_query_state()
|
||||||
|
assert result["boot_enabled"] is None
|
||||||
|
assert result["timer_active"] is None
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
"""Normal-collection retention acceptance tests for issue #100."""
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||||
|
|
||||||
|
from fenris.collector import run_collection
|
||||||
|
from fenris.local_day import query_local_day_summary
|
||||||
|
from fenris.pruning import prune_old_samples
|
||||||
|
from fenris.status import read_status
|
||||||
|
from fenris.store import init_store
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sysfs_controller(tmp_path):
|
||||||
|
controller = tmp_path / "sys" / "class" / "nvme" / "nvme0"
|
||||||
|
controller.mkdir(parents=True)
|
||||||
|
(controller / "subsysnqn").write_text("nqn.example:drive-1\n")
|
||||||
|
(controller / "model").write_text("Fenris Test Drive\n")
|
||||||
|
(controller / "serial").write_text("drive-1\n")
|
||||||
|
(controller / "firmware_rev").write_text("1.0\n")
|
||||||
|
transport = controller / "transport"
|
||||||
|
transport.mkdir()
|
||||||
|
(transport / "address").write_text("0000:00:01.0\n")
|
||||||
|
(transport / "trstring").write_text("pcie\n")
|
||||||
|
return controller
|
||||||
|
|
||||||
|
|
||||||
|
class FakeClock:
|
||||||
|
def __init__(self, now):
|
||||||
|
self.now = now
|
||||||
|
|
||||||
|
def utcnow(self):
|
||||||
|
return self.now
|
||||||
|
|
||||||
|
|
||||||
|
def _smartctl(written, read):
|
||||||
|
return {
|
||||||
|
"nvme_smart_health_information_log": {
|
||||||
|
"critical_warning": 0,
|
||||||
|
"temperature": 35,
|
||||||
|
"available_spare": 100,
|
||||||
|
"percentage_used": 1,
|
||||||
|
"data_units_written": written,
|
||||||
|
"data_units_read": read,
|
||||||
|
"power_on_hours": 100,
|
||||||
|
"power_cycles": 1,
|
||||||
|
"unsafe_shutdowns": 0,
|
||||||
|
"media_errors": 0,
|
||||||
|
},
|
||||||
|
"user_capacity": {"bytes": 1_000_000_000_000},
|
||||||
|
"model_name": "Fenris Test Drive",
|
||||||
|
"serial_number": "drive-1",
|
||||||
|
"firmware_version": "1.0",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _collect(config, controller, now, written, read):
|
||||||
|
return run_collection(
|
||||||
|
smartctl_data=_smartctl(written, read),
|
||||||
|
sysfs_path=controller,
|
||||||
|
config=config,
|
||||||
|
clock=FakeClock(now),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_normal_collection_expires_detail_and_keeps_local_day_evidence(
|
||||||
|
tmp_path, sysfs_controller, monkeypatch,
|
||||||
|
):
|
||||||
|
monkeypatch.setenv("TZ", "UTC")
|
||||||
|
store_path = tmp_path / "observations.db"
|
||||||
|
config = {"device": "/dev/nvme0", "store_path": str(store_path)}
|
||||||
|
first_time = datetime(2026, 9, 1, 0, 0, tzinfo=timezone.utc)
|
||||||
|
second_time = first_time + timedelta(days=15)
|
||||||
|
|
||||||
|
first = _collect(config, sysfs_controller, first_time, 1000, 2000)
|
||||||
|
assert first["ok"] is True, first
|
||||||
|
second = _collect(config, sysfs_controller, second_time, 1001, 2003)
|
||||||
|
assert second["ok"] is True, second
|
||||||
|
|
||||||
|
with sqlite3.connect(store_path) as conn:
|
||||||
|
assert conn.execute("SELECT ts FROM samples ORDER BY id").fetchall() == [
|
||||||
|
(second_time.isoformat(),)
|
||||||
|
]
|
||||||
|
assert conn.execute(
|
||||||
|
"SELECT bytes_written, bytes_read, reason "
|
||||||
|
"FROM local_day_unallocated_evidence"
|
||||||
|
).fetchall() == [(512_000, 1_536_000, "local_midnight")]
|
||||||
|
assert conn.execute("SELECT COUNT(*) FROM pending_publications").fetchone()[0] == 0
|
||||||
|
assert conn.execute("SELECT COUNT(*) FROM controller_segments").fetchone()[0] == 1
|
||||||
|
assert conn.execute("SELECT COUNT(*) FROM day_aggregates").fetchone()[0] == 15
|
||||||
|
|
||||||
|
monkeypatch.setenv("TZ", "Asia/Kolkata")
|
||||||
|
with read_status(store_path, second_time, query_services=False) as (reader, state):
|
||||||
|
assert reader is not None
|
||||||
|
assert state.store_fault is None
|
||||||
|
old_day = query_local_day_summary(
|
||||||
|
reader, "2026-09-01", "UTC", second_time,
|
||||||
|
)
|
||||||
|
assert old_day["utc_start"] == "2026-09-01T00:00:00+00:00"
|
||||||
|
assert old_day["utc_end"] == "2026-09-02T00:00:00+00:00"
|
||||||
|
assert old_day["bytes_written"] is None
|
||||||
|
assert old_day["shared_bytes_written"] == 512_000
|
||||||
|
assert old_day["shared_bytes_read"] == 1_536_000
|
||||||
|
assert old_day["shared_evidence_count"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_pruning_keeps_samples_when_replacement_evidence_is_missing(tmp_path):
|
||||||
|
store_path = tmp_path / "incomplete.db"
|
||||||
|
conn = init_store(store_path)
|
||||||
|
now = datetime(2026, 9, 30, tzinfo=timezone.utc)
|
||||||
|
old_time = now - timedelta(days=20)
|
||||||
|
next_time = now - timedelta(days=1)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO controller_segments (id, opened_at, identity_key) "
|
||||||
|
"VALUES (1, ?, 'test')",
|
||||||
|
(old_time.isoformat(),),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO samples (ts, device, bytes_written, bytes_read, segment_id, local_tz) "
|
||||||
|
"VALUES (?, '/dev/nvme0', 100, 100, 1, 'UTC')",
|
||||||
|
(old_time.isoformat(),),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO samples (ts, device, bytes_written, bytes_read, segment_id, local_tz) "
|
||||||
|
"VALUES (?, '/dev/nvme0', 200, 200, 1, 'UTC')",
|
||||||
|
(next_time.isoformat(),),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
assert prune_old_samples(conn, now) == 0
|
||||||
|
assert conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0] == 2
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_later_same_day_activity_does_not_replace_gap_interval(tmp_path):
|
||||||
|
store_path = tmp_path / "unrelated-local-activity.db"
|
||||||
|
conn = init_store(store_path)
|
||||||
|
gap_start = datetime(2026, 9, 1, 0, 0, tzinfo=timezone.utc)
|
||||||
|
gap_end = gap_start + timedelta(minutes=10)
|
||||||
|
later_end = gap_start + timedelta(minutes=20)
|
||||||
|
now = datetime(2026, 9, 30, tzinfo=timezone.utc)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO controller_segments (id, opened_at, identity_key) "
|
||||||
|
"VALUES (1, ?, 'test')",
|
||||||
|
(gap_start.isoformat(),),
|
||||||
|
)
|
||||||
|
for sample_time, written, read in (
|
||||||
|
(gap_start, 1000, 100),
|
||||||
|
(gap_end, 2000, 200),
|
||||||
|
(later_end, 2100, 210),
|
||||||
|
):
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO samples (ts, device, bytes_written, bytes_read, segment_id, local_tz) "
|
||||||
|
"VALUES (?, '/dev/nvme0', ?, ?, 1, 'UTC')",
|
||||||
|
(sample_time.isoformat(), written, read),
|
||||||
|
)
|
||||||
|
|
||||||
|
# UTC aggregates can replace the old counter pair, but the local-day
|
||||||
|
# summary contains only the later B->C interval, after monitoring resumed.
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO hour_observations (hour, bytes_written_delta, bytes_read_delta) "
|
||||||
|
"VALUES ('2026-09-01T00:00:00+00:00', 1000, 100)"
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO day_aggregates (day) VALUES ('2026-09-01')"
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO monitoring_periods (started_at, ended_at) VALUES (?, ?)",
|
||||||
|
(gap_end.isoformat(), later_end.isoformat()),
|
||||||
|
)
|
||||||
|
last_sample_id = conn.execute("SELECT MAX(id) FROM samples").fetchone()[0]
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO local_days "
|
||||||
|
"(local_date, tz_name, tz_offset, utc_start, utc_end, bytes_written, "
|
||||||
|
" bytes_read, activity_intervals, activity_precision, last_sample_id) "
|
||||||
|
"VALUES ('2026-09-01', 'UTC', '+00:00', ?, ?, 100, 10, 1, 'measured', ?)",
|
||||||
|
(
|
||||||
|
gap_start.isoformat(),
|
||||||
|
(gap_start + timedelta(days=1)).isoformat(),
|
||||||
|
last_sample_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
local_day_id = conn.execute("SELECT MAX(id) FROM local_days").fetchone()[0]
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO local_day_segment_totals "
|
||||||
|
"(local_day_id, segment_id, bytes_written, bytes_read, activity_intervals) "
|
||||||
|
"VALUES (?, 1, 100, 10, 1)",
|
||||||
|
(local_day_id,),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
assert prune_old_samples(conn, now) == 0
|
||||||
|
assert conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0] == 3
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_pruning_keeps_oldest_unpaired_sample_as_successor_anchor(tmp_path):
|
||||||
|
store_path = tmp_path / "anchor.db"
|
||||||
|
conn = init_store(store_path)
|
||||||
|
now = datetime(2026, 9, 30, tzinfo=timezone.utc)
|
||||||
|
old_time = now - timedelta(days=20)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO controller_segments (id, opened_at, identity_key) "
|
||||||
|
"VALUES (1, ?, 'test')",
|
||||||
|
(old_time.isoformat(),),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO samples (ts, device, bytes_written, bytes_read, segment_id, local_tz) "
|
||||||
|
"VALUES (?, '/dev/nvme0', 100, 100, 1, 'UTC')",
|
||||||
|
(old_time.isoformat(),),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
assert prune_old_samples(conn, now) == 0
|
||||||
|
assert conn.execute("SELECT ts FROM samples").fetchone()[0] == old_time.isoformat()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_pruning_never_expires_pending_publications(tmp_path):
|
||||||
|
store_path = tmp_path / "pending.db"
|
||||||
|
conn = init_store(store_path)
|
||||||
|
old_time = datetime(2026, 9, 1, tzinfo=timezone.utc)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO pending_publications (sample_ts, payload) VALUES (?, '{}')",
|
||||||
|
(old_time.isoformat(),),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
assert prune_old_samples(
|
||||||
|
conn, datetime(2026, 9, 30, tzinfo=timezone.utc),
|
||||||
|
) == 0
|
||||||
|
assert conn.execute(
|
||||||
|
"SELECT sample_ts FROM pending_publications"
|
||||||
|
).fetchone()[0] == old_time.isoformat()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_pruning_preserves_unparseable_legacy_timestamp(tmp_path):
|
||||||
|
store_path = tmp_path / "legacy.db"
|
||||||
|
conn = init_store(store_path)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO samples (ts, device, bytes_written, bytes_read) "
|
||||||
|
"VALUES ('legacy timestamp', '/dev/nvme0', 100, 100)"
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
assert prune_old_samples(conn, datetime(2026, 9, 30, tzinfo=timezone.utc)) == 0
|
||||||
|
assert conn.execute("SELECT ts FROM samples").fetchone()[0] == "legacy timestamp"
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_collection_defers_interrupted_pruning_and_retries_next_run(
|
||||||
|
tmp_path, sysfs_controller, monkeypatch,
|
||||||
|
):
|
||||||
|
monkeypatch.setenv("TZ", "UTC")
|
||||||
|
store_path = tmp_path / "interrupted.db"
|
||||||
|
config = {"device": "/dev/nvme0", "store_path": str(store_path)}
|
||||||
|
first_time = datetime(2026, 9, 1, 0, 0, tzinfo=timezone.utc)
|
||||||
|
second_time = first_time + timedelta(days=15)
|
||||||
|
assert _collect(config, sysfs_controller, first_time, 1000, 2000)["ok"] is True
|
||||||
|
|
||||||
|
writer = sqlite3.connect(store_path)
|
||||||
|
writer.execute(
|
||||||
|
"CREATE TRIGGER stop_retention BEFORE DELETE ON samples "
|
||||||
|
"BEGIN SELECT RAISE(ABORT, 'simulated retention interruption'); END"
|
||||||
|
)
|
||||||
|
writer.commit()
|
||||||
|
writer.close()
|
||||||
|
|
||||||
|
second = _collect(config, sysfs_controller, second_time, 1001, 2001)
|
||||||
|
assert second["ok"] is True, second
|
||||||
|
assert "simulated retention interruption" in second["retention_error"]
|
||||||
|
with sqlite3.connect(store_path) as conn:
|
||||||
|
assert conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0] == 2
|
||||||
|
assert conn.execute("SELECT COUNT(*) FROM pending_publications").fetchone()[0] == 0
|
||||||
|
assert conn.execute("SELECT COUNT(*) FROM local_day_unallocated_evidence").fetchone()[0] == 1
|
||||||
|
|
||||||
|
writer = sqlite3.connect(store_path)
|
||||||
|
writer.execute("DROP TRIGGER stop_retention")
|
||||||
|
writer.commit()
|
||||||
|
writer.close()
|
||||||
|
|
||||||
|
third_time = second_time + timedelta(days=15)
|
||||||
|
third = _collect(config, sysfs_controller, third_time, 1002, 2002)
|
||||||
|
assert third["ok"] is True, third
|
||||||
|
assert third["retention_error"] is None
|
||||||
|
with sqlite3.connect(store_path) as conn:
|
||||||
|
assert conn.execute("SELECT ts FROM samples ORDER BY id").fetchall() == [
|
||||||
|
(third_time.isoformat(),)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_collection_recovers_old_pending_work_before_pruning(
|
||||||
|
tmp_path, sysfs_controller, monkeypatch,
|
||||||
|
):
|
||||||
|
monkeypatch.setenv("TZ", "UTC")
|
||||||
|
store_path = tmp_path / "pending-recovery.db"
|
||||||
|
config = {"device": "/dev/nvme0", "store_path": str(store_path)}
|
||||||
|
first_time = datetime(2026, 9, 1, 0, 0, tzinfo=timezone.utc)
|
||||||
|
second_time = first_time + timedelta(days=15)
|
||||||
|
assert _collect(config, sysfs_controller, first_time, 1000, 2000)["ok"] is True
|
||||||
|
|
||||||
|
writer = sqlite3.connect(store_path)
|
||||||
|
writer.execute(
|
||||||
|
"CREATE TRIGGER interrupt_publication "
|
||||||
|
"BEFORE INSERT ON local_day_unallocated_evidence "
|
||||||
|
"BEGIN SELECT RAISE(ABORT, 'simulated publication interruption'); END"
|
||||||
|
)
|
||||||
|
writer.commit()
|
||||||
|
writer.close()
|
||||||
|
|
||||||
|
failed = _collect(config, sysfs_controller, second_time, 1001, 2001)
|
||||||
|
assert failed["ok"] is False
|
||||||
|
assert "simulated publication interruption" in failed["error"]
|
||||||
|
with sqlite3.connect(store_path) as conn:
|
||||||
|
assert conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0] == 1
|
||||||
|
assert conn.execute("SELECT COUNT(*) FROM pending_publications").fetchone()[0] == 1
|
||||||
|
|
||||||
|
writer = sqlite3.connect(store_path)
|
||||||
|
writer.execute("DROP TRIGGER interrupt_publication")
|
||||||
|
writer.commit()
|
||||||
|
writer.close()
|
||||||
|
|
||||||
|
third_time = second_time + timedelta(minutes=5)
|
||||||
|
recovered = _collect(config, sysfs_controller, third_time, 1002, 2002)
|
||||||
|
assert recovered["ok"] is True, recovered
|
||||||
|
assert recovered["retention_error"] is None
|
||||||
|
with sqlite3.connect(store_path) as conn:
|
||||||
|
assert conn.execute("SELECT COUNT(*) FROM pending_publications").fetchone()[0] == 0
|
||||||
|
assert conn.execute("SELECT ts FROM samples ORDER BY id").fetchall() == [
|
||||||
|
(second_time.isoformat(),),
|
||||||
|
(third_time.isoformat(),),
|
||||||
|
]
|
||||||
|
assert conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM local_day_unallocated_evidence"
|
||||||
|
).fetchone()[0] == 1
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
"""Bound pending-publication admission before device acquisition."""
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||||
|
|
||||||
|
from fenris import collector
|
||||||
|
|
||||||
|
|
||||||
|
class FakeClock:
|
||||||
|
def __init__(self, now):
|
||||||
|
self.now = now
|
||||||
|
|
||||||
|
def utcnow(self):
|
||||||
|
return self.now
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sysfs_controller(tmp_path):
|
||||||
|
controller_path = tmp_path / "sys" / "class" / "nvme" / "nvme0"
|
||||||
|
controller_path.mkdir(parents=True)
|
||||||
|
(controller_path / "subsysnqn").write_text("nqn.test:drive\n")
|
||||||
|
(controller_path / "model").write_text("Test NVMe\n")
|
||||||
|
(controller_path / "serial").write_text("test-serial\n")
|
||||||
|
(controller_path / "firmware_rev").write_text("1.0\n")
|
||||||
|
transport = controller_path / "transport"
|
||||||
|
transport.mkdir()
|
||||||
|
(transport / "trstring").write_text("pcie\n")
|
||||||
|
return controller_path
|
||||||
|
|
||||||
|
|
||||||
|
def _smartctl(units_written):
|
||||||
|
return {
|
||||||
|
"nvme_smart_health_information_log": {
|
||||||
|
"critical_warning": 0,
|
||||||
|
"temperature": 35,
|
||||||
|
"available_spare": 100,
|
||||||
|
"percentage_used": 5,
|
||||||
|
"data_units_written": units_written,
|
||||||
|
"data_units_read": 500,
|
||||||
|
"power_on_hours": 100,
|
||||||
|
},
|
||||||
|
"user_capacity": {"bytes": 1_024_000_000_000},
|
||||||
|
"model_name": "Test NVMe",
|
||||||
|
"serial_number": "test-serial",
|
||||||
|
"firmware_version": "1.0",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _collect(config, controller, now, units_written):
|
||||||
|
return collector.run_collection(
|
||||||
|
config=config,
|
||||||
|
clock=FakeClock(now),
|
||||||
|
acquire=lambda: (_smartctl(units_written), controller),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_partial_recovery_frees_admission_and_keeps_pending_order(
|
||||||
|
tmp_path, sysfs_controller, monkeypatch,
|
||||||
|
):
|
||||||
|
"""A freed slot admits one observation behind older pending evidence."""
|
||||||
|
assert collector.PENDING_PUBLICATION_LIMIT == 6_720
|
||||||
|
monkeypatch.setenv("TZ", "UTC")
|
||||||
|
monkeypatch.setattr(collector, "PENDING_PUBLICATION_LIMIT", 2)
|
||||||
|
|
||||||
|
store_path = tmp_path / "observations.db"
|
||||||
|
config = {"device": "/dev/nvme0", "store_path": str(store_path)}
|
||||||
|
old_time = datetime(2026, 8, 1, tzinfo=timezone.utc)
|
||||||
|
controller = sysfs_controller
|
||||||
|
|
||||||
|
assert _collect(config, controller, old_time, 1000)["ok"] is True
|
||||||
|
|
||||||
|
original_derive = collector.derive_hours_from_interval
|
||||||
|
|
||||||
|
def unavailable_derivation(*_args):
|
||||||
|
raise RuntimeError("derivation unavailable")
|
||||||
|
|
||||||
|
monkeypatch.setattr(collector, "derive_hours_from_interval", unavailable_derivation)
|
||||||
|
first_pending = _collect(
|
||||||
|
config, controller, old_time + timedelta(minutes=5), 1010,
|
||||||
|
)
|
||||||
|
assert first_pending["ok"] is False
|
||||||
|
|
||||||
|
second_pending = _collect(
|
||||||
|
config, controller, old_time + timedelta(minutes=10), 1020,
|
||||||
|
)
|
||||||
|
assert second_pending["ok"] is False
|
||||||
|
with sqlite3.connect(store_path) as conn:
|
||||||
|
assert conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM pending_publications"
|
||||||
|
).fetchone()[0] == 2
|
||||||
|
|
||||||
|
called = False
|
||||||
|
|
||||||
|
def must_not_acquire():
|
||||||
|
nonlocal called
|
||||||
|
called = True
|
||||||
|
return _smartctl(1030), controller
|
||||||
|
|
||||||
|
full = collector.run_collection(
|
||||||
|
config=config,
|
||||||
|
clock=FakeClock(old_time + timedelta(days=40)),
|
||||||
|
acquire=must_not_acquire,
|
||||||
|
)
|
||||||
|
assert full["ok"] is False
|
||||||
|
assert "capacity full (2 observations)" in full["error"]
|
||||||
|
assert called is False
|
||||||
|
with sqlite3.connect(store_path) as conn:
|
||||||
|
assert conn.execute(
|
||||||
|
"SELECT COUNT(*), MIN(sample_ts) FROM pending_publications"
|
||||||
|
).fetchone() == (
|
||||||
|
2,
|
||||||
|
(old_time + timedelta(minutes=5)).isoformat(),
|
||||||
|
)
|
||||||
|
assert conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0] == 1
|
||||||
|
|
||||||
|
def fail_second_pending(conn, previous, current):
|
||||||
|
if current["data_units_written"] == 1020:
|
||||||
|
raise RuntimeError("second observation still blocked")
|
||||||
|
return original_derive(conn, previous, current)
|
||||||
|
|
||||||
|
monkeypatch.setattr(collector, "derive_hours_from_interval", fail_second_pending)
|
||||||
|
acquired = False
|
||||||
|
|
||||||
|
def acquire_after_partial_recovery():
|
||||||
|
nonlocal acquired
|
||||||
|
acquired = True
|
||||||
|
return _smartctl(1030), controller
|
||||||
|
|
||||||
|
partial = collector.run_collection(
|
||||||
|
config=config,
|
||||||
|
clock=FakeClock(old_time + timedelta(days=40, minutes=5)),
|
||||||
|
acquire=acquire_after_partial_recovery,
|
||||||
|
)
|
||||||
|
assert partial["ok"] is False
|
||||||
|
assert acquired is True, partial
|
||||||
|
assert "no new observation acquired" not in partial["error"]
|
||||||
|
|
||||||
|
with sqlite3.connect(store_path) as conn:
|
||||||
|
assert conn.execute(
|
||||||
|
"SELECT data_units_written FROM samples ORDER BY id"
|
||||||
|
).fetchall() == [(1000,), (1010,)]
|
||||||
|
queued_units = [
|
||||||
|
json.loads(row[0])["sample"]["data_units_written"]
|
||||||
|
for row in conn.execute(
|
||||||
|
"SELECT payload FROM pending_publications ORDER BY id"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
assert queued_units == [1020, 1030]
|
||||||
|
assert conn.execute("SELECT COUNT(*) FROM monitoring_periods").fetchone()[0] == 1
|
||||||
|
|
||||||
|
recovered_in_order = []
|
||||||
|
|
||||||
|
def record_recovery_order(conn, previous, current):
|
||||||
|
recovered_in_order.append(current["data_units_written"])
|
||||||
|
return original_derive(conn, previous, current)
|
||||||
|
|
||||||
|
monkeypatch.setattr(collector, "derive_hours_from_interval", record_recovery_order)
|
||||||
|
recovered = collector.run_collection(
|
||||||
|
config=config,
|
||||||
|
clock=FakeClock(old_time + timedelta(days=40, minutes=10)),
|
||||||
|
acquire=lambda: (_smartctl(1040), controller),
|
||||||
|
)
|
||||||
|
assert recovered["ok"] is True, recovered
|
||||||
|
assert recovered_in_order == [1020, 1030, 1040]
|
||||||
|
|
||||||
|
with sqlite3.connect(store_path) as conn:
|
||||||
|
assert conn.execute(
|
||||||
|
"SELECT data_units_written FROM samples ORDER BY id DESC LIMIT 1"
|
||||||
|
).fetchone() == (1040,)
|
||||||
|
assert conn.execute("SELECT COUNT(*) FROM pending_publications").fetchone()[0] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_actual_pending_limit_fails_before_acquisition_and_keeps_old_evidence(
|
||||||
|
tmp_path, sysfs_controller, monkeypatch,
|
||||||
|
):
|
||||||
|
"""The production limit retains every old row and refuses device acquisition."""
|
||||||
|
monkeypatch.setenv("TZ", "UTC")
|
||||||
|
store_path = tmp_path / "full-observations.db"
|
||||||
|
config = {"device": "/dev/nvme0", "store_path": str(store_path)}
|
||||||
|
controller = sysfs_controller
|
||||||
|
old_time = datetime(2026, 7, 1, tzinfo=timezone.utc)
|
||||||
|
assert _collect(config, controller, old_time, 1000)["ok"] is True
|
||||||
|
|
||||||
|
def unavailable_derivation(*_args):
|
||||||
|
raise RuntimeError("derivation unavailable")
|
||||||
|
|
||||||
|
monkeypatch.setattr(collector, "derive_hours_from_interval", unavailable_derivation)
|
||||||
|
failed = _collect(
|
||||||
|
config, controller, old_time + timedelta(minutes=5), 1010,
|
||||||
|
)
|
||||||
|
assert failed["ok"] is False
|
||||||
|
|
||||||
|
with sqlite3.connect(store_path) as conn:
|
||||||
|
payload_row = conn.execute(
|
||||||
|
"SELECT payload FROM pending_publications ORDER BY id LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
assert payload_row is not None
|
||||||
|
base = json.loads(payload_row[0])
|
||||||
|
base_observed_at = datetime.fromisoformat(base["sample"]["ts"])
|
||||||
|
base_units = base["sample"]["data_units_written"]
|
||||||
|
rows = []
|
||||||
|
for offset in range(1, collector.PENDING_PUBLICATION_LIMIT):
|
||||||
|
observation = json.loads(payload_row[0])
|
||||||
|
units_written = base_units + offset
|
||||||
|
observed_at = base_observed_at + timedelta(minutes=5 * offset)
|
||||||
|
observation["sample"]["ts"] = observed_at.isoformat()
|
||||||
|
observation["sample"]["data_units_written"] = units_written
|
||||||
|
observation["sample"]["bytes_written"] = units_written * 512_000
|
||||||
|
rows.append((observed_at.isoformat(), json.dumps(observation)))
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT INTO pending_publications (sample_ts, payload) VALUES (?, ?)",
|
||||||
|
rows,
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
called = False
|
||||||
|
|
||||||
|
def must_not_acquire():
|
||||||
|
nonlocal called
|
||||||
|
called = True
|
||||||
|
return _smartctl(9000), controller
|
||||||
|
|
||||||
|
result = collector.run_collection(
|
||||||
|
config=config,
|
||||||
|
clock=FakeClock(old_time + timedelta(days=40)),
|
||||||
|
acquire=must_not_acquire,
|
||||||
|
)
|
||||||
|
assert result["ok"] is False
|
||||||
|
assert f"capacity full ({collector.PENDING_PUBLICATION_LIMIT} observations)" in result[
|
||||||
|
"error"
|
||||||
|
]
|
||||||
|
assert called is False
|
||||||
|
|
||||||
|
with sqlite3.connect(store_path) as conn:
|
||||||
|
pending_rows = conn.execute(
|
||||||
|
"SELECT sample_ts, payload FROM pending_publications ORDER BY id"
|
||||||
|
).fetchall()
|
||||||
|
assert len(pending_rows) == collector.PENDING_PUBLICATION_LIMIT
|
||||||
|
assert (pending_rows[0][0], pending_rows[-1][0]) == (
|
||||||
|
(old_time + timedelta(minutes=5)).isoformat(),
|
||||||
|
(
|
||||||
|
old_time
|
||||||
|
+ timedelta(minutes=5 * collector.PENDING_PUBLICATION_LIMIT)
|
||||||
|
).isoformat(),
|
||||||
|
)
|
||||||
|
assert [
|
||||||
|
json.loads(payload)["sample"]["data_units_written"]
|
||||||
|
for _, payload in pending_rows
|
||||||
|
] == list(range(1010, 1010 + collector.PENDING_PUBLICATION_LIMIT))
|
||||||
|
assert conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0] == 1
|
||||||
|
assert conn.execute("SELECT COUNT(*) FROM monitoring_periods").fetchone()[0] == 1
|
||||||
@@ -0,0 +1,380 @@
|
|||||||
|
"""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, activity_intervals) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)",
|
||||||
|
(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)
|
||||||
|
ensure_period_open(conn, base)
|
||||||
|
conn.commit()
|
||||||
|
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)
|
||||||
|
ensure_period_open(conn, base)
|
||||||
|
conn.commit()
|
||||||
|
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()
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
"""Tests for issue #77: Show honest qualifying-day progress and confidence.
|
||||||
|
|
||||||
|
These tests verify that:
|
||||||
|
1. Warming progress shows detailed qualifying day breakdown
|
||||||
|
2. Confidence state accurately reflects qualifying day progress
|
||||||
|
3. Edge cases like zero-delta intervals and unknown daily shares are handled
|
||||||
|
4. qualifying_days_progress shows honest qualifying day count
|
||||||
|
"""
|
||||||
|
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.projection import (
|
||||||
|
compute_projection, ConfidenceState, BaselineTier,
|
||||||
|
WARMING_MIN_DAYS, WARMING_COVERAGE_FLOOR, WARMING_MAX_LOW_COVERAGE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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, model="Samsung SSD 970 EVO Plus 1TB"):
|
||||||
|
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", model, 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",
|
||||||
|
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_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 _open_period(conn, start="2026-09-01T00:00:00+00:00"):
|
||||||
|
# Convert string to datetime if needed
|
||||||
|
if isinstance(start, str):
|
||||||
|
start = datetime.fromisoformat(start)
|
||||||
|
ensure_period_open(conn, start)
|
||||||
|
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, activity_intervals) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)",
|
||||||
|
(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."""
|
||||||
|
|
||||||
|
def test_warming_shows_qualifying_vs_nonqualifying(self, store):
|
||||||
|
"""Warming progress shows both qualifying and non-qualifying days."""
|
||||||
|
_insert_baseline(store, tbw_tb=10.0, verified=True)
|
||||||
|
_insert_segment(store, opened_at="2026-09-20T00:00:00+00:00")
|
||||||
|
_open_period(store, start="2026-09-20T00:00:00+00:00")
|
||||||
|
bw = 100 * 1024 * 1024
|
||||||
|
|
||||||
|
# 12 qualifying days + 2 non-qualifying (low coverage)
|
||||||
|
for i in range(14):
|
||||||
|
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
cov = 0.30 if i < 2 else 0.95 # First 2 days have low coverage
|
||||||
|
_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
|
||||||
|
assert result.warming_fact is None # Not warming (14 total, 2 below <= WARMING_MAX_LOW_COVERAGE)
|
||||||
|
assert result.qualifying_days_progress is not None
|
||||||
|
assert "12 of 14 qualifying days" in result.qualifying_days_progress
|
||||||
|
assert "2 below coverage" in result.qualifying_days_progress
|
||||||
|
|
||||||
|
def test_warming_progress_includes_nonqualifying_reason(self, store):
|
||||||
|
"""Warming progress indicates why days don't qualify."""
|
||||||
|
_insert_baseline(store, tbw_tb=10.0, verified=True)
|
||||||
|
_insert_segment(store, opened_at="2026-09-20T00:00:00+00:00")
|
||||||
|
_open_period(store, start="2026-09-20T00:00:00+00:00")
|
||||||
|
bw = 100 * 1024 * 1024
|
||||||
|
|
||||||
|
# 13 qualifying days + 1 non-qualifying (zero samples)
|
||||||
|
for i in range(14):
|
||||||
|
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
samples = 0 if i == 0 else 24 # First day has no samples
|
||||||
|
_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
|
||||||
|
assert result.warming_fact is None # Not warming (14 total, 1 below <= WARMING_MAX_LOW_COVERAGE)
|
||||||
|
assert result.qualifying_days_progress is not None
|
||||||
|
assert "13 of 14 qualifying days" in result.qualifying_days_progress
|
||||||
|
assert "1 below coverage" in result.qualifying_days_progress
|
||||||
|
|
||||||
|
def test_confidence_updates_as_qualifying_days_increase(self, store):
|
||||||
|
"""Confidence state reflects actual qualifying day count."""
|
||||||
|
_insert_baseline(store, tbw_tb=10.0, verified=True)
|
||||||
|
_insert_segment(store, opened_at="2026-09-20T00:00:00+00:00")
|
||||||
|
_open_period(store, start="2026-09-20T00:00:00+00:00")
|
||||||
|
bw = 100 * 1024 * 1024
|
||||||
|
|
||||||
|
# Start with 10 days (below minimum)
|
||||||
|
for i in range(10):
|
||||||
|
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 be warming
|
||||||
|
assert result.warming_fact is not None
|
||||||
|
assert result.confidence_state == ConfidenceState.LIMITED
|
||||||
|
|
||||||
|
def test_zero_rate_with_qualifying_days(self, store):
|
||||||
|
"""Zero rate with qualifying days shows honest state."""
|
||||||
|
_insert_baseline(store, tbw_tb=10.0, verified=True)
|
||||||
|
_insert_segment(store, opened_at="2026-09-20T00:00:00+00:00")
|
||||||
|
_open_period(store, start="2026-09-20T00:00:00+00:00")
|
||||||
|
|
||||||
|
# 14 days with zero bytes written
|
||||||
|
for i in range(14):
|
||||||
|
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_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
|
||||||
|
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
||||||
|
assert result.zero_rate_fact is not None
|
||||||
|
assert "no finite projection" in result.zero_rate_fact
|
||||||
|
|
||||||
|
def test_qualifying_days_excludes_low_coverage(self, store):
|
||||||
|
"""Days below 50% coverage don't count as qualifying."""
|
||||||
|
_insert_baseline(store, tbw_tb=10.0, verified=True)
|
||||||
|
_insert_segment(store, opened_at="2026-09-20T00:00:00+00:00")
|
||||||
|
_open_period(store, start="2026-09-20T00:00:00+00:00")
|
||||||
|
bw = 100 * 1024 * 1024
|
||||||
|
|
||||||
|
# 14 days total, but 3 have low coverage
|
||||||
|
for i in range(14):
|
||||||
|
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
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())
|
||||||
|
|
||||||
|
# Should still be warming (3 days below coverage > WARMING_MAX_LOW_COVERAGE=2)
|
||||||
|
assert result.warming_fact is not None
|
||||||
|
assert "warming up" in result.warming_fact.lower()
|
||||||
|
|
||||||
|
def test_qualifying_days_excludes_zero_samples(self, store):
|
||||||
|
"""Days with zero samples don't count as qualifying."""
|
||||||
|
_insert_baseline(store, tbw_tb=10.0, verified=True)
|
||||||
|
_insert_segment(store, opened_at="2026-09-20T00:00:00+00:00")
|
||||||
|
_open_period(store, start="2026-09-20T00:00:00+00:00")
|
||||||
|
bw = 100 * 1024 * 1024
|
||||||
|
|
||||||
|
# 14 days total, but 3 have zero samples
|
||||||
|
for i in range(14):
|
||||||
|
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
samples = 0 if i < 3 else 24
|
||||||
|
_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
|
||||||
|
assert result.warming_fact is not None
|
||||||
|
assert "warming up" in result.warming_fact.lower()
|
||||||
|
|
||||||
|
def test_qualifying_days_progress_after_warming(self, store):
|
||||||
|
"""After warming, qualifying_days_progress shows honest count."""
|
||||||
|
_insert_baseline(store, tbw_tb=10.0, verified=True)
|
||||||
|
_insert_segment(store, opened_at="2026-09-17T00:00:00+00:00")
|
||||||
|
_open_period(store, start="2026-09-17T00:00:00+00:00")
|
||||||
|
bw = 100 * 1024 * 1024
|
||||||
|
|
||||||
|
# 14 total days, 12 qualifying (2 below coverage)
|
||||||
|
for i in range(14):
|
||||||
|
d = (datetime(2026, 9, 17) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
cov = 0.30 if i < 2 else 0.95 # First 2 days have low coverage
|
||||||
|
_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)
|
||||||
|
assert result.warming_fact is None
|
||||||
|
# But qualifying_days_progress should show the honest count
|
||||||
|
assert result.qualifying_days_progress is not None
|
||||||
|
assert "12 of 14 qualifying days" in result.qualifying_days_progress
|
||||||
|
assert "2 below coverage" in result.qualifying_days_progress
|
||||||
|
|
||||||
|
def test_qualifying_days_progress_all_qualifying(self, store):
|
||||||
|
"""When all days qualify, qualifying_days_progress shows 100%."""
|
||||||
|
_insert_baseline(store, tbw_tb=10.0, verified=True)
|
||||||
|
_insert_segment(store, opened_at="2026-09-17T00:00:00+00:00")
|
||||||
|
_open_period(store, start="2026-09-17T00:00:00+00:00")
|
||||||
|
bw = 100 * 1024 * 1024
|
||||||
|
|
||||||
|
# 14 days, all qualifying
|
||||||
|
for i in range(14):
|
||||||
|
d = (datetime(2026, 9, 17) + 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 not be warming
|
||||||
|
assert result.warming_fact is None
|
||||||
|
# qualifying_days_progress should show all qualifying
|
||||||
|
assert result.qualifying_days_progress is not None
|
||||||
|
assert "14 of 14 qualifying days" in result.qualifying_days_progress
|
||||||
|
# Should not show "below coverage" since all qualify
|
||||||
|
assert "below coverage" not in result.qualifying_days_progress
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
"""Tests for issue #79: Apply Fenris identity and Drive health grouping.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- TPH-1: Titlebox shows Fenris identity with wolf fallback
|
||||||
|
- TPH-11: Single maker-credit placement, vendor wear under Drive health
|
||||||
|
- Preserve: continuity, pause block, quit rail, auth banner
|
||||||
|
"""
|
||||||
|
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.tui import FenrisTuiApp
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers (reuse from test_tui.py)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
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,
|
||||||
|
model="Samsung SSD 970 EVO Plus 1TB"):
|
||||||
|
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", model,
|
||||||
|
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",
|
||||||
|
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_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, 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) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(ts, device, 1000000, 500000, pu, 512000000000, 256000000000, 8765),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _open_period(conn, start="2026-09-01T00:00:00+00:00"):
|
||||||
|
ensure_period_open(conn, datetime.fromisoformat(start))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Issue #79: Fenris identity and Drive health grouping
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestFenrisIdentity:
|
||||||
|
"""TPH-1: Titlebox shows Fenris identity with wolf fallback."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_titlebox_shows_wolf_identity(self, tmp_path):
|
||||||
|
"""Top titlebox reads 🐺 Fenris by Bongbetic."""
|
||||||
|
app = FenrisTuiApp(
|
||||||
|
store_path=tmp_path / "nonexistent.db",
|
||||||
|
refresh_interval_s=0.2,
|
||||||
|
)
|
||||||
|
async with app.run_test(size=(120, 24)) as pilot:
|
||||||
|
headline = str(app.query_one("#headline-band").render())
|
||||||
|
assert "🐺 Fenris by Bongbetic" in str(app.query_one("#identity").render())
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_titlebox_fallback_without_wolf(self, tmp_path):
|
||||||
|
"""Fallback to 'Fenris by Bongbetic' when wolf is unsupported."""
|
||||||
|
app = FenrisTuiApp(
|
||||||
|
store_path=tmp_path / "nonexistent.db",
|
||||||
|
refresh_interval_s=0.2,
|
||||||
|
)
|
||||||
|
# Simulate narrow terminal that can't render wolf
|
||||||
|
async with app.run_test(size=(60, 24)) as pilot:
|
||||||
|
headline = str(app.query_one("#headline-band").render())
|
||||||
|
# Either shows wolf or fallback - both are acceptable
|
||||||
|
assert "Fenris by Bongbetic" in str(app.query_one("#identity").render())
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_wolf_never_shows_tofu(self, tmp_path):
|
||||||
|
"""Wolf glyph is never rendered as tofu (unsupported character)."""
|
||||||
|
app = FenrisTuiApp(
|
||||||
|
store_path=tmp_path / "nonexistent.db",
|
||||||
|
refresh_interval_s=0.2,
|
||||||
|
)
|
||||||
|
async with app.run_test(size=(120, 24)) as pilot:
|
||||||
|
headline = str(app.query_one("#headline-band").render())
|
||||||
|
# No replacement character (U+FFFD) should appear
|
||||||
|
assert "\ufffd" not in headline.lower()
|
||||||
|
assert "?" not in headline or "Fenris" in headline
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_lifespan_headline_is_data_surface(self, tmp_path):
|
||||||
|
"""Lifespan headline remains a data surface, not app title."""
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
_insert_baseline(conn, tbw_tb=1.0, verified=True)
|
||||||
|
_insert_segment(conn)
|
||||||
|
_open_period(conn)
|
||||||
|
for i in range(30):
|
||||||
|
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_day(conn, d, bw=1024*1024*100, coverage=0.95, samples=24)
|
||||||
|
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||||
|
async with app.run_test(size=(120, 24)) as pilot:
|
||||||
|
headline = str(app.query_one("#headline-band").render())
|
||||||
|
# Identity appears once, lifespan is separate data
|
||||||
|
assert "🐺 Fenris by Bongbetic" in str(app.query_one("#identity").render())
|
||||||
|
assert "remaining" in headline.lower() or "projection" in headline.lower()
|
||||||
|
|
||||||
|
|
||||||
|
class TestSingleMakerCredit:
|
||||||
|
"""TPH-1: Single maker-credit placement in title only."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_maker_credit_not_in_service_strip(self, tmp_path):
|
||||||
|
"""Remove duplicate maker credit from service facts."""
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
_insert_segment(conn)
|
||||||
|
_open_period(conn)
|
||||||
|
for i in range(20):
|
||||||
|
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_day(conn, d, bw=1024*1024*100)
|
||||||
|
_insert_sample(conn, "2026-09-30T10:00:00+00:00")
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||||
|
async with app.run_test(size=(120, 24)) as pilot:
|
||||||
|
strip = str(app.query_one("#service-strip").render())
|
||||||
|
# "by Bongbetic" should NOT appear in service strip
|
||||||
|
assert "by Bongbetic" not in strip
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_maker_credit_in_titlebox(self, tmp_path):
|
||||||
|
"""Maker credit appears in the titlebox identity."""
|
||||||
|
app = FenrisTuiApp(
|
||||||
|
store_path=tmp_path / "nonexistent.db",
|
||||||
|
refresh_interval_s=0.2,
|
||||||
|
)
|
||||||
|
async with app.run_test(size=(120, 24)) as pilot:
|
||||||
|
headline = str(app.query_one("#headline-band").render())
|
||||||
|
assert "Fenris by Bongbetic" in str(app.query_one("#identity").render())
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_empty_store_no_maker_credit_in_strip(self, tmp_path):
|
||||||
|
"""Empty store: no maker credit in service strip."""
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||||
|
async with app.run_test(size=(120, 24)) as pilot:
|
||||||
|
strip = str(app.query_one("#service-strip").render())
|
||||||
|
assert "by Bongbetic" not in strip
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_store_fault_no_maker_credit_in_strip(self, tmp_path):
|
||||||
|
"""Store fault: no maker credit in service strip."""
|
||||||
|
# Create a corrupt store
|
||||||
|
db = tmp_path / "test.db"
|
||||||
|
db.write_bytes(b"not a database")
|
||||||
|
app = FenrisTuiApp(store_path=db)
|
||||||
|
async with app.run_test(size=(120, 24)) as pilot:
|
||||||
|
strip = str(app.query_one("#service-strip").render())
|
||||||
|
assert "by Bongbetic" not in strip
|
||||||
|
|
||||||
|
|
||||||
|
class TestDriveHealthVendorWear:
|
||||||
|
"""TPH-1: Vendor wear renders under Drive health context."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_vendor_wear_in_drive_health(self, tmp_path):
|
||||||
|
"""Vendor wear shows with health context, not as Settings."""
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
_insert_segment(conn)
|
||||||
|
_open_period(conn)
|
||||||
|
for i in range(20):
|
||||||
|
d = (datetime(2026, 9, 10) + 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", pu=10)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||||
|
async with app.run_test(size=(120, 24)) as pilot:
|
||||||
|
health = str(app.query_one("#drive-health").render())
|
||||||
|
# Vendor wear should be present with health context
|
||||||
|
assert "vendor wear" in health.lower()
|
||||||
|
# Should show thermal, spare, errors, etc.
|
||||||
|
assert "temperature" in health.lower()
|
||||||
|
assert "spare" in health.lower()
|
||||||
|
assert "media errors" in health.lower()
|
||||||
|
# Settings section should NOT be a separate heading
|
||||||
|
assert "[bold]Settings[/bold]" not in health
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_missing_wear_values_honest(self, tmp_path):
|
||||||
|
"""Missing values remain honest unavailable facts."""
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
_insert_segment(conn)
|
||||||
|
_open_period(conn)
|
||||||
|
for i in range(20):
|
||||||
|
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_day(conn, d, bw=1024*1024*100)
|
||||||
|
# Insert sample with zero wear values
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO samples (ts, device, data_units_written, data_units_read, "
|
||||||
|
"percentage_used, bytes_written, bytes_read, power_on_hours) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
("2026-09-30T10:00:00+00:00", "/dev/nvme0n1", 0, 0, 0, 0, 0, 0),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||||
|
async with app.run_test(size=(120, 24)) as pilot:
|
||||||
|
health = str(app.query_one("#drive-health").render())
|
||||||
|
# Should show 0% used or honest zero, not crash
|
||||||
|
assert "vendor wear" in health.lower()
|
||||||
|
|
||||||
|
|
||||||
|
class TestPreservedBehavior:
|
||||||
|
"""Preserve existing continuity, pause, quit, auth behavior."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_continuity_preserved(self, tmp_path):
|
||||||
|
"""Continuity wording preserved in service strip."""
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
_open_period(conn)
|
||||||
|
conn.close()
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||||
|
|
||||||
|
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,
|
||||||
|
}):
|
||||||
|
async with app.run_test(size=(120, 24)) as pilot:
|
||||||
|
strip = str(app.query_one("#service-strip").render())
|
||||||
|
assert "continuity" in strip.lower()
|
||||||
|
assert "monitoring" in strip.lower()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_quit_rail_preserved(self, tmp_path):
|
||||||
|
"""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("#action-rail").render())
|
||||||
|
assert "q Quit TUI" in rail
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_auth_banner_preserved(self, tmp_path):
|
||||||
|
"""Polkit authentication banner lifecycle preserved."""
|
||||||
|
app = FenrisTuiApp(
|
||||||
|
store_path=tmp_path / "nonexistent.db",
|
||||||
|
refresh_interval_s=0.2,
|
||||||
|
)
|
||||||
|
async with app.run_test(size=(120, 24)) as pilot:
|
||||||
|
headline = str(app.query_one("#headline-band").render())
|
||||||
|
assert "polkit" in str(app.query_one("#auth-notice").render()).lower()
|
||||||
|
# Should clear after first tick
|
||||||
|
await pilot.pause(0.25)
|
||||||
|
headline_after = str(app.query_one("#headline-band").render())
|
||||||
|
assert not app.query_one("#auth-notice").display
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_deliberate_pause_block_preserved(self, tmp_path):
|
||||||
|
"""Deliberate pause banner preserved."""
|
||||||
|
db = tmp_path / "test.db"
|
||||||
|
conn = init_store(db)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO monitoring_periods (started_at, ended_at, end_cause) "
|
||||||
|
"VALUES (?, ?, ?)",
|
||||||
|
("2026-09-01T09:00:00+00:00", "2026-09-01T10:00:00+00:00", "user_disabled"),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
app = FenrisTuiApp(store_path=db)
|
||||||
|
|
||||||
|
with patch("fenris.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,
|
||||||
|
}):
|
||||||
|
async with app.run_test(size=(120, 24)) as pilot:
|
||||||
|
banner = str(app.query_one("#paused-banner").render())
|
||||||
|
assert "paused" in banner.lower()
|
||||||
|
assert "deliberate" in banner.lower()
|
||||||
@@ -0,0 +1,489 @@
|
|||||||
|
"""Integration tests for issue #80: Persist accessible colour and motion preferences.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- AC80-1: Amber/Nord/High Contrast presets with Amber default
|
||||||
|
- AC80-2: t preset and m motion controls with clickable equivalents
|
||||||
|
- AC80-3: Persistent user-scoped XDG TUI preferences
|
||||||
|
- AC80-4: Reduced motion makes Monitoring steady
|
||||||
|
- AC80-5: Theme roles for graph rendering
|
||||||
|
- AC80-6: Headless interaction tests with temporary user config
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
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.preferences import (
|
||||||
|
load_preferences,
|
||||||
|
save_preferences,
|
||||||
|
get_preference_path,
|
||||||
|
)
|
||||||
|
from fenris.themes import get_theme, get_graph_colors, THEME_NAMES
|
||||||
|
from fenris.status_composition import (
|
||||||
|
StatusState,
|
||||||
|
compose_status,
|
||||||
|
render_status_tui,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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_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, 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) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(ts, device, 1000000, 500000, 5, 512000000000, 256000000000, 8765),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _open_period(conn, start="2026-09-01T00:00:00+00:00"):
|
||||||
|
from fenris.monitoring_periods import ensure_period_open
|
||||||
|
ensure_period_open(conn, datetime.fromisoformat(start))
|
||||||
|
|
||||||
|
|
||||||
|
def _make_prefs_dir(tmp_path: Path) -> Path:
|
||||||
|
config_home = tmp_path / ".config"
|
||||||
|
config_home.mkdir(parents=True, exist_ok=True)
|
||||||
|
return config_home
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC80-1: Preset loading and application
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestPresetLoading:
|
||||||
|
"""Themes load from preferences and apply to the TUI."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tui_applies_chalktone_theme_by_default(self, tmp_path):
|
||||||
|
"""TUI starts with the Amber theme when no preferences exist."""
|
||||||
|
from fenris.tui import FenrisTuiApp
|
||||||
|
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
# Theme should be fenris-amber
|
||||||
|
assert app.theme == "fenris-chalktone"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tui_applies_nord_theme_from_prefs(self, tmp_path):
|
||||||
|
"""TUI applies Nord theme from saved preferences."""
|
||||||
|
from fenris.tui import FenrisTuiApp
|
||||||
|
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
save_preferences(theme="nord", reduced_motion=False)
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
assert app.theme == "fenris-nord"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tui_applies_high_contrast_from_prefs(self, tmp_path):
|
||||||
|
"""TUI applies High Contrast theme from saved preferences."""
|
||||||
|
from fenris.tui import FenrisTuiApp
|
||||||
|
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
save_preferences(theme="high_contrast", reduced_motion=False)
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
assert app.theme == "fenris-high-contrast"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tui_applies_reduced_motion_from_prefs(self, tmp_path):
|
||||||
|
"""TUI respects reduced_motion preference."""
|
||||||
|
from fenris.tui import FenrisTuiApp
|
||||||
|
from fenris.status_composition import compose_status
|
||||||
|
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
save_preferences(theme="amber", reduced_motion=True)
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
# The app should have reduced_motion set
|
||||||
|
assert app._reduced_motion is True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC80-2: t and m key bindings
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestKeyBindings:
|
||||||
|
"""t cycles presets and m toggles reduced motion."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_t_binding_exists(self, tmp_path):
|
||||||
|
"""The TUI has a 't' binding for theme cycling."""
|
||||||
|
from fenris.tui import FenrisTuiApp
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||||
|
binding_keys = {b.key for b in app.BINDINGS}
|
||||||
|
assert "t" in binding_keys
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_m_binding_exists(self, tmp_path):
|
||||||
|
"""The TUI has an 'm' binding for motion toggle."""
|
||||||
|
from fenris.tui import FenrisTuiApp
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||||
|
binding_keys = {b.key for b in app.BINDINGS}
|
||||||
|
assert "m" in binding_keys
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
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)
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
# Start at amber
|
||||||
|
assert app.theme == "fenris-chalktone"
|
||||||
|
|
||||||
|
# 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 back in daily mode
|
||||||
|
assert graph.view_mode == "daily"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_m_toggles_reduced_motion(self, tmp_path):
|
||||||
|
"""Pressing m toggles reduced motion."""
|
||||||
|
from fenris.tui import FenrisTuiApp
|
||||||
|
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
# Start with normal motion
|
||||||
|
assert app._reduced_motion is False
|
||||||
|
|
||||||
|
# Press m to toggle
|
||||||
|
await pilot.press("m")
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
# Should be reduced motion now
|
||||||
|
assert app._reduced_motion is True
|
||||||
|
|
||||||
|
# Press m again to toggle back
|
||||||
|
await pilot.press("m")
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
assert app._reduced_motion is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC80-3: Persistence across restart
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestPersistence:
|
||||||
|
"""Preferences survive app restart."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
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 via preferences API
|
||||||
|
app1 = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||||
|
async with app1.run_test() as pilot:
|
||||||
|
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
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reduced_motion_survives_restart(self, tmp_path):
|
||||||
|
"""Reduced motion preference persists across TUI restart."""
|
||||||
|
from fenris.tui import FenrisTuiApp
|
||||||
|
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
# First run: toggle motion
|
||||||
|
app1 = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||||
|
async with app1.run_test() as pilot:
|
||||||
|
await pilot.press("m")
|
||||||
|
await pilot.pause()
|
||||||
|
assert app1._reduced_motion is True
|
||||||
|
|
||||||
|
# Second run: motion should persist
|
||||||
|
app2 = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||||
|
async with app2.run_test() as pilot:
|
||||||
|
assert app2._reduced_motion is True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC80-4: Reduced motion makes Monitoring steady
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestReducedMotion:
|
||||||
|
"""Reduced motion disables the Monitoring dot blink."""
|
||||||
|
|
||||||
|
def test_reduced_motion_disables_blink(self, tmp_path):
|
||||||
|
"""compose_status with reduced_motion=True → should_blink=False."""
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
_insert_segment(conn)
|
||||||
|
_open_period(conn)
|
||||||
|
now = _clock()
|
||||||
|
ts = (now - timedelta(minutes=2)).isoformat()
|
||||||
|
_insert_sample(conn, ts)
|
||||||
|
for i in range(20):
|
||||||
|
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_day(conn, d, bw=1024*1024*100)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
svc = {
|
||||||
|
"boot_enabled": True, "timer_active": True,
|
||||||
|
"last_collect_ok": True, "last_collect_age_s": 120,
|
||||||
|
"last_collect_reason": None,
|
||||||
|
}
|
||||||
|
comp = compose_status(conn, svc, now, store_fault=None, newer_schema=None,
|
||||||
|
reduced_motion=True)
|
||||||
|
assert comp.state == StatusState.MONITORING
|
||||||
|
assert comp.should_blink is False
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_normal_motion_allows_blink(self, tmp_path):
|
||||||
|
"""compose_status with reduced_motion=False → should_blink=True for Monitoring."""
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
_insert_segment(conn)
|
||||||
|
_open_period(conn)
|
||||||
|
now = _clock()
|
||||||
|
ts = (now - timedelta(minutes=2)).isoformat()
|
||||||
|
_insert_sample(conn, ts)
|
||||||
|
for i in range(20):
|
||||||
|
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_day(conn, d, bw=1024*1024*100)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
svc = {
|
||||||
|
"boot_enabled": True, "timer_active": True,
|
||||||
|
"last_collect_ok": True, "last_collect_age_s": 120,
|
||||||
|
"last_collect_reason": None,
|
||||||
|
}
|
||||||
|
comp = compose_status(conn, svc, now, store_fault=None, newer_schema=None,
|
||||||
|
reduced_motion=False)
|
||||||
|
assert comp.state == StatusState.MONITORING
|
||||||
|
assert comp.should_blink is True
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_framework_reduced_motion_honoured(self, tmp_path):
|
||||||
|
"""TUI honours Textual's reduced_motion signal."""
|
||||||
|
from fenris.tui import FenrisTuiApp
|
||||||
|
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
# The app should check for reduced motion on mount
|
||||||
|
assert hasattr(app, '_reduced_motion')
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC80-5: Theme roles for graph rendering
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestGraphThemeRoles:
|
||||||
|
"""Graph uses theme-derived colours for each bar role."""
|
||||||
|
|
||||||
|
def test_graph_colors_available_for_all_themes(self):
|
||||||
|
"""Every theme provides all required graph colour roles."""
|
||||||
|
required_roles = {"allocated", "unallocated", "gap", "zero", "partial"}
|
||||||
|
for name in THEME_NAMES:
|
||||||
|
colors = get_graph_colors(name)
|
||||||
|
assert required_roles.issubset(set(colors.keys())), "Theme %s missing roles: %s" % (name, required_roles - set(colors.keys()))
|
||||||
|
|
||||||
|
def test_graph_colors_are_strings(self):
|
||||||
|
"""All graph colour values are strings (Textual CSS colour values)."""
|
||||||
|
for name in THEME_NAMES:
|
||||||
|
colors = get_graph_colors(name)
|
||||||
|
for role, color in colors.items():
|
||||||
|
assert isinstance(color, str), "Theme %s role %s has non-string color: %s" % (name, role, color)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC80-6: Safe persistence — failure modes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestSafePersistence:
|
||||||
|
"""Preference failures never crash the dashboard."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_corrupt_prefs_does_not_crash_tui(self, tmp_path):
|
||||||
|
"""Corrupt preference file does not prevent TUI from starting."""
|
||||||
|
from fenris.tui import FenrisTuiApp
|
||||||
|
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
fenris_dir = config_home / "fenris"
|
||||||
|
fenris_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(fenris_dir / "preferences.json").write_text("{bad json!!!")
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
# TUI should start with default theme
|
||||||
|
assert app.theme == "fenris-chalktone"
|
||||||
|
# Dashboard should be functional
|
||||||
|
headline = str(app.query_one("#headline-band").render())
|
||||||
|
assert headline is not None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_readonly_config_dir_does_not_crash(self, tmp_path):
|
||||||
|
"""Read-only config directory does not prevent TUI from starting."""
|
||||||
|
from fenris.tui import FenrisTuiApp
|
||||||
|
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
fenris_dir = config_home / "fenris"
|
||||||
|
fenris_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
fenris_dir.chmod(0o555)
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
# TUI should start without crash
|
||||||
|
assert app.theme in ("fenris-chalktone", "fenris-amber", "fenris-nord", "fenris-high-contrast")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CLI isolation (preferences do not affect CLI)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestCLIIsolation:
|
||||||
|
"""Preferences are TUI-only — CLI status is independent."""
|
||||||
|
|
||||||
|
def test_cli_status_unchanged_by_theme(self, tmp_path):
|
||||||
|
"""fenris status output does not change based on theme preference."""
|
||||||
|
from fenris.status import get_status
|
||||||
|
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
_insert_segment(conn)
|
||||||
|
_open_period(conn)
|
||||||
|
now = _clock()
|
||||||
|
ts = (now - timedelta(minutes=2)).isoformat()
|
||||||
|
_insert_sample(conn, ts)
|
||||||
|
for i in range(20):
|
||||||
|
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_day(conn, d, bw=1024*1024*100)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
|
||||||
|
# With amber theme
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
save_preferences(theme="amber", reduced_motion=False)
|
||||||
|
status_amber = get_status(
|
||||||
|
store_path=tmp_path / "test.db",
|
||||||
|
clock_now=now,
|
||||||
|
query_services=False,
|
||||||
|
query_journal=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# With high contrast theme
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
save_preferences(theme="high_contrast", reduced_motion=True)
|
||||||
|
status_hc = get_status(
|
||||||
|
store_path=tmp_path / "test.db",
|
||||||
|
clock_now=now,
|
||||||
|
query_services=False,
|
||||||
|
query_journal=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Status output should be identical
|
||||||
|
assert status_amber == status_hc
|
||||||
|
|
||||||
|
def test_cli_status_unchanged_by_motion(self, tmp_path):
|
||||||
|
"""fenris status output does not change based on reduced_motion preference."""
|
||||||
|
from fenris.status import get_status
|
||||||
|
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
_insert_segment(conn)
|
||||||
|
_open_period(conn)
|
||||||
|
now = _clock()
|
||||||
|
ts = (now - timedelta(minutes=2)).isoformat()
|
||||||
|
_insert_sample(conn, ts)
|
||||||
|
for i in range(20):
|
||||||
|
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_day(conn, d, bw=1024*1024*100)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
save_preferences(theme="amber", reduced_motion=False)
|
||||||
|
status_normal = get_status(
|
||||||
|
store_path=tmp_path / "test.db",
|
||||||
|
clock_now=now,
|
||||||
|
query_services=False,
|
||||||
|
query_journal=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
save_preferences(theme="amber", reduced_motion=True)
|
||||||
|
status_reduced = get_status(
|
||||||
|
store_path=tmp_path / "test.db",
|
||||||
|
clock_now=now,
|
||||||
|
query_services=False,
|
||||||
|
query_journal=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert status_normal == status_reduced
|
||||||
@@ -0,0 +1,837 @@
|
|||||||
|
"""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,
|
||||||
|
local_day_boundaries,
|
||||||
|
persist_local_day,
|
||||||
|
)
|
||||||
|
from fenris.tz_util import detect_system_tz
|
||||||
|
from fenris.tui import (
|
||||||
|
FenrisTuiApp,
|
||||||
|
_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,
|
||||||
|
utc_start=None, utc_end=None):
|
||||||
|
if utc_start is None:
|
||||||
|
utc_start = f"{local_date}T00:00:00+00:00"
|
||||||
|
if utc_end is None:
|
||||||
|
utc_end = f"{(datetime.fromisoformat(local_date) + timedelta(days=1)).strftime('%Y-%m-%d')}T00:00:00+00:00"
|
||||||
|
summary = LocalDaySummary(
|
||||||
|
local_date=local_date,
|
||||||
|
tz_name=tz_name,
|
||||||
|
tz_offset=tz_offset,
|
||||||
|
utc_start=utc_start,
|
||||||
|
utc_end=utc_end,
|
||||||
|
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
|
||||||
|
|
||||||
|
class MockDatetime(_real_datetime):
|
||||||
|
@classmethod
|
||||||
|
def now(cls, tz=None):
|
||||||
|
return clock
|
||||||
|
|
||||||
|
mock_dt = MockDatetime
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def _visible_selected_date(app):
|
||||||
|
return str(app.query_one("#local-day").render())[:10]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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()
|
||||||
|
# Move to an older day first so ] can advance
|
||||||
|
for _ in range(3):
|
||||||
|
await pilot.press("left_square_bracket")
|
||||||
|
day_before = _visible_selected_date(app)
|
||||||
|
|
||||||
|
await pilot.press("right_square_bracket")
|
||||||
|
day_after = _visible_selected_date(app)
|
||||||
|
# ] 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()
|
||||||
|
day_before = _visible_selected_date(app)
|
||||||
|
|
||||||
|
await pilot.press("left_square_bracket")
|
||||||
|
day_after = _visible_selected_date(app)
|
||||||
|
# [ 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")
|
||||||
|
|
||||||
|
# Visible readout identifies oldest selected date.
|
||||||
|
selected_day = _visible_selected_date(app)
|
||||||
|
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()
|
||||||
|
initial_date = _visible_selected_date(app)
|
||||||
|
|
||||||
|
await pilot.press("right_square_bracket")
|
||||||
|
assert _visible_selected_date(app) == initial_date
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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()
|
||||||
|
await pilot.press("v", "v")
|
||||||
|
app.query_one("#usage-history").focus()
|
||||||
|
await pilot.pause()
|
||||||
|
day_before = _visible_selected_date(app)
|
||||||
|
|
||||||
|
await pilot.press("left")
|
||||||
|
day_after = _visible_selected_date(app)
|
||||||
|
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)
|
||||||
|
local_date = datetime.now().astimezone().date().isoformat()
|
||||||
|
timezone_name = detect_system_tz()
|
||||||
|
utc_start, utc_end, tz_offset = local_day_boundaries(local_date, timezone_name)
|
||||||
|
_insert_local_day(
|
||||||
|
conn, local_date, tz_name=timezone_name, tz_offset=tz_offset,
|
||||||
|
utc_start=utc_start.isoformat(), utc_end=utc_end.isoformat(),
|
||||||
|
)
|
||||||
|
hour = utc_start.replace(minute=0, second=0, microsecond=0)
|
||||||
|
if hour < utc_start:
|
||||||
|
hour += timedelta(hours=1)
|
||||||
|
while hour + timedelta(hours=1) <= utc_end:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO hour_observations "
|
||||||
|
"(hour, bytes_written_delta, bytes_read_delta, coverage, sample_count) "
|
||||||
|
"VALUES (?, 1000000, 2000000, 1, 1)",
|
||||||
|
(hour.isoformat(),),
|
||||||
|
)
|
||||||
|
hour += timedelta(hours=1)
|
||||||
|
conn.commit()
|
||||||
|
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("v", "v")
|
||||||
|
await pilot.click("#usage-history")
|
||||||
|
|
||||||
|
# Enter drill-down
|
||||||
|
await pilot.press("enter")
|
||||||
|
assert app.query_one("#activity-tabs").active == "view-day"
|
||||||
|
|
||||||
|
# Arrow keys navigate hours
|
||||||
|
readout_before = str(app.query_one("#bar-readout").render())
|
||||||
|
await pilot.press("left")
|
||||||
|
assert str(app.query_one("#bar-readout").render()) != readout_before
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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()
|
||||||
|
|
||||||
|
# Browse to an older day using [
|
||||||
|
for _ in range(5):
|
||||||
|
await pilot.press("left_square_bracket")
|
||||||
|
browsed_day = _visible_selected_date(app)
|
||||||
|
today = datetime.now().astimezone().date().isoformat()
|
||||||
|
assert browsed_day != today
|
||||||
|
|
||||||
|
# Press t to return to today
|
||||||
|
await pilot.press("t")
|
||||||
|
assert app.query_one("#activity-tabs").active == "view-live"
|
||||||
|
|
||||||
|
@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()
|
||||||
|
await pilot.press("v", "v")
|
||||||
|
await pilot.click("#usage-history")
|
||||||
|
|
||||||
|
# Enter drill
|
||||||
|
await pilot.press("enter")
|
||||||
|
assert app.query_one("#activity-tabs").active == "view-day"
|
||||||
|
|
||||||
|
# t returns to daily
|
||||||
|
await pilot.press("t")
|
||||||
|
assert app.query_one("#activity-tabs").active == "view-live"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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 app.screen.query_one("#date-input") is not None
|
||||||
|
|
||||||
|
@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")
|
||||||
|
|
||||||
|
await pilot.press("g")
|
||||||
|
await pilot.press(*target_date)
|
||||||
|
await pilot.press("enter")
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
assert _visible_selected_date(app) == 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)
|
||||||
|
error = str(app.screen.query_one("#date-error").render())
|
||||||
|
assert "Invalid date format" in error
|
||||||
|
|
||||||
|
@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()
|
||||||
|
prior_readout = str(app.query_one("#bar-readout").render())
|
||||||
|
|
||||||
|
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 str(app.query_one("#bar-readout").render()) == prior_readout
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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()
|
||||||
|
# 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()
|
||||||
|
# Browse to a specific day
|
||||||
|
for _ in range(3):
|
||||||
|
await pilot.press("left_square_bracket")
|
||||||
|
browsed_day = _visible_selected_date(app)
|
||||||
|
|
||||||
|
# Wait for at least one refresh tick
|
||||||
|
import asyncio
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
# Selection should be preserved
|
||||||
|
assert _visible_selected_date(app) == 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()
|
||||||
|
# Browse to older day using [
|
||||||
|
for _ in range(5):
|
||||||
|
await pilot.press("left_square_bracket")
|
||||||
|
|
||||||
|
browsed_day = _visible_selected_date(app)
|
||||||
|
today = datetime.now().astimezone().date().isoformat()
|
||||||
|
assert browsed_day != today
|
||||||
|
|
||||||
|
# Wait for refresh
|
||||||
|
import asyncio
|
||||||
|
await asyncio.sleep(0.3)
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
# Should NOT jump back to today
|
||||||
|
assert _visible_selected_date(app) == browsed_day
|
||||||
|
assert _visible_selected_date(app) != 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()
|
||||||
|
await pilot.press("v", "v")
|
||||||
|
await pilot.click("#usage-history")
|
||||||
|
|
||||||
|
# Enter drill-down
|
||||||
|
await pilot.press("enter")
|
||||||
|
assert app.query_one("#activity-tabs").active == "view-day"
|
||||||
|
|
||||||
|
assert "hourly evidence unavailable" in str(
|
||||||
|
app.query_one("#bar-readout").render()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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()
|
||||||
|
day_before = _visible_selected_date(app)
|
||||||
|
|
||||||
|
await pilot.press("left_square_bracket")
|
||||||
|
day_after = _visible_selected_date(app)
|
||||||
|
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 app.screen.query_one("#date-input") is not None
|
||||||
|
|
||||||
|
|
||||||
|
class TestClickableNavigation:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("size", [(80, 24), (70, 20)])
|
||||||
|
async def test_click_and_keyboard_select_same_local_date(self, tmp_path, size):
|
||||||
|
"""Clickable and keyboard navigation share date-entry transitions."""
|
||||||
|
app_clock = _clock()
|
||||||
|
conn, _ = _setup_store(tmp_path, n_days=14, today=app_clock)
|
||||||
|
conn.close()
|
||||||
|
app, patcher = _make_app(tmp_path, clock=app_clock)
|
||||||
|
expected = (app_clock - timedelta(days=1)).strftime("%Y-%m-%d")
|
||||||
|
direct = (app_clock - timedelta(days=5)).strftime("%Y-%m-%d")
|
||||||
|
try:
|
||||||
|
async with app.run_test(size=size) as pilot:
|
||||||
|
await pilot.press("left_square_bracket")
|
||||||
|
keyboard_date = _visible_selected_date(app)
|
||||||
|
assert keyboard_date == expected
|
||||||
|
|
||||||
|
await pilot.press("t")
|
||||||
|
await pilot.click("#activity-tools", offset=(2, 0))
|
||||||
|
assert _visible_selected_date(app) == keyboard_date
|
||||||
|
|
||||||
|
await pilot.press("g")
|
||||||
|
await pilot.press(*direct)
|
||||||
|
await pilot.press("enter")
|
||||||
|
await pilot.pause()
|
||||||
|
assert _visible_selected_date(app) == direct
|
||||||
|
finally:
|
||||||
|
patcher.stop()
|
||||||
|
|
||||||
|
|
||||||
|
class TestHistoricalSelectionIdentity:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unavailable_entered_date_stays_visible_after_refresh(self, tmp_path):
|
||||||
|
"""An unavailable requested date remains selected and explained."""
|
||||||
|
conn, clock = _setup_store(tmp_path, n_days=30)
|
||||||
|
conn.close()
|
||||||
|
app, patcher = _make_app(tmp_path, clock=clock)
|
||||||
|
target = (clock - timedelta(days=120)).strftime("%Y-%m-%d")
|
||||||
|
try:
|
||||||
|
async with app.run_test(size=(100, 40)) as pilot:
|
||||||
|
await pilot.press("g")
|
||||||
|
await pilot.press(*target)
|
||||||
|
await pilot.press("enter")
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
local_day = str(app.query_one("#local-day").render())
|
||||||
|
assert target in local_day
|
||||||
|
assert "unavailable" in local_day
|
||||||
|
|
||||||
|
app.on_refresh_tick()
|
||||||
|
await pilot.pause()
|
||||||
|
local_day = str(app.query_one("#local-day").render())
|
||||||
|
assert target in local_day
|
||||||
|
assert "unavailable" in local_day
|
||||||
|
finally:
|
||||||
|
patcher.stop()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_retained_summary_outside_recent_window_remains_available(self, tmp_path):
|
||||||
|
"""Direct date entry can inspect durable local summaries beyond 90 days."""
|
||||||
|
clock = _clock()
|
||||||
|
conn, _ = _setup_store(tmp_path, n_days=30, today=clock)
|
||||||
|
target = (clock - timedelta(days=120)).strftime("%Y-%m-%d")
|
||||||
|
utc_start, utc_end, tz_offset = local_day_boundaries(
|
||||||
|
target, "Asia/Kolkata",
|
||||||
|
)
|
||||||
|
_insert_local_day(
|
||||||
|
conn,
|
||||||
|
target,
|
||||||
|
tz_name="Asia/Kolkata",
|
||||||
|
tz_offset=tz_offset,
|
||||||
|
bw=2_000_000_000,
|
||||||
|
br=1_000_000_000,
|
||||||
|
utc_start=utc_start.isoformat(),
|
||||||
|
utc_end=utc_end.isoformat(),
|
||||||
|
)
|
||||||
|
conn.close()
|
||||||
|
app, patcher = _make_app(tmp_path, clock=clock)
|
||||||
|
try:
|
||||||
|
async with app.run_test(size=(100, 40)) as pilot:
|
||||||
|
await pilot.press("g")
|
||||||
|
await pilot.press(*target)
|
||||||
|
await pilot.press("enter")
|
||||||
|
await pilot.pause()
|
||||||
|
await pilot.press("v")
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
readout = str(app.query_one("#bar-readout").render())
|
||||||
|
assert target in readout
|
||||||
|
assert "Asia/Kolkata +05:30" in readout
|
||||||
|
assert "W 2.000 GB known · R 1.000 GB known" in readout
|
||||||
|
finally:
|
||||||
|
patcher.stop()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_constrained_history_keeps_known_totals_and_evidence_state(self, tmp_path):
|
||||||
|
"""Small textual view retains selected day, totals, and shared evidence."""
|
||||||
|
clock = _clock()
|
||||||
|
conn, _ = _setup_store(tmp_path, n_days=14, today=clock)
|
||||||
|
target = (clock - timedelta(days=2)).strftime("%Y-%m-%d")
|
||||||
|
utc_start, utc_end, tz_offset = local_day_boundaries(
|
||||||
|
target, "Asia/Kolkata",
|
||||||
|
)
|
||||||
|
_insert_local_day(
|
||||||
|
conn,
|
||||||
|
target,
|
||||||
|
tz_name="Asia/Kolkata",
|
||||||
|
tz_offset=tz_offset,
|
||||||
|
bw=1_500_000_000,
|
||||||
|
br=500_000_000,
|
||||||
|
utc_start=utc_start.isoformat(),
|
||||||
|
utc_end=utc_end.isoformat(),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO local_day_unallocated_evidence "
|
||||||
|
"(start_sample_id, end_sample_id, start_local_date, end_local_date, "
|
||||||
|
" start_tz_name, end_tz_name, started_at, ended_at, bytes_written, "
|
||||||
|
" bytes_read, reason) VALUES (1, 2, ?, ?, ?, ?, ?, ?, ?, ?, 'local_midnight')",
|
||||||
|
(target, "2026-09-29", "Asia/Kolkata", "Asia/Kolkata",
|
||||||
|
utc_start.isoformat(), utc_start.isoformat(), 200_000_000, 300_000_000),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
app, patcher = _make_app(tmp_path, clock=clock)
|
||||||
|
try:
|
||||||
|
async with app.run_test(size=(70, 20)) as pilot:
|
||||||
|
await pilot.press("g")
|
||||||
|
await pilot.press(*target)
|
||||||
|
await pilot.press("enter")
|
||||||
|
await pilot.press("v")
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
readout = str(app.query_one("#bar-readout").render())
|
||||||
|
assert target in readout
|
||||||
|
assert "Asia/Kolkata +05:30" in readout
|
||||||
|
assert "incomplete" in readout
|
||||||
|
assert "W 1.500 GB known · R 0.500 GB known" in readout
|
||||||
|
assert "shared at midnight W 0.200 GB · R 0.300 GB" in readout
|
||||||
|
finally:
|
||||||
|
patcher.stop()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_date_picker_keystrokes_do_not_toggle_measurement(self, tmp_path):
|
||||||
|
"""Typing in date entry does not trigger dashboard shortcuts."""
|
||||||
|
conn, _ = _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.press("w")
|
||||||
|
await pilot.press("escape")
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
header = str(app.query_one("#live-header").render())
|
||||||
|
assert "Writes" in header
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_historical_hour_uses_recorded_timezone_and_utc_identity(self, tmp_path):
|
||||||
|
"""Repeated local clock labels retain distinct UTC-hour identities."""
|
||||||
|
clock = _clock(2026, 11, 2, 12)
|
||||||
|
conn, _ = _setup_store(tmp_path, n_days=30, today=clock)
|
||||||
|
target = "2026-11-01"
|
||||||
|
_insert_local_day(
|
||||||
|
conn,
|
||||||
|
target,
|
||||||
|
tz_name="America/New_York",
|
||||||
|
tz_offset="-04:00",
|
||||||
|
utc_start="2026-11-01T04:00:00+00:00",
|
||||||
|
utc_end="2026-11-02T05:00:00+00:00",
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO local_day_unallocated_evidence "
|
||||||
|
"(start_sample_id, end_sample_id, start_local_date, end_local_date, "
|
||||||
|
" start_tz_name, end_tz_name, started_at, ended_at, bytes_written, "
|
||||||
|
" bytes_read, reason) VALUES (1, 2, ?, ?, ?, ?, ?, ?, ?, ?, 'local_midnight')",
|
||||||
|
(target, "2026-11-02", "America/New_York", "America/New_York",
|
||||||
|
"2026-11-02T03:59:00+00:00", "2026-11-02T04:01:00+00:00",
|
||||||
|
2_000_000_000, 3_000_000_000),
|
||||||
|
)
|
||||||
|
for index in range(25):
|
||||||
|
hour = datetime(2026, 11, 1, 4, tzinfo=timezone.utc) + timedelta(hours=index)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO hour_observations "
|
||||||
|
"(hour, bytes_written_delta, bytes_read_delta, coverage, sample_count) "
|
||||||
|
"VALUES (?, ?, ?, 1, 1)",
|
||||||
|
(hour.isoformat(), (index + 1) * 1_000_000_000,
|
||||||
|
(index + 1) * 2_000_000_000),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE hour_observations SET coverage = 0.5, unknown_seconds = 1800 "
|
||||||
|
"WHERE hour = ?",
|
||||||
|
("2026-11-01T04:00:00+00:00",),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
app, patcher = _make_app(tmp_path, clock=clock)
|
||||||
|
try:
|
||||||
|
async with app.run_test(size=(100, 40)) as pilot:
|
||||||
|
await pilot.click("#view-history")
|
||||||
|
await pilot.press("g")
|
||||||
|
await pilot.press(*target)
|
||||||
|
await pilot.press("enter")
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
local_day = str(app.query_one("#local-day").render())
|
||||||
|
assert "America/New_York -04:00" in local_day
|
||||||
|
assert "incomplete" in local_day
|
||||||
|
assert "shared at midnight W 2.000 GB · R 3.000 GB" in local_day
|
||||||
|
|
||||||
|
# Move from final local hour to the second 01:00 occurrence.
|
||||||
|
await pilot.press(*(["left"] * 22))
|
||||||
|
second_occurrence = str(app.query_one("#bar-readout").render())
|
||||||
|
assert "01:00 -0500" in second_occurrence
|
||||||
|
assert "America/New_York -0500" in second_occurrence
|
||||||
|
assert "06:00 UTC" in second_occurrence
|
||||||
|
|
||||||
|
await pilot.press("left")
|
||||||
|
first_occurrence = str(app.query_one("#bar-readout").render())
|
||||||
|
assert "01:00 -0400" in first_occurrence
|
||||||
|
assert "America/New_York -0400" in first_occurrence
|
||||||
|
assert "05:00 UTC" in first_occurrence
|
||||||
|
|
||||||
|
await pilot.press("left")
|
||||||
|
incomplete_hour = str(app.query_one("#bar-readout").render())
|
||||||
|
assert "00:00 -0400" in incomplete_hour
|
||||||
|
assert "50% coverage · incomplete" in incomplete_hour
|
||||||
|
finally:
|
||||||
|
patcher.stop()
|
||||||
@@ -0,0 +1,603 @@
|
|||||||
|
"""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)
|
||||||
|
|
||||||
|
# The handcrafted totals lack replacement provenance, so retain samples.
|
||||||
|
assert pruned == 0
|
||||||
|
|
||||||
|
# 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="America/New_York", 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 == "America/New_York"
|
||||||
|
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: UTC-hour totals never become fabricated local-day totals."""
|
||||||
|
|
||||||
|
def test_hourly_bytes_are_unavailable_at_local_precision(self, tmp_path):
|
||||||
|
"""UTC-day hour bytes cannot be split across local-day boundaries."""
|
||||||
|
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
|
||||||
|
|
||||||
|
assert summary_16.bytes_written == 0
|
||||||
|
assert summary_17.bytes_written == 0
|
||||||
|
assert summary_16.activity_precision == "unavailable"
|
||||||
|
assert summary_17.activity_precision == "unavailable"
|
||||||
|
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 == 0
|
||||||
|
|
||||||
|
cursor = conn.execute("SELECT COUNT(*) FROM samples")
|
||||||
|
assert cursor.fetchone()[0] == 29
|
||||||
|
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,294 @@
|
|||||||
|
"""Pending publication behavior from issue #97."""
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||||
|
|
||||||
|
from fenris import collector
|
||||||
|
from fenris.projection import compute_projection
|
||||||
|
from fenris.status import _query_drive_facts, get_status, read_status
|
||||||
|
from fenris.status_composition import render_status_tui
|
||||||
|
from fenris.store import init_store
|
||||||
|
|
||||||
|
|
||||||
|
class FakeClock:
|
||||||
|
def __init__(self, now):
|
||||||
|
self.now = now
|
||||||
|
|
||||||
|
def utcnow(self):
|
||||||
|
return self.now
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sysfs_fixture_tree(tmp_path):
|
||||||
|
ctrl_dir = tmp_path / "sys" / "class" / "nvme" / "nvme0"
|
||||||
|
ctrl_dir.mkdir(parents=True)
|
||||||
|
(ctrl_dir / "subsysnqn").write_text("nqn.test:drive\n")
|
||||||
|
(ctrl_dir / "model").write_text("Test NVMe\n")
|
||||||
|
(ctrl_dir / "serial").write_text("test-serial\n")
|
||||||
|
(ctrl_dir / "firmware_rev").write_text("1.0\n")
|
||||||
|
transport_dir = ctrl_dir / "transport"
|
||||||
|
transport_dir.mkdir()
|
||||||
|
(transport_dir / "trstring").write_text("pcie\n")
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
def _smartctl(unit_written, unit_read=500):
|
||||||
|
return {
|
||||||
|
"nvme_smart_health_information_log": {
|
||||||
|
"critical_warning": 0,
|
||||||
|
"temperature": 35,
|
||||||
|
"available_spare": 100,
|
||||||
|
"percentage_used": 5,
|
||||||
|
"data_units_written": unit_written,
|
||||||
|
"data_units_read": unit_read,
|
||||||
|
"power_on_hours": 100,
|
||||||
|
},
|
||||||
|
"user_capacity": {"bytes": 1_024_000_000_000},
|
||||||
|
"model_name": "Test NVMe",
|
||||||
|
"serial_number": "test-serial",
|
||||||
|
"firmware_version": "1.0",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _acquirer(sysfs_path, unit_written, unit_read=500):
|
||||||
|
return lambda: (_smartctl(unit_written, unit_read), sysfs_path)
|
||||||
|
|
||||||
|
|
||||||
|
def _public_snapshot(store_path, clock_now):
|
||||||
|
with read_status(store_path, clock_now) as (conn, comp):
|
||||||
|
assert conn is not None
|
||||||
|
return {
|
||||||
|
"state": comp.state,
|
||||||
|
"freshness": comp.freshness,
|
||||||
|
"sample_count": comp.sample_count,
|
||||||
|
"day_count": comp.day_count,
|
||||||
|
"boot_enabled": comp.boot_enabled,
|
||||||
|
"timer_active": comp.timer_active,
|
||||||
|
"last_collect_ok": comp.last_collect_ok,
|
||||||
|
"latest_sample": tuple(conn.execute(
|
||||||
|
"SELECT ts, subnqn, mn, sn, fr, capacity_bytes, temperature_c, "
|
||||||
|
"available_spare, data_units_written, bytes_written, critical_warning, "
|
||||||
|
"media_errors, unsafe_shutdowns "
|
||||||
|
"FROM samples ORDER BY id DESC LIMIT 1"
|
||||||
|
).fetchone()),
|
||||||
|
"drive_facts": tuple(_query_drive_facts(conn)),
|
||||||
|
"projection": repr(compute_projection(conn, clock_now)),
|
||||||
|
"segments": conn.execute("SELECT COUNT(*) FROM controller_segments").fetchone()[0],
|
||||||
|
"periods": conn.execute("SELECT COUNT(*) FROM monitoring_periods").fetchone()[0],
|
||||||
|
"hours": tuple(conn.execute(
|
||||||
|
"SELECT hour, bytes_written_delta, bytes_read_delta, sample_count "
|
||||||
|
"FROM hour_observations ORDER BY hour"
|
||||||
|
).fetchall()),
|
||||||
|
"days": tuple(conn.execute(
|
||||||
|
"SELECT day, bytes_written_delta, bytes_read_delta "
|
||||||
|
"FROM day_aggregates ORDER BY day"
|
||||||
|
).fetchall()),
|
||||||
|
"local_days": tuple(conn.execute(
|
||||||
|
"SELECT local_date, bytes_written, bytes_read, sample_count "
|
||||||
|
"FROM local_days ORDER BY local_date, tz_name"
|
||||||
|
).fetchall()),
|
||||||
|
"pending": comp.pending_publication_count,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_publication_survives_restart_and_readers_keep_last_consistent_view(
|
||||||
|
tmp_path, sysfs_fixture_tree, monkeypatch
|
||||||
|
):
|
||||||
|
"""A failed derivation stays private, visible as pending, and replays in order."""
|
||||||
|
monkeypatch.setenv("TZ", "UTC")
|
||||||
|
service = {"boot_enabled": True, "timer_active": True, "last_collect_ok": True}
|
||||||
|
monkeypatch.setattr("fenris.status.query_service_state", lambda: service)
|
||||||
|
now = datetime.now(timezone.utc).replace(
|
||||||
|
minute=35, second=0, microsecond=0,
|
||||||
|
)
|
||||||
|
store_path = tmp_path / "observations.db"
|
||||||
|
config = {"device": "/dev/nvme0", "store_path": str(store_path)}
|
||||||
|
sysfs_path = sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0"
|
||||||
|
|
||||||
|
for units, at in ((1000, now - timedelta(minutes=10)), (1010, now - timedelta(minutes=5))):
|
||||||
|
result = collector.run_collection(
|
||||||
|
config=config,
|
||||||
|
clock=FakeClock(at),
|
||||||
|
acquire=_acquirer(sysfs_path, units),
|
||||||
|
)
|
||||||
|
assert result["ok"] is True, result
|
||||||
|
|
||||||
|
previous = _public_snapshot(store_path, now)
|
||||||
|
baseline_cli = get_status(store_path, now, query_services=False, query_journal=False)
|
||||||
|
original_derive = collector.derive_hours_from_interval
|
||||||
|
reader_during_write = {}
|
||||||
|
|
||||||
|
def fail_derivation(conn, previous_sample, next_sample):
|
||||||
|
# Read while writer has inserted the unpublished sample but not committed.
|
||||||
|
with read_status(store_path, now, query_services=False) as (reader, comp):
|
||||||
|
assert reader is not None
|
||||||
|
reader_during_write["samples"] = reader.execute(
|
||||||
|
"SELECT COUNT(*) FROM samples"
|
||||||
|
).fetchone()[0]
|
||||||
|
reader_during_write["pending"] = comp.pending_publication_count
|
||||||
|
reader_during_write["latest"] = tuple(reader.execute(
|
||||||
|
"SELECT data_units_written FROM samples ORDER BY id DESC LIMIT 1"
|
||||||
|
).fetchone())
|
||||||
|
raise RuntimeError("injected non-invariant derivation failure")
|
||||||
|
|
||||||
|
monkeypatch.setattr(collector, "derive_hours_from_interval", fail_derivation)
|
||||||
|
failed = collector.run_collection(
|
||||||
|
config=config,
|
||||||
|
clock=FakeClock(now),
|
||||||
|
acquire=_acquirer(sysfs_path, 1025),
|
||||||
|
)
|
||||||
|
assert failed["ok"] is False
|
||||||
|
assert "injected non-invariant derivation failure" in failed["error"]
|
||||||
|
assert reader_during_write == {"samples": 2, "pending": 1, "latest": (1010,)}
|
||||||
|
|
||||||
|
after_failure = _public_snapshot(store_path, now)
|
||||||
|
assert {**after_failure, "pending": previous["pending"]} == previous
|
||||||
|
assert after_failure["pending"] == 1
|
||||||
|
|
||||||
|
cli_text = get_status(store_path, now, query_services=False, query_journal=False)
|
||||||
|
pending_fact = " · pending publication: 1 observation retained for retry"
|
||||||
|
assert pending_fact in cli_text
|
||||||
|
assert cli_text.replace(pending_fact, "", 1) == baseline_cli
|
||||||
|
|
||||||
|
with read_status(store_path, now) as (_, comp):
|
||||||
|
assert comp.state == previous["state"]
|
||||||
|
assert comp.boot_enabled is True
|
||||||
|
assert comp.timer_active is True
|
||||||
|
assert "pending publication: 1 observation retained for retry" in render_status_tui(comp).lower()
|
||||||
|
|
||||||
|
called = False
|
||||||
|
|
||||||
|
def should_wait_for_recovery():
|
||||||
|
nonlocal called
|
||||||
|
called = True
|
||||||
|
return _smartctl(1030), sysfs_path
|
||||||
|
|
||||||
|
monkeypatch.setattr(collector, "PENDING_PUBLICATION_LIMIT", 1)
|
||||||
|
blocked = collector.run_collection(
|
||||||
|
config=config,
|
||||||
|
clock=FakeClock(now + timedelta(minutes=5)),
|
||||||
|
acquire=should_wait_for_recovery,
|
||||||
|
)
|
||||||
|
assert blocked["ok"] is False
|
||||||
|
assert called is False
|
||||||
|
assert _public_snapshot(store_path, now)["pending"] == 1
|
||||||
|
|
||||||
|
monkeypatch.setattr(collector, "derive_hours_from_interval", original_derive)
|
||||||
|
monkeypatch.setenv("TZ", "Asia/Kolkata")
|
||||||
|
recovered = collector.run_collection(
|
||||||
|
config=config,
|
||||||
|
clock=FakeClock(now + timedelta(minutes=5)),
|
||||||
|
acquire=_acquirer(sysfs_path, 1040),
|
||||||
|
)
|
||||||
|
assert recovered["ok"] is True, recovered
|
||||||
|
|
||||||
|
conn = sqlite3.connect(store_path)
|
||||||
|
try:
|
||||||
|
assert conn.execute(
|
||||||
|
"SELECT data_units_written FROM samples ORDER BY id"
|
||||||
|
).fetchall() == [(1000,), (1010,), (1025,), (1040,)]
|
||||||
|
assert conn.execute("SELECT COUNT(*) FROM pending_publications").fetchone()[0] == 0
|
||||||
|
assert conn.execute(
|
||||||
|
"SELECT SUM(bytes_written_delta) FROM day_aggregates"
|
||||||
|
).fetchone()[0] == (1040 - 1000) * 512_000
|
||||||
|
assert conn.execute(
|
||||||
|
"SELECT sample_count FROM local_days WHERE tz_name = 'UTC'"
|
||||||
|
).fetchone()[0] == 4
|
||||||
|
assert conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM local_days WHERE tz_name = 'Asia/Kolkata'"
|
||||||
|
).fetchone()[0] == 1
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_sample_writes_no_sample_segment_period_or_pending_row(tmp_path, sysfs_fixture_tree):
|
||||||
|
"""Sample invariants run before staging or public monitoring changes."""
|
||||||
|
now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
|
||||||
|
store_path = tmp_path / "observations.db"
|
||||||
|
config = {"device": "/dev/nvme0", "store_path": str(store_path)}
|
||||||
|
sysfs_path = sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0"
|
||||||
|
result = collector.run_collection(
|
||||||
|
config=config,
|
||||||
|
clock=FakeClock(now),
|
||||||
|
acquire=_acquirer(sysfs_path, -1),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["ok"] is False
|
||||||
|
assert result["error_type"] == "InvariantViolationError"
|
||||||
|
conn = sqlite3.connect(store_path)
|
||||||
|
try:
|
||||||
|
assert conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0] == 0
|
||||||
|
assert conn.execute("SELECT COUNT(*) FROM controller_segments").fetchone()[0] == 0
|
||||||
|
assert conn.execute("SELECT COUNT(*) FROM monitoring_periods").fetchone()[0] == 0
|
||||||
|
assert conn.execute("SELECT COUNT(*) FROM pending_publications").fetchone()[0] == 0
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_pending_capacity_retries_before_skipping_acquisition(tmp_path, sysfs_fixture_tree, monkeypatch):
|
||||||
|
now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
|
||||||
|
store_path = tmp_path / "observations.db"
|
||||||
|
config = {"device": "/dev/nvme0", "store_path": str(store_path)}
|
||||||
|
sysfs_path = sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0"
|
||||||
|
first = collector.run_collection(
|
||||||
|
config=config,
|
||||||
|
clock=FakeClock(now - timedelta(minutes=5)),
|
||||||
|
acquire=_acquirer(sysfs_path, 1000),
|
||||||
|
)
|
||||||
|
assert first["ok"] is True, first
|
||||||
|
|
||||||
|
monkeypatch.setattr(collector, "PENDING_PUBLICATION_LIMIT", 1)
|
||||||
|
|
||||||
|
def fail_derivation(*_):
|
||||||
|
raise RuntimeError("derivation still unavailable")
|
||||||
|
|
||||||
|
monkeypatch.setattr(collector, "derive_hours_from_interval", fail_derivation)
|
||||||
|
failed = collector.run_collection(
|
||||||
|
config=config,
|
||||||
|
clock=FakeClock(now),
|
||||||
|
acquire=_acquirer(sysfs_path, 1010),
|
||||||
|
)
|
||||||
|
assert failed["ok"] is False
|
||||||
|
assert "capacity full (1 observations)" in failed["error"]
|
||||||
|
|
||||||
|
called = False
|
||||||
|
|
||||||
|
def must_not_acquire():
|
||||||
|
nonlocal called
|
||||||
|
called = True
|
||||||
|
return _smartctl(1020), sysfs_path
|
||||||
|
|
||||||
|
retry = collector.run_collection(
|
||||||
|
config=config,
|
||||||
|
clock=FakeClock(now + timedelta(minutes=5)),
|
||||||
|
acquire=must_not_acquire,
|
||||||
|
)
|
||||||
|
assert retry["ok"] is False
|
||||||
|
assert "capacity full (1 observations)" in retry["error"]
|
||||||
|
assert called is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_v3_readers_ignore_pending_table_until_store_migrates(tmp_path):
|
||||||
|
store_path = tmp_path / "observations.db"
|
||||||
|
conn = init_store(store_path)
|
||||||
|
conn.execute("DROP TABLE pending_publications")
|
||||||
|
conn.execute("PRAGMA user_version=3")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
with read_status(store_path, datetime.now(timezone.utc), query_services=False) as (reader, comp):
|
||||||
|
assert reader is not None
|
||||||
|
assert comp.store_fault is None
|
||||||
|
assert comp.pending_publication_count == 0
|
||||||
|
|
||||||
|
migrated = init_store(store_path)
|
||||||
|
try:
|
||||||
|
assert migrated.execute("PRAGMA user_version").fetchone()[0] == 6
|
||||||
|
assert migrated.execute("SELECT COUNT(*) FROM pending_publications").fetchone()[0] == 0
|
||||||
|
finally:
|
||||||
|
migrated.close()
|
||||||
@@ -0,0 +1,520 @@
|
|||||||
|
"""Historical local-day migration and repair acceptance tests for issue #99."""
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||||
|
|
||||||
|
from fenris.local_day import (
|
||||||
|
query_local_day_summary,
|
||||||
|
record_local_activity_interval,
|
||||||
|
)
|
||||||
|
from fenris.status import read_status
|
||||||
|
from fenris.store import init_store
|
||||||
|
|
||||||
|
SOURCE_SCHEMA_VERSION = 5
|
||||||
|
LOCAL_DATE = "2026-09-02"
|
||||||
|
TIMEZONE = "Asia/Kolkata"
|
||||||
|
UTC_START = "2026-09-01T18:30:00+00:00"
|
||||||
|
UTC_END = "2026-09-02T18:30:00+00:00"
|
||||||
|
|
||||||
|
|
||||||
|
def _legacy_store(path: Path) -> sqlite3.Connection:
|
||||||
|
"""Build a v5 store with one old coarse local-day summary."""
|
||||||
|
conn = init_store(path)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO local_days "
|
||||||
|
"(local_date, tz_name, tz_offset, utc_start, utc_end, bytes_written, "
|
||||||
|
" bytes_read, coverage, sample_count, complete, activity_incomplete, "
|
||||||
|
" activity_precision) "
|
||||||
|
"VALUES (?, ?, '+05:30', ?, ?, 999999, 888888, 1.0, 24, 1, 0, 'legacy')",
|
||||||
|
(LOCAL_DATE, TIMEZONE, UTC_START, UTC_END),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO monitoring_periods (started_at, ended_at) VALUES (?, ?)",
|
||||||
|
(UTC_START, "2026-09-03T00:00:00+00:00"),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO controller_segments "
|
||||||
|
"(opened_at, identity_key, identity_degraded) VALUES (?, ?, 0)",
|
||||||
|
(UTC_START, "controller-a"),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO hour_observations "
|
||||||
|
"(hour, bytes_written_delta, bytes_read_delta, sample_count, coverage) "
|
||||||
|
"VALUES ('2026-09-02T00:00:00+00:00', 123, 45, 2, 1.0)"
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO day_aggregates "
|
||||||
|
"(day, bytes_written_delta, bytes_read_delta, coverage) "
|
||||||
|
"VALUES ('2026-09-02', 678, 90, 1.0)"
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO samples (ts, device, bytes_written, bytes_read, power_on_hours, "
|
||||||
|
" segment_id, local_tz) VALUES (?, '/dev/nvme0', 1000, 2000, 100, 1, ?)",
|
||||||
|
("2026-09-01T19:00:00+00:00", TIMEZONE),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO samples (ts, device, bytes_written, bytes_read, power_on_hours, "
|
||||||
|
" segment_id, local_tz) VALUES (?, '/dev/nvme0', 11000, 5000, 115, 1, ?)",
|
||||||
|
("2026-09-02T10:00:00+00:00", TIMEZONE),
|
||||||
|
)
|
||||||
|
conn.execute(f"PRAGMA user_version={SOURCE_SCHEMA_VERSION}")
|
||||||
|
conn.commit()
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def test_upgrade_rebuilds_legacy_day_from_samples_and_keeps_recorded_bounds(
|
||||||
|
tmp_path, monkeypatch,
|
||||||
|
):
|
||||||
|
db = tmp_path / "legacy.db"
|
||||||
|
conn = _legacy_store(db)
|
||||||
|
conn.close()
|
||||||
|
monkeypatch.setenv("TZ", "UTC")
|
||||||
|
|
||||||
|
conn = init_store(db)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
with read_status(
|
||||||
|
db, datetime(2026, 9, 4, tzinfo=timezone.utc), query_services=False,
|
||||||
|
) as (reader, composition):
|
||||||
|
assert reader is not None
|
||||||
|
assert composition.store_fault is None
|
||||||
|
summary = query_local_day_summary(
|
||||||
|
reader, LOCAL_DATE, TIMEZONE,
|
||||||
|
datetime(2026, 9, 4, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
assert summary is not None
|
||||||
|
assert summary["local_date"] == LOCAL_DATE
|
||||||
|
assert summary["tz_name"] == TIMEZONE
|
||||||
|
assert summary["tz_offset"] == "+05:30"
|
||||||
|
assert summary["utc_start"] == UTC_START
|
||||||
|
assert summary["utc_end"] == UTC_END
|
||||||
|
assert summary["bytes_written"] == 10_000
|
||||||
|
assert summary["bytes_read"] == 3_000
|
||||||
|
assert summary["activity_precision"] == "measured"
|
||||||
|
assert summary["segments"] == [{
|
||||||
|
"segment_id": 1,
|
||||||
|
"bytes_written": 10_000,
|
||||||
|
"bytes_read": 3_000,
|
||||||
|
"activity_seconds": 54_000,
|
||||||
|
"activity_intervals": 1,
|
||||||
|
}]
|
||||||
|
assert summary["activity_state"] == "incomplete"
|
||||||
|
assert reader.execute(
|
||||||
|
"SELECT bytes_written_delta FROM hour_observations"
|
||||||
|
).fetchone()[0] == 123
|
||||||
|
assert reader.execute(
|
||||||
|
"SELECT bytes_written_delta FROM day_aggregates"
|
||||||
|
).fetchone()[0] == 678
|
||||||
|
|
||||||
|
conn = init_store(db)
|
||||||
|
conn.close()
|
||||||
|
with read_status(
|
||||||
|
db, datetime(2026, 9, 4, tzinfo=timezone.utc), query_services=False,
|
||||||
|
) as (reader, _composition):
|
||||||
|
assert reader is not None
|
||||||
|
summary = query_local_day_summary(
|
||||||
|
reader, LOCAL_DATE, TIMEZONE,
|
||||||
|
datetime(2026, 9, 4, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
assert summary["bytes_written"] == 10_000
|
||||||
|
assert summary["bytes_read"] == 3_000
|
||||||
|
|
||||||
|
|
||||||
|
def test_repair_rebuilds_legacy_local_day_once_across_restarts(tmp_path):
|
||||||
|
from fenris.repair import repair_derivation
|
||||||
|
|
||||||
|
db = tmp_path / "repair.db"
|
||||||
|
conn = _legacy_store(db)
|
||||||
|
|
||||||
|
first = repair_derivation(conn)
|
||||||
|
assert first.ok
|
||||||
|
summary = query_local_day_summary(
|
||||||
|
conn, LOCAL_DATE, TIMEZONE,
|
||||||
|
datetime(2026, 9, 4, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
assert summary["bytes_written"] == 10_000
|
||||||
|
assert summary["bytes_read"] == 3_000
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
conn = sqlite3.connect(db)
|
||||||
|
second = repair_derivation(conn)
|
||||||
|
assert second.ok
|
||||||
|
summary = query_local_day_summary(
|
||||||
|
conn, LOCAL_DATE, TIMEZONE,
|
||||||
|
datetime(2026, 9, 4, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
assert summary["bytes_written"] == 10_000
|
||||||
|
assert summary["bytes_read"] == 3_000
|
||||||
|
assert conn.execute(
|
||||||
|
"SELECT bytes_written, bytes_read, activity_intervals "
|
||||||
|
"FROM local_day_segment_totals"
|
||||||
|
).fetchall() == [(10_000, 3_000, 1)]
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_interrupted_repair_keeps_local_day_rebuild_retryable(tmp_path):
|
||||||
|
from fenris.repair import repair_derivation
|
||||||
|
|
||||||
|
db = tmp_path / "repair-interrupted.db"
|
||||||
|
conn = _legacy_store(db)
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TRIGGER interrupt_repair_local_rebuild "
|
||||||
|
"BEFORE INSERT ON local_day_segment_totals "
|
||||||
|
"BEGIN SELECT RAISE(ABORT, 'simulated repair interruption'); END"
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
failed = repair_derivation(conn)
|
||||||
|
assert not failed.ok
|
||||||
|
assert query_local_day_summary(
|
||||||
|
conn, LOCAL_DATE, TIMEZONE,
|
||||||
|
datetime(2026, 9, 4, tzinfo=timezone.utc),
|
||||||
|
)["bytes_written"] is None
|
||||||
|
assert conn.execute(
|
||||||
|
"SELECT activity_precision, bytes_written FROM local_days"
|
||||||
|
).fetchone() == ("legacy", 999_999)
|
||||||
|
conn.execute("DROP TRIGGER interrupt_repair_local_rebuild")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
conn = sqlite3.connect(db)
|
||||||
|
retried = repair_derivation(conn)
|
||||||
|
assert retried.ok
|
||||||
|
summary = query_local_day_summary(
|
||||||
|
conn, LOCAL_DATE, TIMEZONE,
|
||||||
|
datetime(2026, 9, 4, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
assert summary["bytes_written"] == 10_000
|
||||||
|
assert summary["bytes_read"] == 3_000
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_stale_repair_marker_does_not_block_crash_retry(tmp_path):
|
||||||
|
from fenris.repair import repair_derivation
|
||||||
|
|
||||||
|
conn = _legacy_store(tmp_path / "stale-repair.db")
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO store_metadata (key, value) "
|
||||||
|
"VALUES ('repair_in_progress', 'true')"
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
result = repair_derivation(conn)
|
||||||
|
|
||||||
|
assert result.ok
|
||||||
|
assert conn.execute(
|
||||||
|
"SELECT value FROM store_metadata WHERE key = 'repair_in_progress'"
|
||||||
|
).fetchone() == ("false",)
|
||||||
|
assert query_local_day_summary(
|
||||||
|
conn, LOCAL_DATE, TIMEZONE,
|
||||||
|
datetime(2026, 9, 4, tzinfo=timezone.utc),
|
||||||
|
)["bytes_written"] == 10_000
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_upgrade_uses_whole_utc_hours_but_skips_midnight_straddling_hours(
|
||||||
|
tmp_path,
|
||||||
|
):
|
||||||
|
db = tmp_path / "coarse.db"
|
||||||
|
conn = _legacy_store(db)
|
||||||
|
conn.execute("DELETE FROM samples")
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO hour_observations "
|
||||||
|
"(hour, bytes_written_delta, bytes_read_delta, sample_count, "
|
||||||
|
" active_seconds, coverage) "
|
||||||
|
"VALUES ('2026-09-01T18:00:00+00:00', 99999, 9999, 2, 3600, 1.0)"
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO hour_observations "
|
||||||
|
"(hour, bytes_written_delta, bytes_read_delta, sample_count, "
|
||||||
|
" active_seconds, coverage) "
|
||||||
|
"VALUES ('2026-09-02T18:00:00+00:00', 88888, 8888, 2, 3600, 1.0)"
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
conn = init_store(db)
|
||||||
|
summary = query_local_day_summary(
|
||||||
|
conn, LOCAL_DATE, TIMEZONE,
|
||||||
|
datetime(2026, 9, 4, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
assert summary["bytes_written"] == 123
|
||||||
|
assert summary["bytes_read"] == 45
|
||||||
|
assert summary["activity_precision"] == "coarse"
|
||||||
|
assert summary["activity_state"] == "incomplete"
|
||||||
|
assert summary["utc_start"] == UTC_START
|
||||||
|
assert summary["utc_end"] == UTC_END
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO samples "
|
||||||
|
"(id, ts, device, bytes_written, bytes_read, segment_id, local_tz) "
|
||||||
|
"VALUES (10, '2026-09-02T12:00:00+00:00', '/dev/nvme0', 1000, 2000, 1, ?), "
|
||||||
|
" (11, '2026-09-02T12:05:00+00:00', '/dev/nvme0', 2000, 2500, 1, ?)",
|
||||||
|
(TIMEZONE, TIMEZONE),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
assert record_local_activity_interval(
|
||||||
|
conn,
|
||||||
|
{
|
||||||
|
"id": 10, "ts": "2026-09-02T12:00:00+00:00",
|
||||||
|
"bytes_written": 1_000, "bytes_read": 2_000,
|
||||||
|
"local_tz": TIMEZONE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 11, "ts": "2026-09-02T12:05:00+00:00",
|
||||||
|
"bytes_written": 2_000, "bytes_read": 2_500,
|
||||||
|
"local_tz": TIMEZONE,
|
||||||
|
},
|
||||||
|
start_sample_id=10, end_sample_id=11, segment_id=1,
|
||||||
|
) == "known"
|
||||||
|
summary = query_local_day_summary(
|
||||||
|
conn, LOCAL_DATE, TIMEZONE,
|
||||||
|
datetime(2026, 9, 4, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
assert summary["bytes_written"] == 1_000
|
||||||
|
assert summary["bytes_read"] == 500
|
||||||
|
assert summary["activity_precision"] == "measured"
|
||||||
|
assert summary["segments"] == [{
|
||||||
|
"segment_id": 1,
|
||||||
|
"bytes_written": 1_000,
|
||||||
|
"bytes_read": 500,
|
||||||
|
"activity_seconds": 300,
|
||||||
|
"activity_intervals": 1,
|
||||||
|
}]
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_upgrade_does_not_double_count_coarse_hours_in_overlapping_days(tmp_path):
|
||||||
|
db = tmp_path / "overlapping-coarse-days.db"
|
||||||
|
conn = _legacy_store(db)
|
||||||
|
conn.execute("DELETE FROM samples")
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO local_days "
|
||||||
|
"(local_date, tz_name, tz_offset, utc_start, utc_end, bytes_written, "
|
||||||
|
" bytes_read, coverage, sample_count, complete, activity_precision) "
|
||||||
|
"VALUES ('2026-09-02', 'UTC', '+00:00', "
|
||||||
|
"'2026-09-02T00:00:00+00:00', '2026-09-03T00:00:00+00:00', "
|
||||||
|
"999999, 888888, 1.0, 24, 1, 'legacy')"
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
conn = init_store(db)
|
||||||
|
|
||||||
|
for tz_name in (TIMEZONE, "UTC"):
|
||||||
|
summary = query_local_day_summary(
|
||||||
|
conn, LOCAL_DATE, tz_name,
|
||||||
|
datetime(2026, 9, 4, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
assert summary["bytes_written"] is None
|
||||||
|
assert summary["bytes_read"] is None
|
||||||
|
assert summary["activity_precision"] == "unavailable"
|
||||||
|
assert summary["activity_state"] == "unavailable"
|
||||||
|
assert conn.execute(
|
||||||
|
"SELECT bytes_written_delta, bytes_read_delta FROM hour_observations "
|
||||||
|
"WHERE hour = '2026-09-02T00:00:00+00:00'"
|
||||||
|
).fetchone() == (123, 45)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_upgrade_retains_local_midnight_interval_once_as_shared_evidence(tmp_path):
|
||||||
|
db = tmp_path / "shared.db"
|
||||||
|
conn = _legacy_store(db)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO local_days "
|
||||||
|
"(local_date, tz_name, tz_offset, utc_start, utc_end, bytes_written, "
|
||||||
|
" bytes_read, coverage, sample_count, complete, activity_precision) "
|
||||||
|
"VALUES ('2026-09-01', ?, '+05:30', "
|
||||||
|
"'2026-08-31T18:30:00+00:00', '2026-09-01T18:30:00+00:00', "
|
||||||
|
"500, 600, 1.0, 24, 1, 'legacy')",
|
||||||
|
(TIMEZONE,),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE monitoring_periods SET started_at = '2026-08-31T18:00:00+00:00'"
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE controller_segments SET opened_at = '2026-08-31T18:00:00+00:00'"
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE samples SET ts = '2026-09-01T18:25:00+00:00', "
|
||||||
|
"bytes_written = 1000, bytes_read = 2000 WHERE id = 1"
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE samples SET ts = '2026-09-01T18:35:00+00:00', "
|
||||||
|
"bytes_written = 1100, bytes_read = 2050 WHERE id = 2"
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
conn = init_store(db)
|
||||||
|
shared = conn.execute(
|
||||||
|
"SELECT COUNT(*), SUM(bytes_written), SUM(bytes_read) "
|
||||||
|
"FROM local_day_unallocated_evidence"
|
||||||
|
).fetchone()
|
||||||
|
assert shared == (1, 100, 50)
|
||||||
|
for local_date in ("2026-09-01", "2026-09-02"):
|
||||||
|
summary = query_local_day_summary(
|
||||||
|
conn, local_date, TIMEZONE,
|
||||||
|
datetime(2026, 9, 4, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
assert summary["bytes_written"] is None
|
||||||
|
assert summary["shared_bytes_written"] == 100
|
||||||
|
assert summary["shared_bytes_read"] == 50
|
||||||
|
assert summary["activity_state"] == "incomplete"
|
||||||
|
from fenris.repair import repair_derivation
|
||||||
|
assert repair_derivation(conn).ok
|
||||||
|
assert conn.execute(
|
||||||
|
"SELECT COUNT(*), SUM(bytes_written), SUM(bytes_read) "
|
||||||
|
"FROM local_day_unallocated_evidence"
|
||||||
|
).fetchone() == (1, 100, 50)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_interrupted_upgrade_keeps_legacy_day_retryable(tmp_path):
|
||||||
|
db = tmp_path / "interrupted.db"
|
||||||
|
conn = _legacy_store(db)
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TRIGGER interrupt_local_rebuild "
|
||||||
|
"BEFORE INSERT ON local_day_segment_totals "
|
||||||
|
"BEGIN SELECT RAISE(ABORT, 'simulated interruption'); END"
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
with pytest.raises(sqlite3.IntegrityError, match="simulated interruption"):
|
||||||
|
init_store(db)
|
||||||
|
|
||||||
|
conn = sqlite3.connect(db)
|
||||||
|
assert conn.execute("PRAGMA user_version").fetchone() == (SOURCE_SCHEMA_VERSION,)
|
||||||
|
assert conn.execute(
|
||||||
|
"SELECT activity_precision, bytes_written FROM local_days"
|
||||||
|
).fetchone() == ("legacy", 999_999)
|
||||||
|
conn.execute("DROP TRIGGER interrupt_local_rebuild")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
conn = init_store(db)
|
||||||
|
summary = query_local_day_summary(
|
||||||
|
conn, LOCAL_DATE, TIMEZONE,
|
||||||
|
datetime(2026, 9, 4, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
assert summary["bytes_written"] == 10_000
|
||||||
|
assert summary["bytes_read"] == 3_000
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_prior_migration_step_commits_before_local_day_repair(tmp_path):
|
||||||
|
db = tmp_path / "migration-steps.db"
|
||||||
|
conn = _legacy_store(db)
|
||||||
|
conn.execute("PRAGMA user_version=4")
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TRIGGER interrupt_local_rebuild BEFORE UPDATE OF bytes_written "
|
||||||
|
"ON local_days BEGIN SELECT RAISE(ABORT, 'simulated interruption'); END"
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
with pytest.raises(sqlite3.IntegrityError, match="simulated interruption"):
|
||||||
|
init_store(db)
|
||||||
|
|
||||||
|
conn = sqlite3.connect(db)
|
||||||
|
assert conn.execute("PRAGMA user_version").fetchone() == (5,)
|
||||||
|
assert conn.execute(
|
||||||
|
"SELECT 1 FROM sqlite_master WHERE type='table' "
|
||||||
|
"AND name='local_day_segment_totals'"
|
||||||
|
).fetchone() is not None
|
||||||
|
assert conn.execute(
|
||||||
|
"SELECT bytes_written FROM local_days WHERE local_date=?",
|
||||||
|
(LOCAL_DATE,),
|
||||||
|
).fetchone() == (999_999,)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_upgrade_does_not_allocate_unzoned_interval_to_overlapping_timezones(
|
||||||
|
tmp_path,
|
||||||
|
):
|
||||||
|
db = tmp_path / "overlapping-timezones.db"
|
||||||
|
conn = _legacy_store(db)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO local_days "
|
||||||
|
"(local_date, tz_name, tz_offset, utc_start, utc_end, bytes_written, "
|
||||||
|
" bytes_read, coverage, sample_count, complete, activity_precision) "
|
||||||
|
"VALUES ('2026-09-02', 'UTC', '+00:00', "
|
||||||
|
"'2026-09-02T00:00:00+00:00', '2026-09-03T00:00:00+00:00', "
|
||||||
|
"999999, 888888, 1.0, 24, 1, 'legacy')"
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE samples SET ts = '2026-09-02T01:00:00+00:00', local_tz = NULL "
|
||||||
|
"WHERE id = 1"
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE samples SET ts = '2026-09-02T10:00:00+00:00', local_tz = NULL "
|
||||||
|
"WHERE id = 2"
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
conn = init_store(db)
|
||||||
|
for tz_name in (TIMEZONE, "UTC"):
|
||||||
|
summary = query_local_day_summary(
|
||||||
|
conn, LOCAL_DATE, tz_name,
|
||||||
|
datetime(2026, 9, 4, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
assert summary["bytes_written"] is None
|
||||||
|
assert summary["bytes_read"] is None
|
||||||
|
assert summary["activity_precision"] == "unavailable"
|
||||||
|
assert summary["activity_state"] == "unavailable"
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_upgrade_rejects_interval_before_recorded_controller_segment(
|
||||||
|
tmp_path,
|
||||||
|
):
|
||||||
|
db = tmp_path / "segment-boundary.db"
|
||||||
|
conn = _legacy_store(db)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE controller_segments "
|
||||||
|
"SET opened_at = '2026-09-01T20:00:00+00:00' WHERE id = 1"
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
conn = init_store(db)
|
||||||
|
summary = query_local_day_summary(
|
||||||
|
conn, LOCAL_DATE, TIMEZONE,
|
||||||
|
datetime(2026, 9, 4, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
assert summary["bytes_written"] is None
|
||||||
|
assert summary["bytes_read"] is None
|
||||||
|
assert summary["activity_precision"] == "unavailable"
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_sample_at_local_midnight_does_not_hide_coarse_reconstruction(tmp_path):
|
||||||
|
db = tmp_path / "midnight-sample.db"
|
||||||
|
conn = _legacy_store(db)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE samples SET ts = '2026-09-01T17:00:00+00:00' WHERE id = 1"
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE samples SET ts = ?, local_tz = ? WHERE id = 2",
|
||||||
|
(UTC_END, TIMEZONE),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
conn = init_store(db)
|
||||||
|
summary = query_local_day_summary(
|
||||||
|
conn, LOCAL_DATE, TIMEZONE,
|
||||||
|
datetime(2026, 9, 4, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
assert summary["bytes_written"] == 123
|
||||||
|
assert summary["bytes_read"] == 45
|
||||||
|
assert summary["activity_precision"] == "coarse"
|
||||||
|
assert summary["activity_state"] == "incomplete"
|
||||||
|
conn.close()
|
||||||
@@ -0,0 +1,425 @@
|
|||||||
|
"""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
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||||
|
|
||||||
|
from fenris.activity_selection import IntervalIdentity
|
||||||
|
from fenris.monitoring_periods import ensure_period_open
|
||||||
|
from fenris.status import ACCURACY_SEC, CADENCE_DEFAULT_S, FRESH_THRESHOLD_S
|
||||||
|
from fenris.store import init_store
|
||||||
|
from fenris.tui import (
|
||||||
|
LIVE_WINDOW_H,
|
||||||
|
FenrisTuiApp,
|
||||||
|
LiveActivityGraph,
|
||||||
|
_query_live_graph_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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))
|
||||||
|
|
||||||
|
|
||||||
|
def _live_intervals(count=4):
|
||||||
|
first = datetime(2026, 9, 28, 12, 0, tzinfo=timezone.utc)
|
||||||
|
intervals = []
|
||||||
|
for index in range(count):
|
||||||
|
start = first + timedelta(minutes=3 * index)
|
||||||
|
end = start + timedelta(minutes=3)
|
||||||
|
intervals.append({
|
||||||
|
"start_ts": start.isoformat(),
|
||||||
|
"end_ts": end.isoformat(),
|
||||||
|
"start_label": start.strftime("%H:%M"),
|
||||||
|
"end_label": end.strftime("%H:%M"),
|
||||||
|
"bytes_written": (index + 1) * 1000,
|
||||||
|
"bytes_read": (index + 1) * 500,
|
||||||
|
"elapsed_s": 180,
|
||||||
|
"is_gap": False,
|
||||||
|
"is_zero": False,
|
||||||
|
"is_segment_boundary": False,
|
||||||
|
})
|
||||||
|
return intervals
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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 W/R" in str(app.query_one("#activity-tools").render())
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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")
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("size", [(100, 40), (70, 20)])
|
||||||
|
async def test_follow_pin_expiry_and_today_use_visible_readout(self, tmp_path, size):
|
||||||
|
"""Live selection follows, pins, expires, and resets at both layout sizes."""
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
||||||
|
intervals = _live_intervals()
|
||||||
|
async with app.run_test(size=size) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
graph = app.query_one("#live-activity")
|
||||||
|
graph.set_data(intervals[:3])
|
||||||
|
|
||||||
|
readout = str(app.query_one("#live-readout").render())
|
||||||
|
assert "12:09 UTC" in readout
|
||||||
|
assert graph.selection.following_live
|
||||||
|
assert graph.selection.selected_live_interval == IntervalIdentity(
|
||||||
|
intervals[2]["start_ts"], intervals[2]["end_ts"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Refresh while following selects the newly measured newest point.
|
||||||
|
graph.set_data(intervals)
|
||||||
|
assert "12:12 UTC" in str(app.query_one("#live-readout").render())
|
||||||
|
|
||||||
|
# Live graph receives focus at launch; first arrow pins immediately.
|
||||||
|
await pilot.press("left")
|
||||||
|
pinned = graph.selection.selected_live_interval
|
||||||
|
readout = str(app.query_one("#live-readout").render())
|
||||||
|
assert "12:09 UTC" in readout
|
||||||
|
assert not graph.selection.following_live
|
||||||
|
|
||||||
|
graph.set_data(intervals)
|
||||||
|
readout = str(app.query_one("#live-readout").render())
|
||||||
|
assert "12:09 UTC" in readout
|
||||||
|
assert graph.selection.selected_live_interval == pinned
|
||||||
|
|
||||||
|
# Read/write changes presentation only; pinned point stays stable.
|
||||||
|
await pilot.press("w")
|
||||||
|
graph.set_data(intervals)
|
||||||
|
assert graph.measure == "read"
|
||||||
|
assert graph.selection.selected_live_interval == pinned
|
||||||
|
assert "12:09 UTC" in str(app.query_one("#live-readout").render())
|
||||||
|
|
||||||
|
# Remove pinned interval from rolling window. Explain expiration.
|
||||||
|
graph.set_data(intervals[3:])
|
||||||
|
readout = str(app.query_one("#live-readout").render())
|
||||||
|
assert "Inspected interval expired; following live" in readout
|
||||||
|
assert "12:12 UTC" in readout
|
||||||
|
assert graph.selection.following_live
|
||||||
|
|
||||||
|
# Today action clears expiration and resumes newest-point following.
|
||||||
|
await pilot.press("t")
|
||||||
|
assert graph.selection.following_live
|
||||||
|
assert not graph.selection.live_interval_expired
|
||||||
|
graph.set_data(intervals)
|
||||||
|
readout = str(app.query_one("#live-readout").render())
|
||||||
|
assert "12:12 UTC" in readout
|
||||||
|
assert "Inspected interval expired" not in readout
|
||||||
|
assert graph.selection.following_live
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_mouse_inspection_pins_same_interval_identity(self, tmp_path):
|
||||||
|
"""Mouse inspection pins the interval at its visible graph location."""
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db", refresh_interval_s=999)
|
||||||
|
intervals = _live_intervals()
|
||||||
|
async with app.run_test(size=(100, 40)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
graph = app.query_one("#live-activity")
|
||||||
|
graph.set_data(intervals[:3])
|
||||||
|
await pilot.pause()
|
||||||
|
render = app.query_one("#live-render")
|
||||||
|
await pilot.click(
|
||||||
|
"#live-render",
|
||||||
|
offset=(8, max(0, render.content_size.height // 2)),
|
||||||
|
)
|
||||||
|
await pilot.pause()
|
||||||
|
|
||||||
|
expected = IntervalIdentity(
|
||||||
|
intervals[0]["start_ts"], intervals[0]["end_ts"]
|
||||||
|
)
|
||||||
|
assert graph.selection.selected_live_interval == expected
|
||||||
|
assert not graph.selection.following_live
|
||||||
|
assert "12:03 UTC" in str(app.query_one("#live-readout").render())
|
||||||
@@ -0,0 +1,924 @@
|
|||||||
|
"""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,
|
||||||
|
record_local_activity_interval,
|
||||||
|
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()
|
||||||
|
|
||||||
|
def test_v4_totals_keep_values_but_are_not_treated_as_local_precision(
|
||||||
|
self, tmp_path,
|
||||||
|
):
|
||||||
|
db = tmp_path / "v4.db"
|
||||||
|
conn = sqlite3.connect(db)
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE samples (id INTEGER PRIMARY KEY, ts TEXT, device TEXT)"
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE pending_publications "
|
||||||
|
"(id INTEGER PRIMARY KEY, sample_ts TEXT, payload TEXT)"
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE local_days ("
|
||||||
|
"id INTEGER PRIMARY KEY, 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, sample_count INTEGER DEFAULT 0, "
|
||||||
|
"complete BOOLEAN DEFAULT 0, UNIQUE(local_date, tz_name))"
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO local_days VALUES "
|
||||||
|
"(1, '2026-09-01', 'UTC', '+00:00', "
|
||||||
|
"'2026-09-01T00:00:00+00:00', '2026-09-02T00:00:00+00:00', "
|
||||||
|
"5120000, 2048000, 0.5, 10, 0)"
|
||||||
|
)
|
||||||
|
conn.execute("PRAGMA user_version=4")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
conn = init_store(db)
|
||||||
|
stored = conn.execute(
|
||||||
|
"SELECT bytes_written, activity_precision FROM local_days"
|
||||||
|
).fetchone()
|
||||||
|
assert stored == (5_120_000, "unavailable")
|
||||||
|
visible = query_local_day_summary(conn, "2026-09-01", "UTC")
|
||||||
|
assert visible["bytes_written"] is None
|
||||||
|
assert visible["activity_state"] == "unavailable"
|
||||||
|
assert "local_tz" in {
|
||||||
|
row[1] for row in conn.execute("PRAGMA table_info(samples)")
|
||||||
|
}
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Local-day coverage derivation from UTC hours
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestDeriveLocalDay:
|
||||||
|
"""Derive coverage from UTC hours without allocating local-day bytes."""
|
||||||
|
|
||||||
|
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 == 0
|
||||||
|
assert summary.bytes_read == 0
|
||||||
|
assert summary.activity_precision == "unavailable"
|
||||||
|
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 == 0
|
||||||
|
assert summary.bytes_read == 0
|
||||||
|
assert summary.activity_precision == "unavailable"
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_hourly_bytes_cannot_be_allocated_to_local_midnight(self, tmp_path):
|
||||||
|
"""UTC-hour totals do not establish exact local-day byte totals."""
|
||||||
|
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
|
||||||
|
assert summary.bytes_written == 0
|
||||||
|
assert summary.bytes_read == 0
|
||||||
|
assert summary.activity_precision == "unavailable"
|
||||||
|
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()
|
||||||
|
|
||||||
|
def test_interval_crossing_utc_hour_stays_wholly_in_local_day(
|
||||||
|
self, tmp_path, sysfs_tree, monkeypatch,
|
||||||
|
):
|
||||||
|
"""A measured interval inside one local date keeps its full delta."""
|
||||||
|
from fenris.collector import run_collection
|
||||||
|
|
||||||
|
monkeypatch.setenv("TZ", "Asia/Kolkata")
|
||||||
|
store = str(tmp_path / "obs.db")
|
||||||
|
cfg = {"device": "/dev/nvme0", "store_path": store}
|
||||||
|
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
|
||||||
|
first_at = datetime(2026, 9, 1, 12, 59, tzinfo=timezone.utc)
|
||||||
|
second_at = datetime(2026, 9, 1, 13, 1, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
first = run_collection(
|
||||||
|
_make_smartctl(10_000_000, 8_000_000),
|
||||||
|
sysfs_nvme,
|
||||||
|
cfg,
|
||||||
|
_Clock(first_at),
|
||||||
|
)
|
||||||
|
second = run_collection(
|
||||||
|
_make_smartctl(10_000_010, 8_000_004),
|
||||||
|
sysfs_nvme,
|
||||||
|
cfg,
|
||||||
|
_Clock(second_at),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert first["ok"] and second["ok"]
|
||||||
|
conn = sqlite3.connect(store)
|
||||||
|
summary = query_local_day_summary(conn, "2026-09-01")
|
||||||
|
|
||||||
|
assert summary is not None
|
||||||
|
assert summary["bytes_written"] == 5_120_000
|
||||||
|
assert summary["bytes_read"] == 2_048_000
|
||||||
|
assert summary["segments"] == [{
|
||||||
|
"segment_id": 1,
|
||||||
|
"bytes_written": 5_120_000,
|
||||||
|
"bytes_read": 2_048_000,
|
||||||
|
"activity_seconds": 120,
|
||||||
|
"activity_intervals": 1,
|
||||||
|
}]
|
||||||
|
segment_totals = conn.execute(
|
||||||
|
"SELECT segment_id, bytes_written, bytes_read, activity_intervals "
|
||||||
|
"FROM local_day_segment_totals"
|
||||||
|
).fetchone()
|
||||||
|
assert segment_totals == (1, 5_120_000, 2_048_000, 1)
|
||||||
|
|
||||||
|
samples = conn.execute(
|
||||||
|
"SELECT id, ts, bytes_written, bytes_read, local_tz "
|
||||||
|
"FROM samples ORDER BY id"
|
||||||
|
).fetchall()
|
||||||
|
first_sample, second_sample = samples
|
||||||
|
assert record_local_activity_interval(
|
||||||
|
conn,
|
||||||
|
dict(zip(("id", "ts", "bytes_written", "bytes_read", "local_tz"), first_sample)),
|
||||||
|
dict(zip(("id", "ts", "bytes_written", "bytes_read", "local_tz"), second_sample)),
|
||||||
|
start_sample_id=first_sample[0],
|
||||||
|
end_sample_id=second_sample[0],
|
||||||
|
segment_id=1,
|
||||||
|
) == "known"
|
||||||
|
repeated = query_local_day_summary(conn, "2026-09-01", "Asia/Kolkata")
|
||||||
|
assert repeated["bytes_written"] == 5_120_000
|
||||||
|
assert repeated["bytes_read"] == 2_048_000
|
||||||
|
assert conn.execute(
|
||||||
|
"SELECT SUM(bytes_written), SUM(bytes_read), SUM(activity_intervals) "
|
||||||
|
"FROM local_day_segment_totals"
|
||||||
|
).fetchone() == (5_120_000, 2_048_000, 1)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
(
|
||||||
|
"first_at", "second_at", "local_date", "expected_hours",
|
||||||
|
"expected_start", "expected_end", "expected_offset",
|
||||||
|
),
|
||||||
|
[
|
||||||
|
(
|
||||||
|
datetime(2026, 3, 8, 6, 30, tzinfo=timezone.utc),
|
||||||
|
datetime(2026, 3, 8, 7, 30, tzinfo=timezone.utc),
|
||||||
|
"2026-03-08", 23,
|
||||||
|
"2026-03-08T05:00:00+00:00",
|
||||||
|
"2026-03-09T04:00:00+00:00", "-05:00",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
datetime(2026, 11, 1, 5, 30, tzinfo=timezone.utc),
|
||||||
|
datetime(2026, 11, 1, 6, 30, tzinfo=timezone.utc),
|
||||||
|
"2026-11-01", 25,
|
||||||
|
"2026-11-01T04:00:00+00:00",
|
||||||
|
"2026-11-02T05:00:00+00:00", "-04:00",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
ids=("spring-forward", "fall-back-repeated-hour"),
|
||||||
|
)
|
||||||
|
def test_collector_retains_exact_interval_on_dst_local_day(
|
||||||
|
self,
|
||||||
|
tmp_path,
|
||||||
|
sysfs_tree,
|
||||||
|
monkeypatch,
|
||||||
|
first_at,
|
||||||
|
second_at,
|
||||||
|
local_date,
|
||||||
|
expected_hours,
|
||||||
|
expected_start,
|
||||||
|
expected_end,
|
||||||
|
expected_offset,
|
||||||
|
):
|
||||||
|
"""Collection preserves exact activity on short and long local days."""
|
||||||
|
from fenris.collector import run_collection
|
||||||
|
|
||||||
|
zone = "America/New_York"
|
||||||
|
monkeypatch.setenv("TZ", zone)
|
||||||
|
store = str(tmp_path / "obs.db")
|
||||||
|
cfg = {"device": "/dev/nvme0", "store_path": store}
|
||||||
|
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
|
||||||
|
assert run_collection(
|
||||||
|
_make_smartctl(10_000_000, 8_000_000),
|
||||||
|
sysfs_nvme,
|
||||||
|
cfg,
|
||||||
|
_Clock(first_at),
|
||||||
|
)["ok"]
|
||||||
|
assert run_collection(
|
||||||
|
_make_smartctl(10_000_001, 8_000_002),
|
||||||
|
sysfs_nvme,
|
||||||
|
cfg,
|
||||||
|
_Clock(second_at),
|
||||||
|
)["ok"]
|
||||||
|
|
||||||
|
conn = sqlite3.connect(store)
|
||||||
|
summary = query_local_day_summary(
|
||||||
|
conn, local_date, zone, second_at + timedelta(minutes=1)
|
||||||
|
)
|
||||||
|
assert summary is not None
|
||||||
|
assert summary["bytes_written"] == 512_000
|
||||||
|
assert summary["bytes_read"] == 1_024_000
|
||||||
|
assert summary["activity_seconds"] == 3_600
|
||||||
|
assert summary["utc_start"] == expected_start
|
||||||
|
assert summary["utc_end"] == expected_end
|
||||||
|
assert summary["tz_offset"] == expected_offset
|
||||||
|
assert (
|
||||||
|
datetime.fromisoformat(summary["utc_end"])
|
||||||
|
- datetime.fromisoformat(summary["utc_start"])
|
||||||
|
).total_seconds() == expected_hours * 3_600
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_local_midnight_interval_is_shared_once(
|
||||||
|
self, tmp_path, sysfs_tree, monkeypatch,
|
||||||
|
):
|
||||||
|
"""A real counter interval crossing local midnight stays unallocated."""
|
||||||
|
from fenris.collector import run_collection
|
||||||
|
|
||||||
|
monkeypatch.setenv("TZ", "Asia/Kolkata")
|
||||||
|
store = str(tmp_path / "obs.db")
|
||||||
|
cfg = {"device": "/dev/nvme0", "store_path": store}
|
||||||
|
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
|
||||||
|
first_at = datetime(2026, 9, 1, 18, 25, tzinfo=timezone.utc)
|
||||||
|
second_at = datetime(2026, 9, 1, 18, 35, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
first = run_collection(
|
||||||
|
_make_smartctl(10_000_000, 8_000_000),
|
||||||
|
sysfs_nvme,
|
||||||
|
cfg,
|
||||||
|
_Clock(first_at),
|
||||||
|
)
|
||||||
|
second = run_collection(
|
||||||
|
_make_smartctl(10_000_100, 8_000_060),
|
||||||
|
sysfs_nvme,
|
||||||
|
cfg,
|
||||||
|
_Clock(second_at),
|
||||||
|
)
|
||||||
|
assert first["ok"] and second["ok"]
|
||||||
|
|
||||||
|
conn = sqlite3.connect(store)
|
||||||
|
evidence = conn.execute(
|
||||||
|
"SELECT COUNT(*), SUM(bytes_written), SUM(bytes_read) "
|
||||||
|
"FROM local_day_unallocated_evidence"
|
||||||
|
).fetchone()
|
||||||
|
assert evidence == (1, 51_200_000, 30_720_000)
|
||||||
|
assert conn.execute(
|
||||||
|
"SELECT SUM(bytes_written), SUM(bytes_read) FROM local_days"
|
||||||
|
).fetchone() == (0, 0)
|
||||||
|
|
||||||
|
for local_date in ("2026-09-01", "2026-09-02"):
|
||||||
|
summary = query_local_day_summary(
|
||||||
|
conn, local_date, "Asia/Kolkata", second_at
|
||||||
|
)
|
||||||
|
assert summary is not None
|
||||||
|
assert summary["bytes_written"] is None
|
||||||
|
assert summary["shared_bytes_written"] == 51_200_000
|
||||||
|
assert summary["shared_bytes_read"] == 30_720_000
|
||||||
|
assert summary["activity_state"] in ("incomplete", "so_far")
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
from fenris.status import read_status
|
||||||
|
from fenris.tui import FenrisTuiApp
|
||||||
|
|
||||||
|
app = FenrisTuiApp(store_path=Path(store), refresh_interval_s=999)
|
||||||
|
async with app.run_test(size=(100, 32)):
|
||||||
|
app._browse_date = "2026-09-02"
|
||||||
|
with read_status(
|
||||||
|
Path(store), second_at, query_services=False
|
||||||
|
) as (reader, _):
|
||||||
|
assert reader is not None
|
||||||
|
app._render_local_day(reader)
|
||||||
|
visible = str(app.query_one("#local-day").render())
|
||||||
|
assert "2026-09-02" in visible
|
||||||
|
assert "Asia/Kolkata +05:30" in visible
|
||||||
|
assert "W unavailable" in visible
|
||||||
|
assert "shared at midnight W 0.051 GB" in visible
|
||||||
|
|
||||||
|
def test_synthetic_shared_bytes_conserve_one_hundred_bytes(self, tmp_path):
|
||||||
|
"""Shared evidence conserves byte values before display rounding."""
|
||||||
|
conn = init_store(tmp_path / "obs.db")
|
||||||
|
first_at = datetime(2026, 9, 1, 18, 25, tzinfo=timezone.utc)
|
||||||
|
second_at = datetime(2026, 9, 3, 18, 35, tzinfo=timezone.utc)
|
||||||
|
ensure_period_open(conn, first_at)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO samples (ts, device, bytes_written, bytes_read, local_tz) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(first_at.isoformat(), "/dev/nvme0", 1_000, 2_000, "Asia/Kolkata"),
|
||||||
|
)
|
||||||
|
first_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO samples (ts, device, bytes_written, bytes_read, local_tz) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(second_at.isoformat(), "/dev/nvme0", 1_100, 2_050, "Asia/Kolkata"),
|
||||||
|
)
|
||||||
|
second_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
|
||||||
|
|
||||||
|
result = record_local_activity_interval(
|
||||||
|
conn,
|
||||||
|
{
|
||||||
|
"id": first_id,
|
||||||
|
"ts": first_at.isoformat(),
|
||||||
|
"bytes_written": 1_000,
|
||||||
|
"bytes_read": 2_000,
|
||||||
|
"local_tz": "Asia/Kolkata",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": second_id,
|
||||||
|
"ts": second_at.isoformat(),
|
||||||
|
"bytes_written": 1_100,
|
||||||
|
"bytes_read": 2_050,
|
||||||
|
"local_tz": "Asia/Kolkata",
|
||||||
|
},
|
||||||
|
start_sample_id=first_id,
|
||||||
|
end_sample_id=second_id,
|
||||||
|
segment_id=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == "local_midnight"
|
||||||
|
stored = conn.execute(
|
||||||
|
"SELECT COUNT(*), SUM(bytes_written), SUM(bytes_read) "
|
||||||
|
"FROM local_day_unallocated_evidence"
|
||||||
|
).fetchone()
|
||||||
|
assert stored == (1, 100, 50)
|
||||||
|
record_local_activity_interval(
|
||||||
|
conn,
|
||||||
|
{
|
||||||
|
"id": first_id,
|
||||||
|
"ts": first_at.isoformat(),
|
||||||
|
"bytes_written": 1_000,
|
||||||
|
"bytes_read": 2_000,
|
||||||
|
"local_tz": "Asia/Kolkata",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": second_id,
|
||||||
|
"ts": second_at.isoformat(),
|
||||||
|
"bytes_written": 1_100,
|
||||||
|
"bytes_read": 2_050,
|
||||||
|
"local_tz": "Asia/Kolkata",
|
||||||
|
},
|
||||||
|
start_sample_id=first_id,
|
||||||
|
end_sample_id=second_id,
|
||||||
|
segment_id=1,
|
||||||
|
)
|
||||||
|
assert conn.execute(
|
||||||
|
"SELECT COUNT(*), SUM(bytes_written), SUM(bytes_read) "
|
||||||
|
"FROM local_day_unallocated_evidence"
|
||||||
|
).fetchone() == (1, 100, 50)
|
||||||
|
assert conn.execute(
|
||||||
|
"SELECT SUM(bytes_written), SUM(bytes_read) FROM local_days"
|
||||||
|
).fetchone() == (0, 0)
|
||||||
|
middle_day = query_local_day_summary(
|
||||||
|
conn, "2026-09-03", "Asia/Kolkata", second_at + timedelta(days=1)
|
||||||
|
)
|
||||||
|
assert middle_day["shared_bytes_written"] == 100
|
||||||
|
assert middle_day["shared_bytes_read"] == 50
|
||||||
|
assert middle_day["shared_evidence_count"] == 1
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_timezone_change_keeps_interval_unallocated(
|
||||||
|
self, tmp_path, sysfs_tree, monkeypatch,
|
||||||
|
):
|
||||||
|
from fenris.collector import run_collection
|
||||||
|
|
||||||
|
store = str(tmp_path / "obs.db")
|
||||||
|
cfg = {"device": "/dev/nvme0", "store_path": store}
|
||||||
|
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
|
||||||
|
first_at = datetime(2026, 9, 1, 22, 0, tzinfo=timezone.utc)
|
||||||
|
second_at = datetime(2026, 9, 1, 22, 5, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
monkeypatch.setenv("TZ", "UTC")
|
||||||
|
assert run_collection(
|
||||||
|
_make_smartctl(10_000_000, 8_000_000),
|
||||||
|
sysfs_nvme,
|
||||||
|
cfg,
|
||||||
|
_Clock(first_at),
|
||||||
|
)["ok"]
|
||||||
|
monkeypatch.setenv("TZ", "Asia/Kolkata")
|
||||||
|
assert run_collection(
|
||||||
|
_make_smartctl(10_000_010, 8_000_004),
|
||||||
|
sysfs_nvme,
|
||||||
|
cfg,
|
||||||
|
_Clock(second_at),
|
||||||
|
)["ok"]
|
||||||
|
|
||||||
|
conn = sqlite3.connect(store)
|
||||||
|
event = conn.execute(
|
||||||
|
"SELECT COUNT(*), SUM(bytes_written), MIN(reason) "
|
||||||
|
"FROM local_day_unallocated_evidence"
|
||||||
|
).fetchone()
|
||||||
|
assert event == (1, 5_120_000, "timezone_change")
|
||||||
|
old_zone = query_local_day_summary(conn, "2026-09-01", "UTC")
|
||||||
|
new_zone = query_local_day_summary(conn, "2026-09-02", "Asia/Kolkata")
|
||||||
|
assert old_zone["bytes_written"] is None
|
||||||
|
assert new_zone["bytes_written"] is None
|
||||||
|
assert old_zone["unallocated_bytes_written"] == 5_120_000
|
||||||
|
assert new_zone["unallocated_bytes_written"] == 5_120_000
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_pause_boundary_keeps_interval_unallocated(
|
||||||
|
self, tmp_path, sysfs_tree, monkeypatch,
|
||||||
|
):
|
||||||
|
from fenris.collector import run_collection
|
||||||
|
|
||||||
|
monkeypatch.setenv("TZ", "UTC")
|
||||||
|
store = str(tmp_path / "obs.db")
|
||||||
|
cfg = {"device": "/dev/nvme0", "store_path": store}
|
||||||
|
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
|
||||||
|
first_at = datetime(2026, 9, 1, 12, 0, tzinfo=timezone.utc)
|
||||||
|
second_at = datetime(2026, 9, 1, 12, 10, tzinfo=timezone.utc)
|
||||||
|
assert run_collection(
|
||||||
|
_make_smartctl(10_000_000, 8_000_000),
|
||||||
|
sysfs_nvme,
|
||||||
|
cfg,
|
||||||
|
_Clock(first_at),
|
||||||
|
)["ok"]
|
||||||
|
|
||||||
|
conn = sqlite3.connect(store)
|
||||||
|
close_period(conn, first_at + timedelta(minutes=5), "user_disabled")
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
assert run_collection(
|
||||||
|
_make_smartctl(10_000_010, 8_000_004),
|
||||||
|
sysfs_nvme,
|
||||||
|
cfg,
|
||||||
|
_Clock(second_at),
|
||||||
|
)["ok"]
|
||||||
|
|
||||||
|
conn = sqlite3.connect(store)
|
||||||
|
reason = conn.execute(
|
||||||
|
"SELECT reason FROM local_day_unallocated_evidence"
|
||||||
|
).fetchone()
|
||||||
|
summary = query_local_day_summary(conn, "2026-09-01", "UTC")
|
||||||
|
assert reason == ("monitoring_period",)
|
||||||
|
assert summary["bytes_written"] is None
|
||||||
|
assert summary["unallocated_bytes_written"] == 5_120_000
|
||||||
|
assert summary["shared_bytes_written"] == 0
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_controller_segment_boundary_marks_local_activity_incomplete(
|
||||||
|
self, tmp_path, sysfs_tree, monkeypatch,
|
||||||
|
):
|
||||||
|
from fenris.collector import run_collection
|
||||||
|
|
||||||
|
monkeypatch.setenv("TZ", "UTC")
|
||||||
|
store = str(tmp_path / "obs.db")
|
||||||
|
cfg = {"device": "/dev/nvme0", "store_path": store}
|
||||||
|
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
|
||||||
|
first_at = datetime(2026, 9, 1, 12, 0, tzinfo=timezone.utc)
|
||||||
|
second_at = datetime(2026, 9, 1, 12, 5, tzinfo=timezone.utc)
|
||||||
|
assert run_collection(
|
||||||
|
_make_smartctl(10_000_000, 8_000_000),
|
||||||
|
sysfs_nvme,
|
||||||
|
cfg,
|
||||||
|
_Clock(first_at),
|
||||||
|
)["ok"]
|
||||||
|
assert run_collection(
|
||||||
|
_make_smartctl(10, 8_000_004),
|
||||||
|
sysfs_nvme,
|
||||||
|
cfg,
|
||||||
|
_Clock(second_at),
|
||||||
|
)["ok"]
|
||||||
|
|
||||||
|
conn = sqlite3.connect(store)
|
||||||
|
summary = query_local_day_summary(
|
||||||
|
conn, "2026-09-01", "UTC", second_at
|
||||||
|
)
|
||||||
|
assert summary is not None
|
||||||
|
assert summary["bytes_written"] is None
|
||||||
|
assert summary["activity_state"] == "incomplete"
|
||||||
|
assert conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM local_day_unallocated_evidence"
|
||||||
|
).fetchone()[0] == 0
|
||||||
|
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)
|
||||||
|
# New York 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, "America/New_York", 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 duration == 23 * 3600
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_long_day_25_hours(self):
|
||||||
|
from fenris.local_day import local_day_boundaries
|
||||||
|
|
||||||
|
start, end, offset = local_day_boundaries(
|
||||||
|
"2026-11-01", "America/New_York"
|
||||||
|
)
|
||||||
|
assert (end - start).total_seconds() == 25 * 3600
|
||||||
|
assert offset == "-04:00"
|
||||||
|
|
||||||
|
def test_half_hour_offset_records_exact_local_boundaries(self):
|
||||||
|
from fenris.local_day import local_day_boundaries
|
||||||
|
|
||||||
|
start, end, offset = local_day_boundaries(
|
||||||
|
"2026-09-01", "Asia/Kolkata"
|
||||||
|
)
|
||||||
|
assert start.isoformat() == "2026-08-31T18:30:00+00:00"
|
||||||
|
assert end.isoformat() == "2026-09-01T18:30:00+00:00"
|
||||||
|
assert offset == "+05:30"
|
||||||
|
|
||||||
|
def test_repeated_local_clock_labels_remain_one_recorded_day(
|
||||||
|
self, tmp_path, sysfs_tree, monkeypatch,
|
||||||
|
):
|
||||||
|
from fenris.collector import run_collection
|
||||||
|
|
||||||
|
monkeypatch.setenv("TZ", "America/New_York")
|
||||||
|
store = str(tmp_path / "obs.db")
|
||||||
|
cfg = {"device": "/dev/nvme0", "store_path": store}
|
||||||
|
sysfs_nvme = sysfs_tree / "sys" / "class" / "nvme" / "nvme0"
|
||||||
|
# Both endpoints display as 01:30 locally, on opposite UTC offsets.
|
||||||
|
first_at = datetime(2026, 11, 1, 5, 30, tzinfo=timezone.utc)
|
||||||
|
second_at = datetime(2026, 11, 1, 6, 30, tzinfo=timezone.utc)
|
||||||
|
assert run_collection(
|
||||||
|
_make_smartctl(10_000_000, 8_000_000),
|
||||||
|
sysfs_nvme,
|
||||||
|
cfg,
|
||||||
|
_Clock(first_at),
|
||||||
|
)["ok"]
|
||||||
|
assert run_collection(
|
||||||
|
_make_smartctl(10_000_001, 8_000_002),
|
||||||
|
sysfs_nvme,
|
||||||
|
cfg,
|
||||||
|
_Clock(second_at),
|
||||||
|
)["ok"]
|
||||||
|
|
||||||
|
conn = sqlite3.connect(store)
|
||||||
|
summary = query_local_day_summary(
|
||||||
|
conn, "2026-11-01", "America/New_York", second_at
|
||||||
|
)
|
||||||
|
assert summary is not None
|
||||||
|
assert summary["bytes_written"] == 512_000
|
||||||
|
assert summary["bytes_read"] == 1_024_000
|
||||||
|
assert summary["utc_start"] == "2026-11-01T04:00:00+00:00"
|
||||||
|
assert summary["utc_end"] == "2026-11-02T05:00:00+00:00"
|
||||||
|
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()
|
||||||
+11
-26
@@ -56,8 +56,7 @@ class TestEnableIdempotentMatrix:
|
|||||||
"""A fresh package install has a store directory but no database yet."""
|
"""A fresh package install has a store directory but no database yet."""
|
||||||
args = MagicMock(now=False, store_path=store_path)
|
args = MagicMock(now=False, store_path=store_path)
|
||||||
|
|
||||||
with patch("fenris.monitor.subprocess") as mock_sub:
|
with patch("fenris.monitor.enable_timer") as mock_enable:
|
||||||
mock_sub.run.return_value = MagicMock(returncode=0)
|
|
||||||
cmd_enable(args)
|
cmd_enable(args)
|
||||||
|
|
||||||
conn = init_store(store_path)
|
conn = init_store(store_path)
|
||||||
@@ -74,8 +73,7 @@ class TestEnableIdempotentMatrix:
|
|||||||
|
|
||||||
args = MagicMock(now=False, store_path=store_path)
|
args = MagicMock(now=False, store_path=store_path)
|
||||||
|
|
||||||
with patch("fenris.monitor.subprocess") as mock_sub:
|
with patch("fenris.monitor.enable_timer") as mock_enable:
|
||||||
mock_sub.run.return_value = MagicMock(returncode=0)
|
|
||||||
cmd_enable(args)
|
cmd_enable(args)
|
||||||
|
|
||||||
# Period should be open
|
# Period should be open
|
||||||
@@ -100,8 +98,7 @@ class TestEnableIdempotentMatrix:
|
|||||||
|
|
||||||
args = MagicMock(now=True, store_path=store_path)
|
args = MagicMock(now=True, store_path=store_path)
|
||||||
|
|
||||||
with patch("fenris.monitor.subprocess") as mock_sub:
|
with patch("fenris.monitor.enable_timer") as mock_enable:
|
||||||
mock_sub.run.return_value = MagicMock(returncode=0)
|
|
||||||
cmd_enable(args)
|
cmd_enable(args)
|
||||||
|
|
||||||
# Should still have exactly one open period
|
# Should still have exactly one open period
|
||||||
@@ -125,8 +122,7 @@ class TestEnableIdempotentMatrix:
|
|||||||
|
|
||||||
args = MagicMock(now=True, store_path=store_path)
|
args = MagicMock(now=True, store_path=store_path)
|
||||||
|
|
||||||
with patch("fenris.monitor.subprocess") as mock_sub:
|
with patch("fenris.monitor.enable_timer") as mock_enable:
|
||||||
mock_sub.run.return_value = MagicMock(returncode=0)
|
|
||||||
cmd_enable(args)
|
cmd_enable(args)
|
||||||
|
|
||||||
# Should have a new open period
|
# Should have a new open period
|
||||||
@@ -155,8 +151,7 @@ class TestDisableIdempotentMatrix:
|
|||||||
|
|
||||||
args = MagicMock(now=True, store_path=store_path)
|
args = MagicMock(now=True, store_path=store_path)
|
||||||
|
|
||||||
with patch("fenris.monitor.subprocess") as mock_sub:
|
with patch("fenris.monitor.disable_timer") as mock_disable:
|
||||||
mock_sub.run.return_value = MagicMock(returncode=0)
|
|
||||||
cmd_disable(args)
|
cmd_disable(args)
|
||||||
|
|
||||||
# Period should be closed with user_disabled
|
# Period should be closed with user_disabled
|
||||||
@@ -174,8 +169,7 @@ class TestDisableIdempotentMatrix:
|
|||||||
|
|
||||||
args = MagicMock(now=True, store_path=store_path)
|
args = MagicMock(now=True, store_path=store_path)
|
||||||
|
|
||||||
with patch("fenris.monitor.subprocess") as mock_sub:
|
with patch("fenris.monitor.disable_timer") as mock_disable:
|
||||||
mock_sub.run.return_value = MagicMock(returncode=0)
|
|
||||||
cmd_disable(args)
|
cmd_disable(args)
|
||||||
|
|
||||||
# No periods should exist
|
# No periods should exist
|
||||||
@@ -207,28 +201,19 @@ class TestDisableIdempotentMatrix:
|
|||||||
class TestCollectTrigger:
|
class TestCollectTrigger:
|
||||||
"""§8.7: On-demand collection via helper path."""
|
"""§8.7: On-demand collection via helper path."""
|
||||||
|
|
||||||
def test_collect_triggers_systemctl_start(self):
|
def test_collect_triggers_init_system(self):
|
||||||
"""Collect starts fenris-collect.service synchronously."""
|
"""Collect triggers init_system.collect_now."""
|
||||||
args = MagicMock()
|
args = MagicMock()
|
||||||
|
|
||||||
with patch("fenris.monitor.subprocess") as mock_sub:
|
with patch("fenris.monitor.collect_now") as mock_collect:
|
||||||
mock_sub.run.return_value = MagicMock(returncode=0)
|
|
||||||
cmd_collect(args)
|
cmd_collect(args)
|
||||||
|
mock_collect.assert_called_once()
|
||||||
mock_sub.run.assert_called_once_with(
|
|
||||||
["systemctl", "start", "fenris-collect.service"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_collect_failure_exits_nonzero(self):
|
def test_collect_failure_exits_nonzero(self):
|
||||||
"""Collect failure exits with nonzero status."""
|
"""Collect failure exits with nonzero status."""
|
||||||
args = MagicMock()
|
args = MagicMock()
|
||||||
|
|
||||||
with patch("fenris.monitor.subprocess") as mock_sub:
|
with patch("fenris.monitor.collect_now", side_effect=SystemExit(1)):
|
||||||
mock_sub.run.return_value = MagicMock(
|
|
||||||
returncode=1, stderr="Unit not found"
|
|
||||||
)
|
|
||||||
with pytest.raises(SystemExit) as exc_info:
|
with pytest.raises(SystemExit) as exc_info:
|
||||||
cmd_collect(args)
|
cmd_collect(args)
|
||||||
assert exc_info.value.code == 1
|
assert exc_info.value.code == 1
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
"""Package directory permissions must not inherit a builder's private umask."""
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("mask", ["077", "022"])
|
||||||
|
def test_stage_is_publicly_traversable_under_any_builder_umask(tmp_path, mask):
|
||||||
|
root = Path(__file__).resolve().parent.parent
|
||||||
|
for directory in ("packaging", "scripts", "src", "units", "polkit"):
|
||||||
|
shutil.copytree(root / directory, tmp_path / directory,
|
||||||
|
ignore=shutil.ignore_patterns("__pycache__", "*.egg-info"))
|
||||||
|
for filename in ("LICENSE", "requirements.txt"):
|
||||||
|
shutil.copy(root / filename, tmp_path / filename)
|
||||||
|
(tmp_path / "dist").mkdir()
|
||||||
|
(tmp_path / "dist/fenris-9.9.9-py3-none-any.whl").touch()
|
||||||
|
bin_dir = tmp_path / "bin"
|
||||||
|
bin_dir.mkdir()
|
||||||
|
# Avoid downloading dependencies: emulate pip's target output while
|
||||||
|
# exercising the real staging script and its package directory layout.
|
||||||
|
pip_stub = bin_dir / "python3"
|
||||||
|
pip_stub.write_text(
|
||||||
|
f"#!{sys.executable}\n"
|
||||||
|
"import sys\nfrom pathlib import Path\n"
|
||||||
|
"target = Path(sys.argv[sys.argv.index('--target') + 1]) / 'fenris'\n"
|
||||||
|
"target.mkdir(parents=True)\n"
|
||||||
|
"(target / '__init__.py').write_text('')\n"
|
||||||
|
)
|
||||||
|
pip_stub.chmod(0o755)
|
||||||
|
subprocess.run(
|
||||||
|
["bash", "-c", f"umask {mask}; exec bash packaging/stage.sh 9.9.9"],
|
||||||
|
cwd=tmp_path, env={**os.environ, "PATH": str(bin_dir) + os.pathsep + os.environ["PATH"]},
|
||||||
|
check=True, capture_output=True, text=True,
|
||||||
|
)
|
||||||
|
stage = tmp_path / "build/stage"
|
||||||
|
for directory in (stage, *(p for p in stage.rglob("*") if p.is_dir())):
|
||||||
|
assert directory.stat().st_mode & 0o777 == 0o755, directory
|
||||||
|
assert (stage / "usr/bin/fenris").stat().st_mode & 0o777 == 0o755
|
||||||
|
assert (stage / "opt/fenris/vendor/fenris/__init__.py").stat().st_mode & 0o444 == 0o444
|
||||||
+720
-112
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,301 @@
|
|||||||
|
"""Tests for user-scoped TUI preferences (issue #80).
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- Preference load/save with safe defaults
|
||||||
|
- XDG_CONFIG_HOME user-scoped persistence
|
||||||
|
- Chalktone default theme, normal-motion default
|
||||||
|
- Invalid/unreadable/unwritable preference data does not crash
|
||||||
|
- Theme presets: Amber, Nord, High Contrast
|
||||||
|
- Reduced motion preference persistence
|
||||||
|
- CLI status and collector behaviour unchanged by preferences
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
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.preferences import (
|
||||||
|
load_preferences,
|
||||||
|
save_preferences,
|
||||||
|
get_preference_path,
|
||||||
|
PREFERENCE_FILE_NAME,
|
||||||
|
VALID_THEMES,
|
||||||
|
DEFAULT_THEME,
|
||||||
|
DEFAULT_REDUCED_MOTION,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _make_prefs_dir(tmp_path: Path) -> Path:
|
||||||
|
"""Create a fake XDG_CONFIG_HOME with fenris subdir."""
|
||||||
|
config_home = tmp_path / ".config"
|
||||||
|
config_home.mkdir(parents=True, exist_ok=True)
|
||||||
|
return config_home
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Preference path tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestPreferencePath:
|
||||||
|
"""Preference file lives at XDG_CONFIG_HOME/fenris/preferences.json."""
|
||||||
|
|
||||||
|
def test_uses_xdg_config_home(self, tmp_path):
|
||||||
|
"""Path respects XDG_CONFIG_HOME environment variable."""
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
path = get_preference_path()
|
||||||
|
assert path == config_home / "fenris" / PREFERENCE_FILE_NAME
|
||||||
|
|
||||||
|
def test_default_path_fallback(self):
|
||||||
|
"""When XDG_CONFIG_HOME is unset, falls back to ~/.config."""
|
||||||
|
with patch.dict(os.environ, {}, clear=True):
|
||||||
|
# Remove XDG_CONFIG_HOME if present
|
||||||
|
os.environ.pop("XDG_CONFIG_HOME", None)
|
||||||
|
path = get_preference_path()
|
||||||
|
assert "fenris" in str(path)
|
||||||
|
assert PREFERENCE_FILE_NAME in str(path)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Default preferences tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestDefaults:
|
||||||
|
"""When no preference file exists, defaults are returned."""
|
||||||
|
|
||||||
|
def test_default_theme_is_chalktone(self):
|
||||||
|
"""Amber is the default theme preset."""
|
||||||
|
assert DEFAULT_THEME == "chalktone"
|
||||||
|
|
||||||
|
def test_default_reduced_motion_is_false(self):
|
||||||
|
"""Normal motion is the default."""
|
||||||
|
assert DEFAULT_REDUCED_MOTION is False
|
||||||
|
|
||||||
|
def test_valid_themes_are_all_presets(self):
|
||||||
|
"""Three valid presets exist."""
|
||||||
|
assert VALID_THEMES == {"chalktone", "amber", "nord", "high_contrast"}
|
||||||
|
|
||||||
|
def test_load_returns_defaults_when_no_file(self, tmp_path):
|
||||||
|
"""Missing preference file returns safe defaults."""
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(tmp_path)}):
|
||||||
|
prefs = load_preferences()
|
||||||
|
assert prefs["theme"] == "chalktone"
|
||||||
|
assert prefs["reduced_motion"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Save and load round-trip tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestRoundTrip:
|
||||||
|
"""Preferences survive save → load."""
|
||||||
|
|
||||||
|
def test_save_and_load_basic(self, tmp_path):
|
||||||
|
"""Basic theme and motion save/load."""
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
save_preferences(theme="nord", reduced_motion=True)
|
||||||
|
prefs = load_preferences()
|
||||||
|
assert prefs["theme"] == "nord"
|
||||||
|
assert prefs["reduced_motion"] is True
|
||||||
|
|
||||||
|
def test_save_creates_directory(self, tmp_path):
|
||||||
|
"""Save creates the fenris config directory if missing."""
|
||||||
|
config_home = tmp_path / ".config"
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
save_preferences(theme="amber", reduced_motion=False)
|
||||||
|
assert (config_home / "fenris" / PREFERENCE_FILE_NAME).exists()
|
||||||
|
|
||||||
|
def test_overwrites_existing(self, tmp_path):
|
||||||
|
"""Second save overwrites the first."""
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
save_preferences(theme="amber", reduced_motion=False)
|
||||||
|
save_preferences(theme="high_contrast", reduced_motion=True)
|
||||||
|
prefs = load_preferences()
|
||||||
|
assert prefs["theme"] == "high_contrast"
|
||||||
|
assert prefs["reduced_motion"] is True
|
||||||
|
|
||||||
|
def test_all_themes_round_trip(self, tmp_path):
|
||||||
|
"""Every valid theme saves and loads correctly."""
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
for theme in VALID_THEMES:
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
save_preferences(theme=theme, reduced_motion=False)
|
||||||
|
prefs = load_preferences()
|
||||||
|
assert prefs["theme"] == theme
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Safe failure tests — invalid/unreadable/unwritable
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestSafeFailures:
|
||||||
|
"""Invalid data never crashes the dashboard."""
|
||||||
|
|
||||||
|
def test_corrupt_json_returns_defaults(self, tmp_path):
|
||||||
|
"""Malformed JSON returns safe defaults."""
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
fenris_dir = config_home / "fenris"
|
||||||
|
fenris_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(fenris_dir / PREFERENCE_FILE_NAME).write_text("{corrupt json!!")
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
prefs = load_preferences()
|
||||||
|
assert prefs["theme"] == "chalktone"
|
||||||
|
assert prefs["reduced_motion"] is False
|
||||||
|
|
||||||
|
def test_unknown_theme_returns_default(self, tmp_path):
|
||||||
|
"""Unrecognized theme value falls back to amber."""
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
fenris_dir = config_home / "fenris"
|
||||||
|
fenris_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(fenris_dir / PREFERENCE_FILE_NAME).write_text(
|
||||||
|
json.dumps({"theme": "neon-pink", "reduced_motion": False})
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
prefs = load_preferences()
|
||||||
|
assert prefs["theme"] == "chalktone"
|
||||||
|
|
||||||
|
def test_missing_keys_get_defaults(self, tmp_path):
|
||||||
|
"""Partial preference file fills in missing keys."""
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
fenris_dir = config_home / "fenris"
|
||||||
|
fenris_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(fenris_dir / PREFERENCE_FILE_NAME).write_text(
|
||||||
|
json.dumps({"theme": "nord"})
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
prefs = load_preferences()
|
||||||
|
assert prefs["theme"] == "nord"
|
||||||
|
assert prefs["reduced_motion"] is False
|
||||||
|
|
||||||
|
def test_unreadable_file_returns_defaults(self, tmp_path):
|
||||||
|
"""Permission denied on preference file returns defaults."""
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
fenris_dir = config_home / "fenris"
|
||||||
|
fenris_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
pref_file = fenris_dir / PREFERENCE_FILE_NAME
|
||||||
|
pref_file.write_text(json.dumps({"theme": "nord"}))
|
||||||
|
pref_file.chmod(0o000)
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
prefs = load_preferences()
|
||||||
|
# Should fall back to defaults without crashing
|
||||||
|
assert prefs["theme"] in VALID_THEMES
|
||||||
|
|
||||||
|
def test_unwritable_location_returns_defaults(self, tmp_path):
|
||||||
|
"""Read-only config directory returns defaults without crashing."""
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
fenris_dir = config_home / "fenris"
|
||||||
|
fenris_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
fenris_dir.chmod(0o555)
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
# Should not raise
|
||||||
|
save_preferences(theme="nord", reduced_motion=True)
|
||||||
|
prefs = load_preferences()
|
||||||
|
# Either saved successfully or fell back — either way, no crash
|
||||||
|
assert prefs["theme"] in VALID_THEMES
|
||||||
|
|
||||||
|
def test_non_json_file_returns_defaults(self, tmp_path):
|
||||||
|
"""A file that isn't JSON returns defaults."""
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
fenris_dir = config_home / "fenris"
|
||||||
|
fenris_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(fenris_dir / PREFERENCE_FILE_NAME).write_text("this is not json")
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
prefs = load_preferences()
|
||||||
|
assert prefs["theme"] == "chalktone"
|
||||||
|
|
||||||
|
def test_wrong_type_for_reduced_motion(self, tmp_path):
|
||||||
|
"""Non-boolean reduced_motion falls back to default."""
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
fenris_dir = config_home / "fenris"
|
||||||
|
fenris_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(fenris_dir / PREFERENCE_FILE_NAME).write_text(
|
||||||
|
json.dumps({"theme": "amber", "reduced_motion": "yes"})
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
prefs = load_preferences()
|
||||||
|
assert prefs["reduced_motion"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CLI/collector isolation tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestCLIIsolation:
|
||||||
|
"""TUI display preferences do not affect CLI status or collector."""
|
||||||
|
|
||||||
|
def test_cli_status_ignores_preferences(self, tmp_path):
|
||||||
|
"""fenris status output is independent of TUI preferences."""
|
||||||
|
from fenris.status import get_status
|
||||||
|
|
||||||
|
# Create a valid store with data
|
||||||
|
from fenris.store import init_store
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO samples (ts, device, data_units_written, data_units_read, "
|
||||||
|
"percentage_used, bytes_written, bytes_read, power_on_hours) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
("2026-09-30T10:00:00+00:00", "/dev/nvme0n1", 1000000, 500000,
|
||||||
|
5, 512000000000, 256000000000, 8765),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Save non-default preferences
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
save_preferences(theme="high_contrast", reduced_motion=True)
|
||||||
|
status_text = get_status(
|
||||||
|
store_path=tmp_path / "test.db",
|
||||||
|
clock_now=datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc),
|
||||||
|
query_services=False,
|
||||||
|
query_journal=False,
|
||||||
|
)
|
||||||
|
# Status output should NOT contain theme names or motion settings
|
||||||
|
assert "high_contrast" not in status_text.lower()
|
||||||
|
assert "reduced_motion" not in status_text.lower()
|
||||||
|
assert "amber" not in status_text.lower()
|
||||||
|
|
||||||
|
def test_preferences_do_not_alter_store(self, tmp_path):
|
||||||
|
"""Saving preferences never writes to the observation store."""
|
||||||
|
import sqlite3
|
||||||
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
|
store_path = tmp_path / "observations.db"
|
||||||
|
|
||||||
|
from fenris.store import init_store
|
||||||
|
conn = init_store(store_path)
|
||||||
|
# Record store state before preference save
|
||||||
|
cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||||
|
tables_before = sorted(r[0] for r in cursor.fetchall())
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
|
save_preferences(theme="nord", reduced_motion=True)
|
||||||
|
|
||||||
|
# Store schema should be unchanged
|
||||||
|
conn = sqlite3.connect(store_path)
|
||||||
|
cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||||
|
tables_after = sorted(r[0] for r in cursor.fetchall())
|
||||||
|
conn.close()
|
||||||
|
assert tables_before == tables_after
|
||||||
|
|
||||||
|
|
||||||
|
# Need datetime for CLI isolation test
|
||||||
|
from datetime import datetime, timezone
|
||||||
+177
-1
@@ -22,7 +22,7 @@ from fenris.monitoring_periods import ensure_period_open, close_period
|
|||||||
from fenris.projection import (
|
from fenris.projection import (
|
||||||
compute_projection, ConfidenceState, BaselineTier, ScenarioRange,
|
compute_projection, ConfidenceState, BaselineTier, ScenarioRange,
|
||||||
TBW_TO_BYTES, HORIZON_DAYS, WARMING_MIN_DAYS, STALENESS_HOURS,
|
TBW_TO_BYTES, HORIZON_DAYS, WARMING_MIN_DAYS, STALENESS_HOURS,
|
||||||
YOUNG_REGIME_DAYS, DISCLOSURES,
|
YOUNG_REGIME_DAYS, DISCLOSURES, _compute_horizon_rate,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -88,6 +88,33 @@ def _open_period(conn, start="2026-09-01T00:00:00+00:00"):
|
|||||||
ensure_period_open(conn, datetime.fromisoformat(start))
|
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, activity_intervals) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)",
|
||||||
|
(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:
|
class TestPrecedence:
|
||||||
def test_no_baseline_unavailable(self, store):
|
def test_no_baseline_unavailable(self, store):
|
||||||
_insert_segment(store)
|
_insert_segment(store)
|
||||||
@@ -127,6 +154,7 @@ class TestConfidenceRuleTable:
|
|||||||
def test_unavailable_no_baseline(self, store):
|
def test_unavailable_no_baseline(self, store):
|
||||||
_insert_segment(store)
|
_insert_segment(store)
|
||||||
_open_period(store)
|
_open_period(store)
|
||||||
|
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||||
result = compute_projection(store, _clock())
|
result = compute_projection(store, _clock())
|
||||||
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
||||||
assert any("no applicable endurance baseline" in f for f in result.contributing_facts)
|
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):
|
for i in range(20):
|
||||||
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
|
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
_insert_day(store, d, bw=0)
|
_insert_day(store, d, bw=0)
|
||||||
|
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||||
result = compute_projection(store, _clock())
|
result = compute_projection(store, _clock())
|
||||||
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
||||||
assert any("no finite projection" in f for f in result.contributing_facts)
|
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")
|
d = (datetime(2026, 9, 25) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
_insert_day(store, d, bw=1024*1024*100)
|
_insert_day(store, d, bw=1024*1024*100)
|
||||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
_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())
|
result = compute_projection(store, _clock())
|
||||||
assert result.confidence_state == ConfidenceState.LIMITED
|
assert result.confidence_state == ConfidenceState.LIMITED
|
||||||
assert any("regime only" in f and "days old" in f for f in result.contributing_facts)
|
assert any("regime only" in f and "days old" in f for f in result.contributing_facts)
|
||||||
|
|
||||||
def test_limited_degraded_identity(self, store):
|
def test_limited_degraded_identity(self, store):
|
||||||
_insert_baseline(store, tbw_tb=1.0, verified=True)
|
_insert_baseline(store, tbw_tb=1.0, verified=True)
|
||||||
|
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||||
_insert_segment(store, degraded=True)
|
_insert_segment(store, degraded=True)
|
||||||
_open_period(store)
|
_open_period(store)
|
||||||
for i in range(20):
|
for i in range(20):
|
||||||
@@ -201,6 +232,7 @@ class TestZeroRate:
|
|||||||
for i in range(20):
|
for i in range(20):
|
||||||
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
|
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
_insert_day(store, d, bw=0)
|
_insert_day(store, d, bw=0)
|
||||||
|
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||||
result = compute_projection(store, _clock())
|
result = compute_projection(store, _clock())
|
||||||
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
||||||
assert any("no finite projection from this history" in f for f in result.contributing_facts)
|
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")
|
d = (datetime(2026, 6, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
_insert_day(store, d, bw=bw)
|
_insert_day(store, d, bw=bw)
|
||||||
_insert_sample(store, "2026-09-28T12:00:00+00:00", pu=5)
|
_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))
|
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)
|
# 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
|
# 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")
|
d = (datetime(2026, 8, 29) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
_insert_day(store, d, bw=bw_high)
|
_insert_day(store, d, bw=bw_high)
|
||||||
_insert_sample(store, "2026-09-08T10:00:00+00:00", pu=5)
|
_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))
|
result = compute_projection(store, _clock(year=2026, month=9, day=8, hour=12))
|
||||||
assert result.habit_change_fact is not None
|
assert result.habit_change_fact is not None
|
||||||
assert "usage habit changed" in result.habit_change_fact
|
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")
|
d = (datetime(2026, 8, 29) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
_insert_day(store, d, bw=bw_low)
|
_insert_day(store, d, bw=bw_low)
|
||||||
_insert_sample(store, "2026-09-08T10:00:00+00:00", pu=5)
|
_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))
|
result = compute_projection(store, _clock(year=2026, month=9, day=8, hour=12))
|
||||||
assert result.habit_change_fact is not None
|
assert result.habit_change_fact is not None
|
||||||
assert "usage habit changed" in result.habit_change_fact
|
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")
|
d = (datetime(2026, 9, 25) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
_insert_day(store, d, bw=bw)
|
_insert_day(store, d, bw=bw)
|
||||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
_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())
|
result = compute_projection(store, _clock())
|
||||||
assert result.confidence_state == ConfidenceState.LIMITED
|
assert result.confidence_state == ConfidenceState.LIMITED
|
||||||
assert any("regime only" in f and "days old" in f for f in result.contributing_facts)
|
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")
|
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
_insert_day(store, d, bw=bw, coverage=0.95)
|
_insert_day(store, d, bw=bw, coverage=0.95)
|
||||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
_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())
|
result = compute_projection(store, _clock())
|
||||||
assert result.warming_fact is not None
|
assert result.warming_fact is not None
|
||||||
assert "warming up" in result.warming_fact
|
assert "warming up" in result.warming_fact
|
||||||
@@ -560,6 +597,7 @@ class TestWarmingGate:
|
|||||||
cov = 0.30 if i < 3 else 0.95
|
cov = 0.30 if i < 3 else 0.95
|
||||||
_insert_day(store, d, bw=bw, coverage=cov)
|
_insert_day(store, d, bw=bw, coverage=cov)
|
||||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
_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())
|
result = compute_projection(store, _clock())
|
||||||
assert result.warming_fact is not None
|
assert result.warming_fact is not None
|
||||||
assert "warming up" in result.warming_fact
|
assert "warming up" in result.warming_fact
|
||||||
@@ -589,6 +627,7 @@ class TestWarmingGate:
|
|||||||
cov = 0.30 if i < 3 else 0.95
|
cov = 0.30 if i < 3 else 0.95
|
||||||
_insert_day(store, d, bw=bw, coverage=cov)
|
_insert_day(store, d, bw=bw, coverage=cov)
|
||||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
_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())
|
result = compute_projection(store, _clock())
|
||||||
assert result.warming_fact is not None
|
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")
|
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
_insert_day(store, d, bw=bw, coverage=0.95)
|
_insert_day(store, d, bw=bw, coverage=0.95)
|
||||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
_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())
|
result = compute_projection(store, _clock())
|
||||||
# Should have warming fact but still render
|
# Should have warming fact but still render
|
||||||
assert result.warming_fact is not None
|
assert result.warming_fact is not None
|
||||||
@@ -614,6 +654,7 @@ class TestWarmingGate:
|
|||||||
_insert_segment(store)
|
_insert_segment(store)
|
||||||
_open_period(store)
|
_open_period(store)
|
||||||
_insert_day(store, "2026-09-28", bw=100*1024*1024)
|
_insert_day(store, "2026-09-28", bw=100*1024*1024)
|
||||||
|
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||||
result = compute_projection(store, _clock())
|
result = compute_projection(store, _clock())
|
||||||
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
||||||
assert result.headline_remaining_seconds is None
|
assert result.headline_remaining_seconds is None
|
||||||
@@ -626,6 +667,7 @@ class TestWarmingGate:
|
|||||||
for i in range(20):
|
for i in range(20):
|
||||||
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
|
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
_insert_day(store, d, bw=0)
|
_insert_day(store, d, bw=0)
|
||||||
|
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||||
result = compute_projection(store, _clock())
|
result = compute_projection(store, _clock())
|
||||||
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
||||||
assert result.headline_remaining_seconds is None
|
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")
|
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
_insert_day(store, d, bw=bw, coverage=0.95)
|
_insert_day(store, d, bw=bw, coverage=0.95)
|
||||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
_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 is 3 days after last data → staleness > 48h
|
||||||
clock = datetime(2026, 10, 3, 12, 0, 0, tzinfo=timezone.utc)
|
clock = datetime(2026, 10, 3, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
result = compute_projection(store, clock)
|
result = compute_projection(store, clock)
|
||||||
@@ -661,6 +704,7 @@ class TestStalenessDrop:
|
|||||||
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
_insert_day(store, d, bw=bw, coverage=0.95)
|
_insert_day(store, d, bw=bw, coverage=0.95)
|
||||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
_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)
|
clock = datetime(2026, 10, 3, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
result = compute_projection(store, clock)
|
result = compute_projection(store, clock)
|
||||||
assert result.staleness_fact is not None
|
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")
|
d = (datetime(2026, 9, 16) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
_insert_day(store, d, bw=bw)
|
_insert_day(store, d, bw=bw)
|
||||||
_insert_sample(store, "2026-09-20T10:00:00+00:00", pu=5)
|
_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))
|
result = compute_projection(store, _clock(year=2026, month=9, day=20, hour=12))
|
||||||
# Prior days exist in store but projection uses current segment
|
# Prior days exist in store but projection uses current segment
|
||||||
# 5 days in segment → regime_days = 5
|
# 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")
|
d = (datetime(2026, 9, 25) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
_insert_day(store, d, bw=bw)
|
_insert_day(store, d, bw=bw)
|
||||||
_insert_sample(store, "2026-09-28T10:00:00+00:00", pu=5)
|
_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))
|
result = compute_projection(store, _clock(year=2026, month=9, day=28, hour=12))
|
||||||
# Young regime (3 days) → Limited, not enough data for full confidence
|
# Young regime (3 days) → Limited, not enough data for full confidence
|
||||||
assert result.confidence_state == ConfidenceState.LIMITED
|
assert result.confidence_state == ConfidenceState.LIMITED
|
||||||
@@ -753,6 +799,7 @@ class TestSegmentBreakProjection:
|
|||||||
d = (datetime(2026, 9, 25) + timedelta(days=i)).strftime("%Y-%m-%d")
|
d = (datetime(2026, 9, 25) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
_insert_day(store, d, bw=bw)
|
_insert_day(store, d, bw=bw)
|
||||||
_insert_sample(store, "2026-09-28T10:00:00+00:00", pu=5)
|
_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))
|
result = compute_projection(store, _clock(year=2026, month=9, day=28, hour=12))
|
||||||
# Prior history quarantined; only 3 days in new segment
|
# Prior history quarantined; only 3 days in new segment
|
||||||
assert result.regime_days is not None
|
assert result.regime_days is not None
|
||||||
@@ -771,6 +818,7 @@ class TestDegradedIdentity:
|
|||||||
_insert_segment(store, identity_key=None, degraded=True)
|
_insert_segment(store, identity_key=None, degraded=True)
|
||||||
_open_period(store)
|
_open_period(store)
|
||||||
_insert_day(store, "2026-09-28", bw=100*1024*1024)
|
_insert_day(store, "2026-09-28", bw=100*1024*1024)
|
||||||
|
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||||
# No baseline → Unavailable
|
# No baseline → Unavailable
|
||||||
result = compute_projection(store, _clock())
|
result = compute_projection(store, _clock())
|
||||||
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
||||||
@@ -787,6 +835,7 @@ class TestDegradedIdentity:
|
|||||||
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
_insert_day(store, d, bw=bw, coverage=0.95)
|
_insert_day(store, d, bw=bw, coverage=0.95)
|
||||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
_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())
|
result = compute_projection(store, _clock())
|
||||||
assert result.confidence_state == ConfidenceState.LIMITED
|
assert result.confidence_state == ConfidenceState.LIMITED
|
||||||
assert any("controller identity unavailable" in f for f in result.contributing_facts)
|
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")
|
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
_insert_day(store, d, bw=bw, coverage=0.95)
|
_insert_day(store, d, bw=bw, coverage=0.95)
|
||||||
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||||
|
_insert_complete_local_days(store, "2026-09-29", 1)
|
||||||
# Stale clock (>48h)
|
# Stale clock (>48h)
|
||||||
clock = datetime(2026, 10, 5, 12, 0, 0, tzinfo=timezone.utc)
|
clock = datetime(2026, 10, 5, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
result = compute_projection(store, clock)
|
result = compute_projection(store, clock)
|
||||||
@@ -857,6 +907,7 @@ class TestIdentityChangeBlankKeys:
|
|||||||
d = (datetime(2026, 9, 21) + timedelta(days=i)).strftime("%Y-%m-%d")
|
d = (datetime(2026, 9, 21) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
_insert_day(store, d, bw=bw)
|
_insert_day(store, d, bw=bw)
|
||||||
_insert_sample(store, "2026-09-26T10:00:00+00:00", pu=5)
|
_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))
|
result = compute_projection(store, _clock(year=2026, month=9, day=26, hour=12))
|
||||||
# Prior history quarantined; only 5 days in new segment
|
# Prior history quarantined; only 5 days in new segment
|
||||||
assert result.regime_days is not None
|
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")
|
d = (datetime(2026, 9, 21) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
_insert_day(store, d, bw=bw)
|
_insert_day(store, d, bw=bw)
|
||||||
_insert_sample(store, "2026-09-26T10:00:00+00:00", pu=5)
|
_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))
|
result = compute_projection(store, _clock(year=2026, month=9, day=26, hour=12))
|
||||||
assert result.regime_days is not None
|
assert result.regime_days is not None
|
||||||
assert result.regime_days <= 5
|
assert result.regime_days <= 5
|
||||||
@@ -895,7 +947,131 @@ class TestIdentityChangeBlankKeys:
|
|||||||
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
_insert_day(store, d, bw=bw)
|
_insert_day(store, d, bw=bw)
|
||||||
_insert_sample(store, "2026-09-26T10:00:00+00:00", pu=5)
|
_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))
|
result = compute_projection(store, _clock(year=2026, month=9, day=26, hour=12))
|
||||||
# All 25 days in same segment (equal blanks continue)
|
# All 25 days in same segment (equal blanks continue)
|
||||||
assert result.regime_days is not None
|
assert result.regime_days is not None
|
||||||
assert result.regime_days >= 20 # Most of the history
|
assert result.regime_days >= 20 # Most of the history
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# Issue #76: Evidence-anchored projection rates
|
||||||
|
# Scenario windows anchored at latest evidence endpoint T
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TestEvidenceAnchoredHorizons:
|
||||||
|
"""Issue #76: Scenario windows anchored at latest published usage-evidence
|
||||||
|
endpoint T with exact trailing 7/28/90×86400-second starts."""
|
||||||
|
|
||||||
|
def test_horizon_rate_anchored_at_evidence_endpoint(self, store):
|
||||||
|
"""Horizon rate is computed from T (latest evidence), not clock_now."""
|
||||||
|
_insert_baseline(store, tbw_tb=10.0, verified=True)
|
||||||
|
_insert_segment(store, opened_at="2026-09-01T00:00:00+00:00")
|
||||||
|
_open_period(store, start="2026-09-01T00:00:00+00:00")
|
||||||
|
bw = 100 * 1024 * 1024
|
||||||
|
# 30 days of data ending Sep 29
|
||||||
|
for i in range(30):
|
||||||
|
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_day(store, d, bw=bw)
|
||||||
|
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||||
|
# Clock is Oct 1, but T is Sep 29 (latest evidence endpoint)
|
||||||
|
clock = datetime(2026, 10, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
result = compute_projection(store, clock)
|
||||||
|
# 7-day horizon should be anchored at Sep 29, not Oct 1
|
||||||
|
if result.scenario_range and 7 in result.scenario_range.rates:
|
||||||
|
# Rate should be based on Sep 23-29, not Sep 25-Oct 1
|
||||||
|
assert result.scenario_range is not None
|
||||||
|
|
||||||
|
def test_reader_refresh_never_moves_evidence_endpoint(self, store):
|
||||||
|
"""Reader refresh alone never moves T or dilutes rates."""
|
||||||
|
_insert_baseline(store, tbw_tb=10.0, verified=True)
|
||||||
|
_insert_segment(store, opened_at="2026-09-01T00:00:00+00:00")
|
||||||
|
_open_period(store, start="2026-09-01T00:00:00+00:00")
|
||||||
|
bw = 100 * 1024 * 1024
|
||||||
|
for i in range(30):
|
||||||
|
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_day(store, d, bw=bw)
|
||||||
|
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||||
|
# Two reads at different clock times
|
||||||
|
clock1 = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
clock2 = datetime(2026, 10, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
r1 = compute_projection(store, clock1)
|
||||||
|
r2 = compute_projection(store, clock2)
|
||||||
|
# Both should produce identical scenario rates (anchored at T, not clock)
|
||||||
|
if r1.scenario_range and r2.scenario_range:
|
||||||
|
assert r1.scenario_range.rates == r2.scenario_range.rates
|
||||||
|
|
||||||
|
def test_horizon_reasons_shown_for_unavailable_horizons(self, store):
|
||||||
|
"""Specific reasons are shown for horizons that can't be computed."""
|
||||||
|
_insert_baseline(store, tbw_tb=10.0, verified=True)
|
||||||
|
_insert_segment(store, opened_at="2026-09-20T00:00:00+00:00")
|
||||||
|
_open_period(store, start="2026-09-20T00:00:00+00:00")
|
||||||
|
bw = 100 * 1024 * 1024
|
||||||
|
# Only 10 days of data
|
||||||
|
for i in range(10):
|
||||||
|
d = (datetime(2026, 9, 20) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_day(store, d, bw=bw)
|
||||||
|
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||||
|
result = compute_projection(store, _clock())
|
||||||
|
# 7-day horizon should be available, 28 and 90 should have reasons
|
||||||
|
if result.scenario_range:
|
||||||
|
assert 7 in result.scenario_range.rates
|
||||||
|
if 28 in result.scenario_range.horizon_reasons:
|
||||||
|
assert "starts before earliest data" in result.scenario_range.horizon_reasons[28]
|
||||||
|
if 90 in result.scenario_range.horizon_reasons:
|
||||||
|
assert "starts before earliest data" in result.scenario_range.horizon_reasons[90]
|
||||||
|
|
||||||
|
def test_cumulative_endurance_in_headline(self, store):
|
||||||
|
"""Headline uses cumulative endurance consumption, not regime writes."""
|
||||||
|
_insert_baseline(store, tbw_tb=10.0, verified=True)
|
||||||
|
_insert_segment(store, opened_at="2026-09-01T00:00:00+00:00")
|
||||||
|
_open_period(store, start="2026-09-01T00:00:00+00:00")
|
||||||
|
bw = 100 * 1024 * 1024
|
||||||
|
for i in range(30):
|
||||||
|
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_day(store, d, bw=bw)
|
||||||
|
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||||
|
result = compute_projection(store, _clock())
|
||||||
|
# Headline should be computed with cumulative bytes
|
||||||
|
if result.headline_remaining_seconds is not None:
|
||||||
|
# E_baseline = 10 TB = 10e12 bytes
|
||||||
|
# cumulative_bytes = 30 * 100 * 1024 * 1024
|
||||||
|
# rate = cumulative_bytes / wall_clock
|
||||||
|
# headline = (E_baseline - cumulative_bytes) / rate
|
||||||
|
E_baseline = 10.0 * TBW_TO_BYTES
|
||||||
|
cumulative_bytes = 30 * bw
|
||||||
|
assert result.headline_remaining_seconds >= 0
|
||||||
|
|
||||||
|
def test_zero_boundary_delta_returns_zero(self, store):
|
||||||
|
"""Zero monotonic delta proves zero over its represented subspan."""
|
||||||
|
_insert_baseline(store, tbw_tb=10.0, verified=True)
|
||||||
|
_insert_segment(store, opened_at="2026-09-01T00:00:00+00:00")
|
||||||
|
_open_period(store, start="2026-09-01T00:00:00+00:00")
|
||||||
|
# Days with zero bytes written
|
||||||
|
for i in range(30):
|
||||||
|
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_day(store, d, bw=0)
|
||||||
|
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||||
|
result = compute_projection(store, _clock())
|
||||||
|
# Zero rate should result in UNSUPPORTED
|
||||||
|
assert result.confidence_state == ConfidenceState.UNSUPPORTED
|
||||||
|
assert result.headline_remaining_seconds is None
|
||||||
|
|
||||||
|
def test_noon_endpoint_same_as_midnight(self, store):
|
||||||
|
"""Noon endpoint produces same rates as midnight endpoint."""
|
||||||
|
_insert_baseline(store, tbw_tb=10.0, verified=True)
|
||||||
|
_insert_segment(store, opened_at="2026-09-01T00:00:00+00:00")
|
||||||
|
_open_period(store, start="2026-09-01T00:00:00+00:00")
|
||||||
|
bw = 100 * 1024 * 1024
|
||||||
|
for i in range(30):
|
||||||
|
d = (datetime(2026, 9, 1) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
|
_insert_day(store, d, bw=bw)
|
||||||
|
_insert_sample(store, "2026-09-30T10:00:00+00:00", pu=5)
|
||||||
|
# Both reads should produce same scenario rates
|
||||||
|
clock1 = datetime(2026, 9, 30, 0, 0, 0, tzinfo=timezone.utc)
|
||||||
|
clock2 = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
r1 = compute_projection(store, clock1)
|
||||||
|
r2 = compute_projection(store, clock2)
|
||||||
|
if r1.scenario_range and r2.scenario_range:
|
||||||
|
assert r1.scenario_range.rates == r2.scenario_range.rates
|
||||||
|
|||||||
+64
-11
@@ -1,7 +1,9 @@
|
|||||||
"""Raw sample pruning tests.
|
"""Raw sample pruning tests.
|
||||||
|
|
||||||
Spec §3.4, ST-5: Raw samples pruned to 14 days; hour observations and
|
Spec §3.4, ST-5: Raw samples pruned to 14 days; hour observations and
|
||||||
day aggregates retained indefinitely.
|
day aggregates are retained indefinitely.
|
||||||
|
|
||||||
|
Issue #74: Boundary anchors required for successor evidence are retained.
|
||||||
"""
|
"""
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import sys
|
import sys
|
||||||
@@ -25,9 +27,14 @@ def store_conn(tmp_path: Path):
|
|||||||
|
|
||||||
|
|
||||||
def _insert_sample(conn, ts_iso, device="/dev/nvme0"):
|
def _insert_sample(conn, ts_iso, device="/dev/nvme0"):
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO controller_segments "
|
||||||
|
"(id, opened_at, identity_key) VALUES (1, '2026-01-01T00:00:00+00:00', 'test')"
|
||||||
|
)
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO samples (ts, device, data_units_written, data_units_read, "
|
"INSERT INTO samples (ts, device, data_units_written, data_units_read, "
|
||||||
" bytes_written, bytes_read, percentage_used) VALUES (?, ?, 0, 0, 0, 0, 0)",
|
" bytes_written, bytes_read, percentage_used, segment_id, local_tz) "
|
||||||
|
"VALUES (?, ?, 0, 0, 0, 0, 0, 1, 'UTC')",
|
||||||
(ts_iso, device),
|
(ts_iso, device),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
@@ -48,30 +55,30 @@ class TestPruneOldSamples:
|
|||||||
cursor = store_conn.execute("SELECT COUNT(*) FROM samples")
|
cursor = store_conn.execute("SELECT COUNT(*) FROM samples")
|
||||||
assert cursor.fetchone()[0] == 1
|
assert cursor.fetchone()[0] == 1
|
||||||
|
|
||||||
def test_removes_old_samples(self, store_conn):
|
def test_keeps_old_samples_without_replacement_evidence(self, store_conn):
|
||||||
now = datetime(2026, 9, 15, 12, 0, 0, tzinfo=timezone.utc)
|
now = datetime(2026, 9, 15, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
# Insert samples at 10, 14, and 15 days ago
|
# Old samples stay until UTC and local-day evidence replace them.
|
||||||
for days_ago in [10, 14, 15]:
|
for days_ago in [10, 14, 15]:
|
||||||
ts = (now - timedelta(days=days_ago)).isoformat()
|
ts = (now - timedelta(days=days_ago)).isoformat()
|
||||||
_insert_sample(store_conn, ts)
|
_insert_sample(store_conn, ts)
|
||||||
|
|
||||||
pruned = prune_old_samples(store_conn, now, retention_days=14)
|
pruned = prune_old_samples(store_conn, now, retention_days=14)
|
||||||
assert pruned == 1 # Only the 15-day-old sample removed
|
assert pruned == 0
|
||||||
|
|
||||||
cursor = store_conn.execute("SELECT COUNT(*) FROM samples")
|
cursor = store_conn.execute("SELECT COUNT(*) FROM samples")
|
||||||
assert cursor.fetchone()[0] == 2
|
assert cursor.fetchone()[0] == 3
|
||||||
|
|
||||||
def test_removes_many_old_samples(self, store_conn):
|
def test_keeps_many_old_samples_without_replacement_evidence(self, store_conn):
|
||||||
now = datetime(2026, 9, 15, 12, 0, 0, tzinfo=timezone.utc)
|
now = datetime(2026, 9, 15, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
for days_ago in range(1, 30):
|
for days_ago in range(1, 30):
|
||||||
ts = (now - timedelta(days=days_ago)).isoformat()
|
ts = (now - timedelta(days=days_ago)).isoformat()
|
||||||
_insert_sample(store_conn, ts)
|
_insert_sample(store_conn, ts)
|
||||||
|
|
||||||
pruned = prune_old_samples(store_conn, now, retention_days=14)
|
pruned = prune_old_samples(store_conn, now, retention_days=14)
|
||||||
assert pruned == 15 # Days 15-29 removed
|
assert pruned == 0
|
||||||
|
|
||||||
cursor = store_conn.execute("SELECT COUNT(*) FROM samples")
|
cursor = store_conn.execute("SELECT COUNT(*) FROM samples")
|
||||||
assert cursor.fetchone()[0] == 14 # Days 1-14 kept
|
assert cursor.fetchone()[0] == 29
|
||||||
|
|
||||||
def test_empty_store_no_error(self, store_conn):
|
def test_empty_store_no_error(self, store_conn):
|
||||||
now = datetime(2026, 9, 15, 12, 0, 0, tzinfo=timezone.utc)
|
now = datetime(2026, 9, 15, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
@@ -82,6 +89,7 @@ class TestPruneOldSamples:
|
|||||||
"""Hour observations are retained indefinitely."""
|
"""Hour observations are retained indefinitely."""
|
||||||
now = datetime(2026, 9, 15, 12, 0, 0, tzinfo=timezone.utc)
|
now = datetime(2026, 9, 15, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
# Insert an old sample and a recent sample
|
# Insert an old sample and a recent sample
|
||||||
|
# The old sample is a boundary anchor (needed for derivation)
|
||||||
_insert_sample(store_conn, (now - timedelta(days=20)).isoformat())
|
_insert_sample(store_conn, (now - timedelta(days=20)).isoformat())
|
||||||
_insert_sample(store_conn, (now - timedelta(days=1)).isoformat())
|
_insert_sample(store_conn, (now - timedelta(days=1)).isoformat())
|
||||||
|
|
||||||
@@ -95,10 +103,55 @@ class TestPruneOldSamples:
|
|||||||
|
|
||||||
prune_old_samples(store_conn, now, retention_days=14)
|
prune_old_samples(store_conn, now, retention_days=14)
|
||||||
|
|
||||||
# Sample removed
|
# Old sample is retained as boundary anchor (needed for derivation)
|
||||||
cursor = store_conn.execute("SELECT COUNT(*) FROM samples")
|
cursor = store_conn.execute("SELECT COUNT(*) FROM samples")
|
||||||
assert cursor.fetchone()[0] == 1
|
assert cursor.fetchone()[0] == 2
|
||||||
|
|
||||||
# Hour observation retained
|
# Hour observation retained
|
||||||
cursor = store_conn.execute("SELECT COUNT(*) FROM hour_observations")
|
cursor = store_conn.execute("SELECT COUNT(*) FROM hour_observations")
|
||||||
assert cursor.fetchone()[0] == 1
|
assert cursor.fetchone()[0] == 1
|
||||||
|
|
||||||
|
def test_boundary_anchor_retained(self, store_conn):
|
||||||
|
"""Boundary anchors required for derivation are retained."""
|
||||||
|
now = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
# Old sample before boundary (2026-09-14T23:55:00)
|
||||||
|
# is 15 days and 0.75 hours old (before cutoff at 2026-09-16T12:00:00)
|
||||||
|
_insert_sample(store_conn, "2026-09-14T23:55:00+00:00", 1000000)
|
||||||
|
|
||||||
|
# Sample after boundary (2026-09-16T12:30:00)
|
||||||
|
# is 13 days and 23.5 hours old (within retention)
|
||||||
|
_insert_sample(store_conn, "2026-09-16T12:30:00+00:00", 2000000)
|
||||||
|
|
||||||
|
# Run pruning
|
||||||
|
pruned = prune_old_samples(store_conn, now, retention_days=14)
|
||||||
|
|
||||||
|
# The boundary anchor should be retained
|
||||||
|
cursor = store_conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM samples WHERE ts = '2026-09-14T23:55:00+00:00'"
|
||||||
|
)
|
||||||
|
assert cursor.fetchone()[0] == 1
|
||||||
|
|
||||||
|
def test_hour_rows_alone_do_not_replace_local_day_evidence(self, store_conn):
|
||||||
|
"""An hour row alone cannot authorize source-sample deletion."""
|
||||||
|
now = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
# Old sample with derived interval
|
||||||
|
_insert_sample(store_conn, "2026-09-10T10:00:00+00:00", 1000000)
|
||||||
|
_insert_sample(store_conn, "2026-09-10T10:30:00+00:00", 2000000)
|
||||||
|
|
||||||
|
# Hour observation exists for the interval
|
||||||
|
store_conn.execute(
|
||||||
|
"INSERT INTO hour_observations (hour, active_seconds, bytes_written_delta, bytes_read_delta, sample_count, coverage) "
|
||||||
|
"VALUES ('2026-09-10T10:00:00+00:00', 3600, 1000000, 0, 1, 1.0)",
|
||||||
|
)
|
||||||
|
store_conn.commit()
|
||||||
|
|
||||||
|
# Run pruning
|
||||||
|
pruned = prune_old_samples(store_conn, now, retention_days=14)
|
||||||
|
|
||||||
|
# Keep the source samples until local-day replacement evidence exists.
|
||||||
|
cursor = store_conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM samples WHERE ts = '2026-09-10T10:00:00+00:00'"
|
||||||
|
)
|
||||||
|
assert cursor.fetchone()[0] == 1
|
||||||
|
|||||||
@@ -0,0 +1,397 @@
|
|||||||
|
"""Repair and retention tests (issue #74).
|
||||||
|
|
||||||
|
Tests the idempotent, safe repair of hour observations and day aggregates
|
||||||
|
from surviving raw samples, boundary anchor retention, and legacy summary
|
||||||
|
handling at actual precision.
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||||
|
|
||||||
|
from fenris.store import init_store
|
||||||
|
from fenris.repair import (
|
||||||
|
repair_derivation,
|
||||||
|
is_repair_in_progress,
|
||||||
|
get_repair_status,
|
||||||
|
)
|
||||||
|
from fenris.pruning import prune_old_samples, needs_boundary_anchor
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def store_conn(tmp_path: Path):
|
||||||
|
"""Create a fresh store for each test."""
|
||||||
|
db_path = tmp_path / "test.db"
|
||||||
|
conn = init_store(db_path)
|
||||||
|
yield conn
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_sample(conn, ts_iso, bytes_written, bytes_read=0, power_on_hours=100,
|
||||||
|
device="/dev/nvme0", segment_id=None):
|
||||||
|
"""Insert a raw sample."""
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO samples
|
||||||
|
(ts, device, bytes_written, bytes_read, power_on_hours,
|
||||||
|
data_units_written, data_units_read, segment_id)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(ts_iso, device, bytes_written, bytes_read, power_on_hours,
|
||||||
|
bytes_written // 512000, bytes_read // 512000, segment_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_hour(conn, hour_iso, bytes_written_delta=0, active_seconds=3600,
|
||||||
|
idle_seconds=0, powered_off_seconds=0, unknown_seconds=0,
|
||||||
|
sample_count=1, coverage=1.0):
|
||||||
|
"""Insert an hour observation."""
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO hour_observations
|
||||||
|
(hour, active_seconds, idle_seconds, powered_off_seconds, unknown_seconds,
|
||||||
|
bytes_written_delta, bytes_read_delta, sample_count, coverage)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(hour_iso, active_seconds, idle_seconds, powered_off_seconds, unknown_seconds,
|
||||||
|
bytes_written_delta, 0, sample_count, coverage),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_day_aggregate(conn, day, bytes_written_delta=0, coverage=1.0):
|
||||||
|
"""Insert a day aggregate."""
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO day_aggregates
|
||||||
|
(day, active_seconds, bytes_written_delta, coverage, sample_count)
|
||||||
|
VALUES (?, 3600, ?, ?, 1)""",
|
||||||
|
(day, bytes_written_delta, coverage),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _open_period(conn, start_iso, end_iso=None, end_cause=None):
|
||||||
|
"""Insert a monitoring period."""
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO monitoring_periods (started_at, ended_at, end_cause)
|
||||||
|
VALUES (?, ?, ?)""",
|
||||||
|
(start_iso, end_iso, end_cause),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC1: Normal collection, legacy import and recovery use same evidence rules
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestRepairUsesSameEvidenceRules:
|
||||||
|
"""AC1: Repair derives only surviving supported evidence, transactionally
|
||||||
|
and idempotently."""
|
||||||
|
|
||||||
|
def test_repair_idempotent_on_empty_store(self, store_conn):
|
||||||
|
"""Repair on empty store succeeds and does nothing."""
|
||||||
|
result = repair_derivation(store_conn)
|
||||||
|
assert result.ok is True
|
||||||
|
assert result.hours_created == 0
|
||||||
|
assert result.days_created == 0
|
||||||
|
|
||||||
|
def test_repair_idempotent_on_fully_derived(self, store_conn):
|
||||||
|
"""Repair on store with existing derived data does not duplicate."""
|
||||||
|
now = datetime(2026, 9, 15, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
_open_period(store_conn, "2026-09-14T00:00:00+00:00")
|
||||||
|
|
||||||
|
# Insert samples that span an hour
|
||||||
|
_insert_sample(store_conn, "2026-09-14T10:00:00+00:00", 1000000)
|
||||||
|
_insert_sample(store_conn, "2026-09-14T10:30:00+00:00", 2000000)
|
||||||
|
|
||||||
|
# Pre-existing hour observation for the 10:00 hour
|
||||||
|
# This hour observation captures the interval [10:00, 10:30]
|
||||||
|
_insert_hour(store_conn, "2026-09-14T10:00:00+00:00",
|
||||||
|
bytes_written_delta=1000000)
|
||||||
|
_insert_day_aggregate(store_conn, "2026-09-14",
|
||||||
|
bytes_written_delta=1000000)
|
||||||
|
|
||||||
|
# Run repair
|
||||||
|
result = repair_derivation(store_conn)
|
||||||
|
|
||||||
|
# Should not create new observations (already exist)
|
||||||
|
assert result.hours_created == 0
|
||||||
|
assert result.days_created == 0
|
||||||
|
|
||||||
|
# Verify no duplicates
|
||||||
|
cursor = store_conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM hour_observations WHERE hour = '2026-09-14T10:00:00+00:00'"
|
||||||
|
)
|
||||||
|
assert cursor.fetchone()[0] == 1
|
||||||
|
|
||||||
|
def test_repair_preserves_import_markers(self, store_conn):
|
||||||
|
"""Repair does not remove legacy import markers."""
|
||||||
|
# Set legacy import marker
|
||||||
|
store_conn.execute(
|
||||||
|
"INSERT INTO store_metadata (key, value) VALUES ('legacy_imported', 'true')"
|
||||||
|
)
|
||||||
|
store_conn.commit()
|
||||||
|
|
||||||
|
# Run repair
|
||||||
|
result = repair_derivation(store_conn)
|
||||||
|
|
||||||
|
# Verify marker preserved
|
||||||
|
cursor = store_conn.execute(
|
||||||
|
"SELECT value FROM store_metadata WHERE key = 'legacy_imported'"
|
||||||
|
)
|
||||||
|
assert cursor.fetchone()[0] == "true"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC2: Rerunning or interrupting repair produces no duplicates
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestRepairIdempotency:
|
||||||
|
"""AC2: No duplicated intervals or totals from repeated repair."""
|
||||||
|
|
||||||
|
def test_repair_no_duplicate_hours(self, store_conn):
|
||||||
|
"""Running repair twice produces no duplicate hour observations."""
|
||||||
|
_open_period(store_conn, "2026-09-14T00:00:00+00:00")
|
||||||
|
_insert_sample(store_conn, "2026-09-14T10:00:00+00:00", 1000000)
|
||||||
|
_insert_sample(store_conn, "2026-09-14T10:30:00+00:00", 2000000)
|
||||||
|
|
||||||
|
# First repair
|
||||||
|
result1 = repair_derivation(store_conn)
|
||||||
|
assert result1.hours_created == 1
|
||||||
|
|
||||||
|
# Second repair
|
||||||
|
result2 = repair_derivation(store_conn)
|
||||||
|
assert result2.hours_created == 0 # No new hours
|
||||||
|
|
||||||
|
# Verify only one hour observation
|
||||||
|
cursor = store_conn.execute("SELECT COUNT(*) FROM hour_observations")
|
||||||
|
assert cursor.fetchone()[0] == 1
|
||||||
|
|
||||||
|
def test_repair_no_duplicate_days(self, store_conn):
|
||||||
|
"""Running repair twice produces no duplicate day aggregates."""
|
||||||
|
_open_period(store_conn, "2026-09-14T00:00:00+00:00")
|
||||||
|
_insert_sample(store_conn, "2026-09-14T10:00:00+00:00", 1000000)
|
||||||
|
_insert_sample(store_conn, "2026-09-14T10:30:00+00:00", 2000000)
|
||||||
|
|
||||||
|
# First repair
|
||||||
|
result1 = repair_derivation(store_conn)
|
||||||
|
assert result1.days_created == 1
|
||||||
|
|
||||||
|
# Second repair
|
||||||
|
result2 = repair_derivation(store_conn)
|
||||||
|
assert result2.days_created == 0 # No new days
|
||||||
|
|
||||||
|
# Verify only one day aggregate
|
||||||
|
cursor = store_conn.execute("SELECT COUNT(*) FROM day_aggregates")
|
||||||
|
assert cursor.fetchone()[0] == 1
|
||||||
|
|
||||||
|
def test_interrupted_repair_preserves_evidence(self, store_conn):
|
||||||
|
"""If repair fails, prior valid history is preserved."""
|
||||||
|
_open_period(store_conn, "2026-09-14T00:00:00+00:00")
|
||||||
|
|
||||||
|
# Insert valid existing data
|
||||||
|
_insert_hour(store_conn, "2026-09-14T10:00:00+00:00",
|
||||||
|
bytes_written_delta=500000)
|
||||||
|
_insert_day_aggregate(store_conn, "2026-09-14",
|
||||||
|
bytes_written_delta=500000)
|
||||||
|
|
||||||
|
# Run repair (should succeed but not modify existing valid data)
|
||||||
|
result = repair_derivation(store_conn)
|
||||||
|
assert result.ok is True
|
||||||
|
|
||||||
|
# Verify existing data preserved
|
||||||
|
cursor = store_conn.execute(
|
||||||
|
"SELECT bytes_written_delta FROM hour_observations "
|
||||||
|
"WHERE hour = '2026-09-14T10:00:00+00:00'"
|
||||||
|
)
|
||||||
|
assert cursor.fetchone()[0] == 500000
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC3: Boundary anchor retention
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestBoundaryAnchorRetention:
|
||||||
|
"""AC3: Prune samples only after durable derivation; retain anchors."""
|
||||||
|
|
||||||
|
def test_needs_boundary_anchor_sample(self, store_conn):
|
||||||
|
"""Sample before 14-day boundary is needed for derivation."""
|
||||||
|
now = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
# Sample just before 14-day boundary (2026-09-15T23:55:00)
|
||||||
|
# is 14 days and 0.75 hours old (before cutoff at 2026-09-16T12:00:00)
|
||||||
|
_insert_sample(store_conn, "2026-09-15T23:55:00+00:00", 1000000)
|
||||||
|
|
||||||
|
# Sample just after boundary (2026-09-16T12:30:00)
|
||||||
|
# is 13 days and 23.5 hours old (within retention)
|
||||||
|
_insert_sample(store_conn, "2026-09-16T12:30:00+00:00", 2000000)
|
||||||
|
|
||||||
|
# The sample at 2026-09-15 is a boundary anchor because
|
||||||
|
# the interval spans the retention boundary
|
||||||
|
assert needs_boundary_anchor(store_conn, "2026-09-15T23:55:00+00:00", now)
|
||||||
|
|
||||||
|
def test_hour_only_derivation_does_not_replace_local_day_evidence(self, store_conn):
|
||||||
|
"""UTC hour evidence alone does not make a sample safe to prune."""
|
||||||
|
now = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
# Insert sample and fully derive its interval
|
||||||
|
_insert_sample(store_conn, "2026-09-14T10:00:00+00:00", 1000000)
|
||||||
|
_insert_sample(store_conn, "2026-09-14T10:30:00+00:00", 2000000)
|
||||||
|
|
||||||
|
# Hour observation already exists for this interval
|
||||||
|
_insert_hour(store_conn, "2026-09-14T10:00:00+00:00",
|
||||||
|
bytes_written_delta=1000000)
|
||||||
|
|
||||||
|
assert needs_boundary_anchor(store_conn, "2026-09-14T10:00:00+00:00", now)
|
||||||
|
|
||||||
|
def test_pruning_retains_boundary_anchors(self, store_conn):
|
||||||
|
"""Pruning keeps samples needed as boundary anchors."""
|
||||||
|
now = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
# Old sample before boundary (2026-09-14T23:55:00)
|
||||||
|
# is 15 days and 0.75 hours old (before cutoff at 2026-09-16T12:00:00)
|
||||||
|
_insert_sample(store_conn, "2026-09-14T23:55:00+00:00", 1000000)
|
||||||
|
|
||||||
|
# Sample after boundary (2026-09-16T12:30:00)
|
||||||
|
# is 13 days and 23.5 hours old (within retention)
|
||||||
|
_insert_sample(store_conn, "2026-09-16T12:30:00+00:00", 2000000)
|
||||||
|
|
||||||
|
# Recent sample
|
||||||
|
_insert_sample(store_conn, "2026-09-29T12:00:00+00:00", 3000000)
|
||||||
|
|
||||||
|
# Run pruning
|
||||||
|
pruned = prune_old_samples(store_conn, now, retention_days=14)
|
||||||
|
|
||||||
|
# The boundary anchor should be retained
|
||||||
|
cursor = store_conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM samples WHERE ts = '2026-09-14T23:55:00+00:00'"
|
||||||
|
)
|
||||||
|
assert cursor.fetchone()[0] == 1
|
||||||
|
|
||||||
|
def test_pruning_keeps_samples_without_local_day_replacement(self, store_conn):
|
||||||
|
"""Pruning keeps samples when only the UTC-hour row exists."""
|
||||||
|
now = datetime(2026, 9, 30, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
# Old sample with derived interval
|
||||||
|
_insert_sample(store_conn, "2026-09-10T10:00:00+00:00", 1000000)
|
||||||
|
_insert_sample(store_conn, "2026-09-10T10:30:00+00:00", 2000000)
|
||||||
|
|
||||||
|
# Hour observation exists for the interval
|
||||||
|
_insert_hour(store_conn, "2026-09-10T10:00:00+00:00",
|
||||||
|
bytes_written_delta=1000000)
|
||||||
|
|
||||||
|
# Run pruning
|
||||||
|
pruned = prune_old_samples(store_conn, now, retention_days=14)
|
||||||
|
|
||||||
|
# The source remains until local-day evidence is also durable.
|
||||||
|
cursor = store_conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM samples WHERE ts = '2026-09-10T10:00:00+00:00'"
|
||||||
|
)
|
||||||
|
assert cursor.fetchone()[0] == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC4: Legacy day-only summaries retain actual precision
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestLegacySummaryPrecision:
|
||||||
|
"""AC4: Legacy summaries at actual precision, no interpolation."""
|
||||||
|
|
||||||
|
def test_legacy_summary_not_reconstructed(self, store_conn):
|
||||||
|
"""Legacy day-only summaries are not interpolated to hour detail."""
|
||||||
|
# Insert a legacy-style day aggregate without hour observations
|
||||||
|
_insert_day_aggregate(store_conn, "2026-08-01",
|
||||||
|
bytes_written_delta=5000000)
|
||||||
|
|
||||||
|
# Run repair
|
||||||
|
result = repair_derivation(store_conn)
|
||||||
|
|
||||||
|
# Should not create hour observations for legacy day
|
||||||
|
cursor = store_conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM hour_observations WHERE hour LIKE '2026-08-01%'"
|
||||||
|
)
|
||||||
|
assert cursor.fetchone()[0] == 0
|
||||||
|
|
||||||
|
def test_legacy_summary_no_double_counting(self, store_conn):
|
||||||
|
"""Legacy summaries and derived intervals don't double-count."""
|
||||||
|
# Insert legacy day aggregate
|
||||||
|
_insert_day_aggregate(store_conn, "2026-08-01",
|
||||||
|
bytes_written_delta=5000000)
|
||||||
|
|
||||||
|
# Run repair
|
||||||
|
result = repair_derivation(store_conn)
|
||||||
|
|
||||||
|
# Day aggregate should not be modified
|
||||||
|
cursor = store_conn.execute(
|
||||||
|
"SELECT bytes_written_delta FROM day_aggregates WHERE day = '2026-08-01'"
|
||||||
|
)
|
||||||
|
assert cursor.fetchone()[0] == 5000000
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC5: Status distinguishes evidence states
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestStatusEvidenceDistingushing:
|
||||||
|
"""AC5: Read-only views distinguish evidence states."""
|
||||||
|
|
||||||
|
def test_repair_status_available(self, store_conn):
|
||||||
|
"""Repair status is available for read-only views."""
|
||||||
|
status = get_repair_status(store_conn)
|
||||||
|
assert hasattr(status, 'last_repair')
|
||||||
|
assert hasattr(status, 'repair_in_progress')
|
||||||
|
assert hasattr(status, 'hours_derived')
|
||||||
|
assert hasattr(status, 'days_derived')
|
||||||
|
|
||||||
|
def test_repair_in_progress_flag(self, store_conn):
|
||||||
|
"""Repair in progress flag is trackable."""
|
||||||
|
assert not is_repair_in_progress(store_conn)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AC6: Migration then collection then reader consumption
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestMigrationCollectionReader:
|
||||||
|
"""AC6: End-to-end migration, collection, and reader consumption."""
|
||||||
|
|
||||||
|
def test_store_with_raw_evidence_and_summaries(self, store_conn):
|
||||||
|
"""Store with raw evidence and old summaries works correctly."""
|
||||||
|
# Set up store with mixed data
|
||||||
|
_open_period(store_conn, "2026-08-01T00:00:00+00:00")
|
||||||
|
|
||||||
|
# Old day-only summary (legacy)
|
||||||
|
_insert_day_aggregate(store_conn, "2026-08-01",
|
||||||
|
bytes_written_delta=5000000)
|
||||||
|
|
||||||
|
# Recent raw samples
|
||||||
|
_insert_sample(store_conn, "2026-09-14T10:00:00+00:00", 1000000)
|
||||||
|
_insert_sample(store_conn, "2026-09-14T10:30:00+00:00", 2000000)
|
||||||
|
|
||||||
|
# Run repair
|
||||||
|
result = repair_derivation(store_conn)
|
||||||
|
assert result.ok is True
|
||||||
|
|
||||||
|
# Verify legacy summary preserved
|
||||||
|
cursor = store_conn.execute(
|
||||||
|
"SELECT bytes_written_delta FROM day_aggregates WHERE day = '2026-08-01'"
|
||||||
|
)
|
||||||
|
assert cursor.fetchone()[0] == 5000000
|
||||||
|
|
||||||
|
# Verify new hour observation created
|
||||||
|
cursor = store_conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM hour_observations WHERE hour LIKE '2026-09-14%'"
|
||||||
|
)
|
||||||
|
assert cursor.fetchone()[0] == 1
|
||||||
|
|
||||||
|
def test_concurrent_reader_consistency(self, store_conn):
|
||||||
|
"""Reader sees consistent snapshot during repair."""
|
||||||
|
# This is more of a documentation test - SQLite WAL mode handles this
|
||||||
|
# We verify the store is in WAL mode
|
||||||
|
cursor = store_conn.execute("PRAGMA journal_mode")
|
||||||
|
assert cursor.fetchone()[0] == "wal"
|
||||||
@@ -259,8 +259,8 @@ class TestKeyCeremonyDoc:
|
|||||||
|
|
||||||
def test_documents_private_key_storage(self):
|
def test_documents_private_key_storage(self):
|
||||||
content = self._doc_content()
|
content = self._doc_content()
|
||||||
assert "password manager" in content.lower(), \
|
assert "gitea repository actions secret `gpg_private_key`" in content.lower(), \
|
||||||
"Must document that private key lives in password manager"
|
"Must document that Gitea Actions stores the private signing key"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -622,6 +622,7 @@ class TestProjectionInStatus:
|
|||||||
from fenris.status import get_status
|
from fenris.status import get_status
|
||||||
|
|
||||||
nonexistent = tmp_path / "nonexistent.db"
|
nonexistent = tmp_path / "nonexistent.db"
|
||||||
|
nonexistent.write_bytes(b"not a SQLite database")
|
||||||
now = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
now = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
with patch("fenris.status.query_service_state", return_value={
|
with patch("fenris.status.query_service_state", return_value={
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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("#live-render").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")
|
||||||
@@ -118,12 +118,12 @@ class TestMigrateToLatest:
|
|||||||
assert migrate_to_latest(db) == 0
|
assert migrate_to_latest(db) == 0
|
||||||
|
|
||||||
def test_migrates_intermediate_version(self, tmp_path):
|
def test_migrates_intermediate_version(self, tmp_path):
|
||||||
"""Store at version 1 with SCHEMA_VERSION=1 → 0 steps (current)."""
|
"""Store at version SCHEMA_VERSION-1 → 1 step to current."""
|
||||||
|
from fenris.store import SCHEMA_VERSION
|
||||||
db = tmp_path / "observations.db"
|
db = tmp_path / "observations.db"
|
||||||
_make_store(db, version=1)
|
_make_store(db, version=SCHEMA_VERSION - 1)
|
||||||
# SCHEMA_VERSION is 1, so version 1 is current
|
|
||||||
steps = migrate_to_latest(db)
|
steps = migrate_to_latest(db)
|
||||||
assert steps == 0
|
assert steps == 1
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user