Compare commits
3
Commits
4a7661d81c
...
d01df6468f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d01df6468f | ||
|
|
7bbe5cede7 | ||
|
|
bb5bc9a72e |
@@ -189,6 +189,14 @@ No interval key exists in `/etc/fenris/fenris.conf`. Cadence is a systemd concer
|
||||
| `fenris start` / `stop` / `run` | Rejected with a one-line migration pointer — never aliased. |
|
||||
| `fenris --device` | Rejected with a pointer to the configuration file. |
|
||||
|
||||
## Reading the dashboard
|
||||
|
||||
`fenris` opens the TUI dashboard.
|
||||
|
||||
- **Continuity** — the service strip's continuity line (and `fenris status`) reports whether monitoring survives reboots: `monitoring: active in background · persists across reboots`, or `monitoring: does not start on next boot`.
|
||||
- **Paused vs. quit** — a full-width `monitoring: paused — deliberate disable` block means collection is stopped (`fenris monitor pause`); resume with `fenris monitor resume`. Pressing `q` only leaves the screen — monitoring keeps running in the background.
|
||||
- **Auth banner** — at launch, `privileged actions will prompt for authentication (polkit)` shows once and clears on the first refresh. Privileged actions elevate via polkit; Fenris never asks for sudo.
|
||||
|
||||
## Retired menu options
|
||||
|
||||
The legacy `fenris.sh` menu script and the `fenris.py` monolith have been removed. Here's where the old options went:
|
||||
|
||||
@@ -91,7 +91,7 @@ Status: Accepted — resolves [Define cross-cutting acceptance criteria](https:/
|
||||
- **TUI-1** (A) Variant A "Panes": one dense keyboard-first screen; confidence rendered as evidence (state + contributing facts); boot enablement, runtime activity, last collect outcome, and freshness displayed as four separate facts.
|
||||
- **TUI-2** (M) Pause/resume asymmetry and polkit tty passthrough work in a live terminal: pause confirms, resume does not, and the platform agent prompts without breaking the TUI.
|
||||
- **TUI-3** (P) Textual runs on Python 3.9+, gated at install time, never a runtime crash.
|
||||
- **TUI-4** (A) The Panes screen layout is normative: a full-width headline band (lifespan headline or its no-projection wording, confidence state with contributing facts, scenario range); a usage-history pane on the left (write-history sparkline with ▲ habit-change and ? unexplained-gap markers plus legend, habit-split bar with active/idle/powered-off/unknown shares); a drive-health and settings pane on the right (health facts, vendor-wear context line, read-only settings with the endurance baseline and its provenance label); a full-width service strip at the bottom (the four separate service facts, the monitoring-period line, the action legend). Production bindings are `p` pause (asks), `r` resume (does not), `c` collect now, `d` disclosures, `q` quit ([Prototype the TUI information architecture](https://git.bongbetic.com/xavierk/Fenris/issues/3)); the prototype branch is visual reference only.
|
||||
- **TUI-4** (A) The Panes screen layout is normative: a full-width headline band (lifespan headline or its no-projection wording, confidence state with contributing facts, scenario range); a usage-history pane on the left (write-history sparkline with ▲ habit-change and ? unexplained-gap markers plus legend, habit-split bar with active/idle/powered-off/unknown shares); a drive-health and settings pane on the right (health facts, vendor-wear context line, read-only settings with the endurance baseline and its provenance label); a full-width service strip at the bottom (the four separate service facts, the monitoring-period line, the action legend). Production bindings are the footer `p pause · r resume · c collect · d disclosures` — pause asks, resume does not — plus a bordered quit rail `q QUIT TUI` visually separate from monitoring state; the rail owns quit and the footer carries no quit entry (bindings amended by [Lock the dashboard wording strings](https://git.bongbetic.com/xavierk/Fenris/issues/57); original [Prototype the TUI information architecture](https://git.bongbetic.com/xavierk/Fenris/issues/3)); the prototype branch is visual reference only.
|
||||
|
||||
## Failure and recovery (ADR 0005)
|
||||
|
||||
@@ -131,3 +131,16 @@ Status: Accepted — resolves [Define cross-cutting acceptance criteria](https:/
|
||||
- **AC-3** (A) Any acquisition failure — missing binary, nonzero exit, malformed JSON, unreadable sysfs attribute — fails the whole collection run; no partial sample (identity without counters, or counters without identity) is ever written; the miss surfaces through ADR 0005 freshness, never as degraded identity.
|
||||
- **AC-4** (P) `vid`/`ssvid` are read from the PCI sysfs node when present and stored null otherwise; they are segment metadata only, never key components.
|
||||
- **AC-5** (P) `make install` verifies `smartctl` and fails cleanly otherwise; the acquisition path adds no Python dependency and no OS package beyond smartmontools (ADR 0004 §9).
|
||||
|
||||
## Dashboard clarity and release notes ([Chart Fenris dashboard clarity](https://git.bongbetic.com/xavierk/Fenris/issues/55))
|
||||
|
||||
Decided in [Write the dashboard clarity acceptance criteria](https://git.bongbetic.com/xavierk/Fenris/issues/59), from [Prototype the dashboard clarity additions](https://git.bongbetic.com/xavierk/Fenris/issues/56), [Lock the dashboard wording strings](https://git.bongbetic.com/xavierk/Fenris/issues/57), and [Specify the changelog and release-notes mechanism](https://git.bongbetic.com/xavierk/Fenris/issues/58).
|
||||
|
||||
- **DC-1** (A) TUI branding: the header bar renders `Fenris — NVMe endurance monitor`; a dimmed `by Bongbetic` sits inline with service facts in the bottom service strip; neither string appears in `fenris status` (TUI-only identity surfaces).
|
||||
- **DC-2** (A) Continuity parity, keyed to the boot fact as-is: active + boot-enabled renders `monitoring: active in background · persists across reboots`; boot-disabled renders `monitoring: does not start on next boot` — identical lowercase source strings in the TUI service strip and `fenris status`, including while paused (paused implies boot-disabled; the row still reports the fact). Test impact: feeds the CI-2 sweep (lowercase source-string comparison).
|
||||
- **DC-3** (A) Paused presentation (Deliberate disable): the TUI shows a strong state block titled `monitoring: paused — deliberate disable` with subline `paused time is excluded from your usage habit · resume: fenris monitor resume`; `fenris status` prints the same two lines with identical wording. Test impact: feeds the CI-2 sweep (lowercase source-string comparison).
|
||||
- **DC-4** (A) Quit affordance distinct from monitoring state: a bordered labelled rail `q QUIT TUI` visually separate from the paused state block; the footer reads `p pause · r resume · c collect · d disclosures` with no quit entry (the rail owns quit); quitting the TUI never alters monitoring state. Amends TUI-4's binding parenthetical.
|
||||
- **DC-5** (A) Launch auth banner: `privileged actions will prompt for authentication (polkit)` renders full-width under the header at TUI launch, clears on the first refresh tick, and never reappears in the session; no user-facing string uses "sudo" (polkit-accurate elevation wording only).
|
||||
- **DC-6** (A) CHANGELOG.md shape (Keep a Changelog 1.1): `## [Unreleased]` always present at top, even empty; version headings `## [X.Y.Z] - YYYY-MM-DD` with strict ISO date; categories Added/Changed/Fixed only, security folding into Fixed; entries are single `- ` bullets, imperative mood, user-facing, no commit hashes or issue numbers.
|
||||
- **DC-7** (A) Extraction fails closed: `scripts/extract_changelog.py` slices the requested version's section verbatim and never reads `[Unreleased]`; a missing or empty section or a malformed date produces `::error::` and a nonzero exit; the release workflow fails when the pushed tag ≠ `v{version from pyproject.toml}` (guard skipped on `workflow_dispatch`).
|
||||
- **DC-8** (A/P) Release body: the body is the extracted section verbatim plus the standing footer from `packaging/release-footer.md`; a re-run against an existing release PATCHes the body (re-sync is a feature) while uploaded assets skip idempotently. A covers assembly/PATCH-logic unit tests; P is one scripted `workflow_dispatch` verification of body assembly.
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
# Fenris dashboard clarity specification
|
||||
|
||||
**Status: decision-complete.** Assembled by [Assemble the dashboard clarity specification and close the map](https://git.bongbetic.com/xavierk/Fenris/issues/60) from the closed tickets of the Wayfinder map [Chart Fenris dashboard clarity](https://git.bongbetic.com/xavierk/Fenris/issues/55). This document is normative for the follow-up **execution effort**; nothing here is implemented by the map.
|
||||
|
||||
**Canonical roles.** The [redesign specification](fenris-redesign.md) (frozen) and [ADRs 0001–0007](../adr/) remain authoritative and untouched — this is a companion spec covering five dashboard clarity additions plus the changelog-driven release-notes mechanism. The [criteria register](acceptance-criteria.md) carries the testable statements: **DC-1–DC-8**, appended by this assembly, with **TUI-4's binding list amended** (§4). Terminology follows the glossary in [`CONTEXT.md`](../../CONTEXT.md), including *Deliberate disable* and *Release*.
|
||||
|
||||
**Binding language.** *Must*, *exactly*, and *never* are normative.
|
||||
|
||||
## How to read this document
|
||||
|
||||
Five screen additions (§1–§5), one release-notes mechanism (§6), the verbatim string register (§7), and the README section to add at execution (§8). Each section cites its criteria. Source strings are lowercase; the TUI may render uppercase via styling only. Typography, governing every string: em-dash `—` separates a title from its qualifier; middle dot `·` joins facts within a line; UTF-8 is assumed. CI parity sweeps compare lowercase source strings — rendering case is styling, not wording.
|
||||
|
||||
## 1. Header bar and Bongbetic credit — DC-1
|
||||
|
||||
Visual base is treatment A, quiet integration: the existing Panes information architecture is preserved.
|
||||
|
||||
- The header bar reads `Fenris — NVMe endurance monitor`.
|
||||
- The credit `by Bongbetic` renders dimmed, inline with service facts in the bottom service strip — never in the action row.
|
||||
- Both are TUI-only identity surfaces: `fenris status` never renders them.
|
||||
|
||||
## 2. Continuity line — DC-2
|
||||
|
||||
A labelled `CONTINUITY` row in the service strip (treatment B), mirrored by `fenris status` — the TUI/CLI parity anchor. The row is keyed to the boot fact as-is, independently of run state (Deliberate disable runs `systemctl disable --now`, so paused implies boot-disabled; the row still reports the fact):
|
||||
|
||||
- Active + boot enabled: `monitoring: active in background · persists across reboots`
|
||||
- Boot disabled: `monitoring: does not start on next boot`
|
||||
|
||||
Identical lowercase source strings in the TUI service strip and `fenris status`, including while paused.
|
||||
|
||||
## 3. Paused state block — DC-3
|
||||
|
||||
Treatment C, strong state blocks: when monitoring is paused, a full-width, high-contrast banner clearly identifying Deliberate disable:
|
||||
|
||||
- Title: `monitoring: paused — deliberate disable`
|
||||
- Subline: `paused time is excluded from your usage habit · resume: fenris monitor resume`
|
||||
|
||||
`fenris status` prints the same two lines with identical wording (state line + consequence line). The resume hint uses the CLI form only; the footer owns key hints — no duplication.
|
||||
|
||||
## 4. Quit rail — DC-4 (amends TUI-4)
|
||||
|
||||
Treatment B, labelled rails: a prominent bordered `q QUIT TUI` rail, visually separate from the monitoring-state block and the paused banner. The footer becomes `p pause · r resume · c collect · d disclosures` — the rail owns quit; the footer carries no quit entry. Quitting the TUI never alters monitoring state. The register's TUI-4 binding parenthetical is amended accordingly by this assembly.
|
||||
|
||||
## 5. Launch auth banner — DC-5
|
||||
|
||||
A quiet informational line (treatment A) that never competes with drive state:
|
||||
|
||||
- Text: `privileged actions will prompt for authentication (polkit)`
|
||||
- Full-width under the header at TUI launch; clears on the first refresh tick; never reappears in the session.
|
||||
- TUI-only; `fenris status` never shows it.
|
||||
- Elevation wording is polkit-accurate everywhere: no user-facing string uses "sudo" (sudo belongs to install/upgrade docs).
|
||||
- Evidence class A: a Textual pilot drives refresh ticks headlessly.
|
||||
|
||||
## 6. Changelog and release notes — DC-6, DC-7, DC-8
|
||||
|
||||
Implements the existing glossary term *Release* (tag + packages + change notes together). No new glossary terms; no ADR (reversible mechanism).
|
||||
|
||||
### 6.1 CHANGELOG.md (source of truth, repo root)
|
||||
|
||||
- Keep a Changelog 1.1 shape. `## [Unreleased]` is always present at top, even empty. Version headings are `## [X.Y.Z] - YYYY-MM-DD` — bracketed bare semver, strict ISO date.
|
||||
- Categories are `### Added`, `### Changed`, `### Fixed` only; security fixes fold into Fixed.
|
||||
- Entries are single `- ` bullets, imperative mood, user-facing phrasing; no commit hashes or issue numbers.
|
||||
|
||||
### 6.2 Extraction (release.yml, tag time)
|
||||
|
||||
- `scripts/extract_changelog.py` (checked in, unit-tested): takes the changelog path and a version; slices that version's section verbatim; never reads `[Unreleased]`. Fails closed — `::error::` plus nonzero exit — when the section is missing or empty or the date is malformed.
|
||||
- Guard: the workflow fails when the pushed tag ≠ `v{version from pyproject.toml}` (guard skipped on `workflow_dispatch`).
|
||||
|
||||
### 6.3 Release body
|
||||
|
||||
- Body = extracted version section verbatim + standing footer from `packaging/release-footer.md` (channel install one-liners, `sha256sum -c SHA256SUMS.asc` verify, rollback pointer). The footer is standing text; only the changelog section varies.
|
||||
- Re-run against an existing release: PATCH the body (changelog re-sync is a feature); uploaded assets/packages keep their current idempotent-skip.
|
||||
|
||||
### 6.4 Discipline
|
||||
|
||||
- All entries land in `[Unreleased]` as part of the fixing change — no notes-later step.
|
||||
- One release commit bumps the pyproject version, renames `[Unreleased]` → the version heading, and restores an empty `[Unreleased]`; the tag points at that commit (tag ↔ pyproject ↔ changelog triple-match, enforced fail-closed by DC-7).
|
||||
- No backfill: per-release notes begin with the release shipping this mechanism; `CHANGELOG.md` starts with empty `[Unreleased]`.
|
||||
|
||||
## 7. String register (verbatim)
|
||||
|
||||
### TUI-only strings (launch/identity surfaces)
|
||||
|
||||
| Surface | String |
|
||||
|---|---|
|
||||
| Header bar | `Fenris — NVMe endurance monitor` |
|
||||
| Credit (dimmed, inline with service facts) | `by Bongbetic` |
|
||||
| Auth banner (full-width under header at launch, clears on first refresh tick, never reappears) | `privileged actions will prompt for authentication (polkit)` |
|
||||
| Quit rail (bordered, labelled) | `q QUIT TUI` |
|
||||
| Footer (owns key hints; no quit entry) | `p pause · r resume · c collect · d disclosures` |
|
||||
|
||||
### Parity strings (TUI and `fenris status` identical — CI-2)
|
||||
|
||||
| Surface | String |
|
||||
|---|---|
|
||||
| Continuity, active + boot enabled | `monitoring: active in background · persists across reboots` |
|
||||
| Continuity, boot disabled | `monitoring: does not start on next boot` |
|
||||
| Paused state line | `monitoring: paused — deliberate disable` |
|
||||
| Paused consequence line | `paused time is excluded from your usage habit · resume: fenris monitor resume` |
|
||||
|
||||
`fenris status` prints the paused state line + consequence line when paused, identical wording to the banner title + subline.
|
||||
|
||||
## 8. README section (add at execution)
|
||||
|
||||
The README gains a "Reading the dashboard" section after the CLI reference. Verbatim text:
|
||||
|
||||
```markdown
|
||||
## Reading the dashboard
|
||||
|
||||
`fenris` opens the TUI dashboard. Three things it tells you:
|
||||
|
||||
- **Continuity** — the service strip's continuity line (and `fenris status`) reports whether monitoring survives reboots: `monitoring: active in background · persists across reboots`, or `monitoring: does not start on next boot`.
|
||||
- **Paused vs. quit** — a full-width `monitoring: paused — deliberate disable` block means collection is stopped (`fenris monitor pause`); resume with `fenris monitor resume`. Pressing `q` only leaves the screen — monitoring keeps running in the background.
|
||||
- **Auth banner** — at launch, `privileged actions will prompt for authentication (polkit)` shows once and clears on the first refresh. Privileged actions elevate via polkit; Fenris never asks for sudo.
|
||||
|
||||
Per-release notes live on the [releases page](https://git.bongbetic.com/xavierk/Fenris/releases): each entry is the version's `CHANGELOG.md` section — what was added, changed, and fixed — plus standing install and verification instructions.
|
||||
```
|
||||
|
||||
This resolves the map's README-wording fog: the wording is decided here; the actual README edit is execution.
|
||||
|
||||
## 9. Out of scope
|
||||
|
||||
Executing any of this — code, tests, releases — and any TUI layout or information-architecture redesign beyond the five additions named above. Execution is a fresh effort after handoff.
|
||||
+54
-1
@@ -449,7 +449,7 @@ def _format_headline(proj) -> str:
|
||||
|
||||
|
||||
def _append_service_facts(lines: List[str], service: Dict[str, Any]) -> None:
|
||||
"""Append the four separate service facts (§7.3, LC-9)."""
|
||||
"""Append service facts and dashboard-clarity monitoring state."""
|
||||
boot = "enabled" if service.get("boot_enabled") else "disabled"
|
||||
activity = "active" if service.get("timer_active") else "inactive"
|
||||
|
||||
@@ -473,6 +473,55 @@ def _append_service_facts(lines: List[str], service: Dict[str, Any]) -> None:
|
||||
|
||||
lines.append("boot: %s · timer: %s · last collect: %s%s · freshness: %s%s"
|
||||
% (boot, activity, collect, collect_age, freshness_str, freshness_age))
|
||||
lines.append("CONTINUITY: %s" % monitoring_continuity(service))
|
||||
if service.get("deliberately_paused"):
|
||||
lines.extend(deliberate_pause_lines())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dashboard clarity parity wording (DC-2, DC-3)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_CONTINUITY_ACTIVE = "monitoring: active in background · persists across reboots"
|
||||
_CONTINUITY_DISABLED = "monitoring: does not start on next boot"
|
||||
_PAUSED_TITLE = "monitoring: paused — deliberate disable"
|
||||
_PAUSED_CONSEQUENCE = (
|
||||
"paused time is excluded from your usage habit · resume: fenris monitor resume"
|
||||
)
|
||||
|
||||
|
||||
def monitoring_continuity(service: Dict[str, Any]) -> str:
|
||||
"""Return the boot-persistence wording, independent of timer runtime."""
|
||||
return _CONTINUITY_ACTIVE if service.get("boot_enabled") else _CONTINUITY_DISABLED
|
||||
|
||||
|
||||
def deliberate_pause_lines() -> List[str]:
|
||||
"""Return the exact CLI/TUI presentation for a sanctioned pause."""
|
||||
return [_PAUSED_TITLE, _PAUSED_CONSEQUENCE]
|
||||
|
||||
|
||||
def is_deliberately_paused(conn: sqlite3.Connection, service: Dict[str, Any]) -> bool:
|
||||
"""Whether the latest closed period was ended by Fenris's own pause path.
|
||||
|
||||
Raw systemd operations have no `user_disabled` row, so they must never be
|
||||
presented as a Deliberate disable. A live enabled timer also wins over a
|
||||
stale period marker, keeping the presentation consistent with service facts.
|
||||
"""
|
||||
if service.get("boot_enabled") or service.get("timer_active"):
|
||||
return False
|
||||
|
||||
open_period = conn.execute(
|
||||
"SELECT 1 FROM monitoring_periods WHERE ended_at IS NULL LIMIT 1"
|
||||
).fetchone()
|
||||
if open_period is not None:
|
||||
return False
|
||||
|
||||
row = conn.execute(
|
||||
"SELECT end_cause FROM monitoring_periods "
|
||||
"WHERE ended_at IS NOT NULL "
|
||||
"ORDER BY ended_at DESC, id DESC LIMIT 1"
|
||||
).fetchone()
|
||||
return row is not None and row[0] == "user_disabled"
|
||||
|
||||
|
||||
def format_disclosures() -> str:
|
||||
@@ -562,6 +611,10 @@ def get_status(store_path: Optional[Path] = None, clock_now: Optional[datetime]
|
||||
|
||||
service["freshness"] = freshness
|
||||
service["freshness_age_s"] = freshness_age_s
|
||||
try:
|
||||
service["deliberately_paused"] = is_deliberately_paused(conn, service)
|
||||
except sqlite3.Error:
|
||||
service["deliberately_paused"] = False
|
||||
|
||||
# --- Drive anomalies (§9.7, FL-7) ---
|
||||
drive_facts = []
|
||||
|
||||
+110
-29
@@ -23,7 +23,7 @@ from typing import Any, Dict, List, Optional
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.containers import Container, Horizontal, VerticalScroll
|
||||
from textual.screen import ModalScreen
|
||||
from textual.widgets import Static
|
||||
|
||||
@@ -43,6 +43,9 @@ from .status import (
|
||||
format_disclosures,
|
||||
freshness_age_human,
|
||||
grade_freshness,
|
||||
deliberate_pause_lines,
|
||||
is_deliberately_paused,
|
||||
monitoring_continuity,
|
||||
open_store_readonly,
|
||||
query_service_state,
|
||||
read_config,
|
||||
@@ -229,6 +232,7 @@ def _query_service_facts(conn: sqlite3.Connection, clock_now: datetime) -> Dict[
|
||||
"freshness": freshness,
|
||||
"freshness_age_s": None,
|
||||
"period": period_info,
|
||||
"deliberately_paused": is_deliberately_paused(conn, svc),
|
||||
**svc,
|
||||
}
|
||||
|
||||
@@ -286,20 +290,38 @@ class DisclosuresScreen(ModalScreen[None]):
|
||||
class FenrisTuiApp(App):
|
||||
"""Fenris Panes TUI — keyboard-first, one dense screen (spec §7)."""
|
||||
|
||||
TITLE = "Fenris"
|
||||
SUB_TITLE = "NVMe endurance monitor"
|
||||
TITLE = "Fenris — NVMe endurance monitor"
|
||||
SUB_TITLE = ""
|
||||
|
||||
CSS = """
|
||||
#main-grid {
|
||||
layout: grid;
|
||||
grid-size: 2 3;
|
||||
grid-size: 2 4;
|
||||
grid-columns: 3fr 2fr;
|
||||
grid-rows: 8 1fr 7;
|
||||
height: 1fr;
|
||||
grid-rows: 8 10 7 3;
|
||||
height: auto;
|
||||
}
|
||||
#main-grid.paused {
|
||||
grid-size: 2 5;
|
||||
grid-rows: 8 5 10 7 3;
|
||||
}
|
||||
#dashboard-scroll { height: 1fr; }
|
||||
#headline-band { column-span: 2; }
|
||||
#service-strip { column-span: 2; }
|
||||
.pane { border: round #555555; padding: 0 1; }
|
||||
#paused-banner {
|
||||
column-span: 2;
|
||||
display: none;
|
||||
background: $error 20%;
|
||||
color: $text;
|
||||
height: 100%;
|
||||
}
|
||||
#service-strip { column-span: 2; height: 100%; }
|
||||
#quit-rail {
|
||||
column-span: 2;
|
||||
border: heavy $accent;
|
||||
content-align: center middle;
|
||||
height: 100%;
|
||||
}
|
||||
.pane { border: round #555555; padding: 0 1; height: 100%; }
|
||||
#confirm-text { padding: 1 2; }
|
||||
#disc-text { padding: 1 2; }
|
||||
"""
|
||||
@@ -317,21 +339,27 @@ class FenrisTuiApp(App):
|
||||
store_path: Optional[Path] = None,
|
||||
config_path: Optional[Path] = None,
|
||||
helper_path: Optional[str] = None,
|
||||
refresh_interval_s: float = CADENCE_DEFAULT_S,
|
||||
**kwargs,
|
||||
) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self.store_path = store_path or Path("/var/lib/fenris/observations.db")
|
||||
self.config_path = config_path
|
||||
self.helper_path = helper_path or "/usr/libexec/fenris/fenris-monitor"
|
||||
self.refresh_interval_s = refresh_interval_s
|
||||
self._show_auth_notice = True
|
||||
self._conn: Optional[sqlite3.Connection] = None
|
||||
self._clock_now = datetime.now(timezone.utc)
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Vertical(id="main-grid"):
|
||||
yield Static("", id="headline-band", classes="pane")
|
||||
yield Static("", id="usage-history", classes="pane")
|
||||
yield Static("", id="drive-health", classes="pane")
|
||||
yield Static("", id="service-strip", classes="pane")
|
||||
with VerticalScroll(id="dashboard-scroll"):
|
||||
with Container(id="main-grid"):
|
||||
yield Static("", id="headline-band", classes="pane")
|
||||
yield Static("", id="paused-banner")
|
||||
yield Static("", id="usage-history", classes="pane")
|
||||
yield Static("", id="drive-health", classes="pane")
|
||||
yield Static("", id="service-strip", classes="pane")
|
||||
yield Static("q QUIT TUI", id="quit-rail")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
"""Set border titles and render initial state."""
|
||||
@@ -339,8 +367,30 @@ class FenrisTuiApp(App):
|
||||
self.query_one("#usage-history").border_title = "usage history"
|
||||
self.query_one("#drive-health").border_title = "drive"
|
||||
self.query_one("#service-strip").border_title = "service + actions"
|
||||
self._refresh_timer = self.set_interval(
|
||||
self.refresh_interval_s, self.on_refresh_tick
|
||||
)
|
||||
self._refresh()
|
||||
|
||||
def on_refresh_tick(self) -> None:
|
||||
"""Refresh dashboard and dismiss launch-only authentication guidance."""
|
||||
self._show_auth_notice = False
|
||||
self._refresh()
|
||||
|
||||
def _headline_prefix(self) -> str:
|
||||
"""Render identity and any launch-only guidance above drive state."""
|
||||
lines = ["[bold]Fenris — NVMe endurance monitor[/bold]"]
|
||||
if self._show_auth_notice:
|
||||
lines.append("[dim]privileged actions will prompt for authentication (polkit)[/dim]")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _render_headline(self, body: str = "") -> None:
|
||||
"""Render full-width identity, guidance, and current drive state."""
|
||||
text = self._headline_prefix()
|
||||
if body:
|
||||
text += "\n\n" + body
|
||||
self.query_one("#headline-band").update(text)
|
||||
|
||||
def _open_store(self) -> Optional[sqlite3.Connection]:
|
||||
"""Open store read-only, handling faults."""
|
||||
try:
|
||||
@@ -366,28 +416,33 @@ class FenrisTuiApp(App):
|
||||
|
||||
def _render_empty_or_fault(self) -> None:
|
||||
"""Render empty store greeting or store fault."""
|
||||
self._hide_paused_banner()
|
||||
if not self.store_path.exists():
|
||||
# Empty store — greeting with enable hint (IN-3)
|
||||
self.query_one("#headline-band").update(
|
||||
self._render_headline(
|
||||
"[bold]No observations yet[/bold]\n\n"
|
||||
"Enable monitoring: fenris monitor resume"
|
||||
)
|
||||
self.query_one("#usage-history").update("")
|
||||
self.query_one("#drive-health").update("")
|
||||
self.query_one("#service-strip").update(
|
||||
"boot: disabled · timer: inactive · last collect: unknown · freshness: empty\n"
|
||||
"p pause · r resume · c collect · d disclosures · q quit"
|
||||
"boot: disabled · timer: inactive · last collect: unknown · freshness: empty · "
|
||||
"[dim]by Bongbetic[/dim]\n"
|
||||
"[bold]CONTINUITY[/bold] %s\n"
|
||||
"p pause · r resume · c collect · d disclosures"
|
||||
% monitoring_continuity({"boot_enabled": False})
|
||||
)
|
||||
else:
|
||||
# Store fault (FL-4)
|
||||
self.query_one("#headline-band").update(
|
||||
self._render_headline(
|
||||
"[bold red]Observation store unreadable[/bold red]\n"
|
||||
"Check journalctl -u fenris-collect.service"
|
||||
)
|
||||
self.query_one("#usage-history").update("")
|
||||
self.query_one("#drive-health").update("")
|
||||
self.query_one("#service-strip").update(
|
||||
"p pause · r resume · c collect · d disclosures · q quit"
|
||||
"[dim]by Bongbetic[/dim]\n"
|
||||
"p pause · r resume · c collect · d disclosures"
|
||||
)
|
||||
|
||||
def _render_all_regions(self, conn: sqlite3.Connection) -> None:
|
||||
@@ -398,13 +453,9 @@ class FenrisTuiApp(App):
|
||||
headline = self._format_headline(proj)
|
||||
confidence = self._format_confidence(proj)
|
||||
scenario = self._format_scenario(proj)
|
||||
self.query_one("#headline-band").update(
|
||||
headline + "\n" + confidence + "\n" + scenario
|
||||
)
|
||||
self._render_headline(headline + "\n" + confidence + "\n" + scenario)
|
||||
except Exception:
|
||||
self.query_one("#headline-band").update(
|
||||
"[bold]No projection available[/bold]"
|
||||
)
|
||||
self._render_headline("[bold]No projection available[/bold]")
|
||||
|
||||
# --- Usage-history pane (§7.2 left) ---
|
||||
history = _query_usage_history(conn)
|
||||
@@ -448,17 +499,47 @@ class FenrisTuiApp(App):
|
||||
collect = "ok" if svc.get("last_collect_ok") else "FAILED"
|
||||
freshness = svc.get("freshness", "unknown")
|
||||
self.query_one("#service-strip").update(
|
||||
"boot: %s · timer: %s · last collect: %s · freshness: %s\n"
|
||||
"boot: %s · timer: %s · last collect: %s · freshness: %s · "
|
||||
"[dim]by Bongbetic[/dim]\n"
|
||||
"[bold]CONTINUITY[/bold] %s\n"
|
||||
"%s\n"
|
||||
"p pause · r resume · c collect · d disclosures · q quit"
|
||||
% (boot, activity, collect, freshness, svc.get("period", ""))
|
||||
"p pause · r resume · c collect · d disclosures"
|
||||
% (
|
||||
boot, activity, collect, freshness,
|
||||
monitoring_continuity(svc), svc.get("period", ""),
|
||||
)
|
||||
)
|
||||
self._render_paused_banner(svc)
|
||||
except Exception:
|
||||
self._hide_paused_banner()
|
||||
self.query_one("#service-strip").update(
|
||||
"boot: unknown · timer: unknown · last collect: unknown · freshness: unknown\n"
|
||||
"p pause · r resume · c collect · d disclosures · q quit"
|
||||
"boot: unknown · timer: unknown · last collect: unknown · freshness: unknown · "
|
||||
"[dim]by Bongbetic[/dim]\n"
|
||||
"p pause · r resume · c collect · d disclosures"
|
||||
)
|
||||
|
||||
def _render_paused_banner(self, service: Dict[str, Any]) -> None:
|
||||
"""Show the high-contrast Deliberate disable block only when sanctioned."""
|
||||
banner = self.query_one("#paused-banner")
|
||||
if service.get("deliberately_paused"):
|
||||
banner.update(
|
||||
"[bold black on red]%s[/bold black on red]\n%s"
|
||||
% tuple(deliberate_pause_lines())
|
||||
)
|
||||
banner.styles.display = "block"
|
||||
main_grid = self.query_one("#main-grid")
|
||||
main_grid.add_class("paused")
|
||||
main_grid.refresh(layout=True)
|
||||
else:
|
||||
self._hide_paused_banner()
|
||||
|
||||
def _hide_paused_banner(self) -> None:
|
||||
"""Ensure an unavailable store cannot retain a stale paused presentation."""
|
||||
self.query_one("#paused-banner").styles.display = "none"
|
||||
main_grid = self.query_one("#main-grid")
|
||||
main_grid.remove_class("paused")
|
||||
main_grid.refresh(layout=True)
|
||||
|
||||
def _format_headline(self, proj: ProjectionResult) -> str:
|
||||
"""Format the lifespan headline (spec §6.11)."""
|
||||
if proj.headline_remaining_seconds is None:
|
||||
|
||||
@@ -399,6 +399,89 @@ class TestCI2Parity:
|
||||
assert "last collect:" in status
|
||||
assert "freshness:" in status
|
||||
|
||||
def test_dashboard_clarity_parity_strings_have_one_status_source(self):
|
||||
"""DC-2/DC-3 wording originates in status and the TUI imports it."""
|
||||
status_src = (FENRIS_PKG / "status.py").read_text()
|
||||
tui_src = (FENRIS_PKG / "tui.py").read_text()
|
||||
for wording in (
|
||||
"monitoring: active in background · persists across reboots",
|
||||
"monitoring: does not start on next boot",
|
||||
"monitoring: paused — deliberate disable",
|
||||
"paused time is excluded from your usage habit · resume: fenris monitor resume",
|
||||
):
|
||||
assert status_src.count(wording) == 1
|
||||
assert wording not in tui_src
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("state", "service", "expected_lines"),
|
||||
[
|
||||
(
|
||||
"active_enabled",
|
||||
{"boot_enabled": True, "timer_active": True},
|
||||
["monitoring: active in background · persists across reboots"],
|
||||
),
|
||||
(
|
||||
"boot_disabled",
|
||||
{"boot_enabled": False, "timer_active": False},
|
||||
["monitoring: does not start on next boot"],
|
||||
),
|
||||
(
|
||||
"deliberately_paused",
|
||||
{"boot_enabled": False, "timer_active": False},
|
||||
[
|
||||
"monitoring: does not start on next boot",
|
||||
"monitoring: paused — deliberate disable",
|
||||
"paused time is excluded from your usage habit · resume: fenris monitor resume",
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_dashboard_clarity_monitoring_lines_match_both_views(
|
||||
self, tmp_path, state, service, expected_lines
|
||||
):
|
||||
"""CI-2 synthetic-store sweep covers active, disabled, and paused states."""
|
||||
db = tmp_path / (state + ".db")
|
||||
conn = init_store(db)
|
||||
if state == "active_enabled":
|
||||
ensure_period_open(conn, _clock())
|
||||
elif state == "deliberately_paused":
|
||||
conn.execute(
|
||||
"INSERT INTO monitoring_periods (started_at, ended_at, end_cause) "
|
||||
"VALUES (?, ?, ?)",
|
||||
("2026-09-30T09:00:00+00:00", "2026-09-30T10:00:00+00:00", "user_disabled"),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
service_state = {
|
||||
**service,
|
||||
"last_collect_ok": None,
|
||||
"last_collect_age_s": None,
|
||||
"last_collect_reason": None,
|
||||
}
|
||||
|
||||
with patch("fenris.status.query_service_state", return_value=service_state), patch(
|
||||
"fenris.tui.query_service_state", return_value=service_state
|
||||
):
|
||||
status = get_status(
|
||||
store_path=db, clock_now=_clock(), query_services=True, query_journal=False
|
||||
).lower()
|
||||
app = FenrisTuiApp(store_path=db)
|
||||
async with app.run_test(size=(100, 40)):
|
||||
tui_text = "\n".join(
|
||||
(
|
||||
str(app.query_one("#service-strip").render()),
|
||||
str(app.query_one("#paused-banner").render()),
|
||||
)
|
||||
).lower()
|
||||
|
||||
for expected in expected_lines:
|
||||
assert expected in status
|
||||
assert expected in tui_text
|
||||
if state != "deliberately_paused":
|
||||
assert "monitoring: paused — deliberate disable" not in status
|
||||
assert "monitoring: paused — deliberate disable" not in tui_text
|
||||
|
||||
def test_pause_resume_action_names(self):
|
||||
tui_keys = {b.key for b in FenrisTuiApp.BINDINGS}
|
||||
assert "p" in tui_keys
|
||||
|
||||
@@ -415,6 +415,131 @@ class TestServiceFacts:
|
||||
assert "last collect:" in result
|
||||
assert "freshness:" in result
|
||||
|
||||
def test_continuity_reports_boot_enabled_independently_of_runtime(self, tmp_path):
|
||||
"""Status names reboot continuity while retaining the timer fact."""
|
||||
from fenris.status import get_status
|
||||
|
||||
db = tmp_path / "observations.db"
|
||||
init_store(db)
|
||||
now = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
with patch("fenris.status.query_service_state", return_value={
|
||||
"boot_enabled": True, "timer_active": False,
|
||||
"last_collect_ok": None, "last_collect_age_s": None,
|
||||
"last_collect_reason": None,
|
||||
}):
|
||||
result = get_status(store_path=db, clock_now=now,
|
||||
query_services=True, query_journal=False)
|
||||
|
||||
assert "monitoring: active in background · persists across reboots" in result
|
||||
assert "timer: inactive" in result
|
||||
|
||||
def test_continuity_and_deliberate_pause_are_reported_separately(self, tmp_path):
|
||||
"""Only a sanctioned user_disabled period renders the paused wording."""
|
||||
from fenris.status import get_status
|
||||
|
||||
db = tmp_path / "observations.db"
|
||||
conn = init_store(db)
|
||||
conn.execute(
|
||||
"INSERT INTO monitoring_periods (started_at, ended_at, end_cause) "
|
||||
"VALUES (?, ?, ?)",
|
||||
("2026-09-01T09:00:00+00:00", "2026-09-01T10:00:00+00:00", "user_disabled"),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
now = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
with patch("fenris.status.query_service_state", return_value={
|
||||
"boot_enabled": False, "timer_active": False,
|
||||
"last_collect_ok": None, "last_collect_age_s": None,
|
||||
"last_collect_reason": None,
|
||||
}):
|
||||
result = get_status(store_path=db, clock_now=now,
|
||||
query_services=True, query_journal=False)
|
||||
|
||||
assert "monitoring: does not start on next boot" in result
|
||||
assert "monitoring: paused — deliberate disable" in result
|
||||
assert "paused time is excluded from your usage habit · resume: fenris monitor resume" in result
|
||||
|
||||
def test_raw_system_state_without_user_disabled_is_not_a_deliberate_pause(self, tmp_path):
|
||||
"""A non-sanctioned stop never acquires the deliberate-disable label."""
|
||||
from fenris.status import get_status
|
||||
|
||||
db = tmp_path / "observations.db"
|
||||
conn = init_store(db)
|
||||
conn.execute(
|
||||
"INSERT INTO monitoring_periods (started_at, ended_at, end_cause) "
|
||||
"VALUES (?, ?, ?)",
|
||||
("2026-09-01T09:00:00+00:00", "2026-09-01T10:00:00+00:00", "migrated"),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
now = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
with patch("fenris.status.query_service_state", return_value={
|
||||
"boot_enabled": False, "timer_active": False,
|
||||
"last_collect_ok": None, "last_collect_age_s": None,
|
||||
"last_collect_reason": None,
|
||||
}):
|
||||
result = get_status(store_path=db, clock_now=now,
|
||||
query_services=True, query_journal=False)
|
||||
|
||||
assert "monitoring: paused — deliberate disable" not in result
|
||||
|
||||
def test_resumed_open_period_clears_a_previous_deliberate_pause(self, tmp_path):
|
||||
"""A later sanctioned resume takes precedence over an older pause."""
|
||||
from fenris.status import get_status
|
||||
|
||||
db = tmp_path / "observations.db"
|
||||
conn = init_store(db)
|
||||
conn.execute(
|
||||
"INSERT INTO monitoring_periods (started_at, ended_at, end_cause) "
|
||||
"VALUES (?, ?, ?)",
|
||||
("2026-09-01T09:00:00+00:00", "2026-09-01T10:00:00+00:00", "user_disabled"),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO monitoring_periods (started_at) VALUES (?)",
|
||||
("2026-09-01T11:00:00+00:00",),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
now = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
with patch("fenris.status.query_service_state", return_value={
|
||||
"boot_enabled": True, "timer_active": True,
|
||||
"last_collect_ok": None, "last_collect_age_s": None,
|
||||
"last_collect_reason": None,
|
||||
}):
|
||||
result = get_status(store_path=db, clock_now=now,
|
||||
query_services=True, query_journal=False)
|
||||
|
||||
assert "monitoring: paused — deliberate disable" not in result
|
||||
|
||||
def test_live_enabled_service_suppresses_a_stale_pause_marker(self, tmp_path):
|
||||
"""A raw re-enable cannot leave a contradictory paused presentation."""
|
||||
from fenris.status import get_status
|
||||
|
||||
db = tmp_path / "observations.db"
|
||||
conn = init_store(db)
|
||||
conn.execute(
|
||||
"INSERT INTO monitoring_periods (started_at, ended_at, end_cause) "
|
||||
"VALUES (?, ?, ?)",
|
||||
("2026-09-01T09:00:00+00:00", "2026-09-01T10:00:00+00:00", "user_disabled"),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
now = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
with patch("fenris.status.query_service_state", return_value={
|
||||
"boot_enabled": True, "timer_active": True,
|
||||
"last_collect_ok": None, "last_collect_age_s": None,
|
||||
"last_collect_reason": None,
|
||||
}):
|
||||
result = get_status(store_path=db, clock_now=now,
|
||||
query_services=True, query_journal=False)
|
||||
|
||||
assert "monitoring: paused — deliberate disable" not in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Status output structure (§8.8, LC-9)
|
||||
@@ -441,6 +566,29 @@ class TestStatusOutput:
|
||||
assert isinstance(result, str)
|
||||
assert len(result) > 0
|
||||
|
||||
def test_status_excludes_tui_identity_and_auth_notice(self, tmp_path):
|
||||
"""CLI status never renders TUI-only identity or launch guidance."""
|
||||
from fenris.status import get_status
|
||||
|
||||
db = tmp_path / "observations.db"
|
||||
init_store(db)
|
||||
now = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||
|
||||
with patch("fenris.status.query_service_state", return_value={
|
||||
"boot_enabled": False, "timer_active": False,
|
||||
"last_collect_ok": None, "last_collect_age_s": None,
|
||||
"last_collect_reason": None,
|
||||
}):
|
||||
result = get_status(store_path=db, clock_now=now,
|
||||
query_services=True, query_journal=False)
|
||||
|
||||
for tui_only in (
|
||||
"Fenris — NVMe endurance monitor",
|
||||
"by Bongbetic",
|
||||
"privileged actions will prompt for authentication (polkit)",
|
||||
):
|
||||
assert tui_only not in result
|
||||
|
||||
def test_status_never_writes(self, tmp_path):
|
||||
"""Status never writes to the store."""
|
||||
from fenris.status import get_status
|
||||
|
||||
+155
-1
@@ -10,6 +10,7 @@ Covers:
|
||||
Criteria: TUI-1, TUI-4, CI-1, CI-4, IN-3.
|
||||
"""
|
||||
import sqlite3
|
||||
from xml.etree import ElementTree
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, MagicMock
|
||||
@@ -384,6 +385,160 @@ class TestDenseScreen:
|
||||
assert "timer:" in strip
|
||||
assert "last collect:" in strip
|
||||
assert "freshness:" in strip
|
||||
assert "by Bongbetic" in strip
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_service_strip_shows_continuity_and_separate_quit_rail(self, tmp_path):
|
||||
"""The visible action footer excludes quit because the rail owns it."""
|
||||
conn = init_store(tmp_path / "test.db")
|
||||
_open_period(conn)
|
||||
conn.close()
|
||||
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||
|
||||
with patch("fenris.tui.query_service_state", return_value={
|
||||
"boot_enabled": True, "timer_active": True,
|
||||
"last_collect_ok": True, "last_collect_age_s": 60,
|
||||
"last_collect_reason": None,
|
||||
}):
|
||||
async with app.run_test():
|
||||
strip = str(app.query_one("#service-strip").render()).lower()
|
||||
rail = str(app.query_one("#quit-rail").render())
|
||||
usage = app.query_one("#usage-history")
|
||||
service = app.query_one("#service-strip")
|
||||
quit_rail = app.query_one("#quit-rail")
|
||||
assert "continuity" in strip
|
||||
assert "monitoring: active in background · persists across reboots" in strip
|
||||
assert "p pause · r resume · c collect · d disclosures" in strip
|
||||
assert "q quit" not in strip
|
||||
assert rail == "q QUIT TUI"
|
||||
assert usage.region.y < service.region.y < quit_rail.region.y
|
||||
assert usage.region.bottom <= service.region.y
|
||||
assert service.region.bottom <= quit_rail.region.y
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_deliberate_pause_banner_is_visible_and_quit_preserves_periods(self, tmp_path):
|
||||
"""The Pilot sees the paused block; q leaves persisted monitoring state alone."""
|
||||
db = tmp_path / "test.db"
|
||||
conn = init_store(db)
|
||||
conn.execute(
|
||||
"INSERT INTO monitoring_periods (started_at, ended_at, end_cause) "
|
||||
"VALUES (?, ?, ?)",
|
||||
("2026-09-01T09:00:00+00:00", "2026-09-01T10:00:00+00:00", "user_disabled"),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
app = FenrisTuiApp(store_path=db)
|
||||
|
||||
with patch("fenris.tui.query_service_state", return_value={
|
||||
"boot_enabled": False, "timer_active": False,
|
||||
"last_collect_ok": None, "last_collect_age_s": None,
|
||||
"last_collect_reason": None,
|
||||
}), patch("fenris.tui.subprocess.run") as subprocess_run, patch.object(
|
||||
app, "_run_helper"
|
||||
) as run_helper:
|
||||
async with app.run_test(size=(80, 24)) as pilot:
|
||||
await pilot.pause()
|
||||
paused_banner = app.query_one("#paused-banner")
|
||||
main_grid = app.query_one("#main-grid")
|
||||
assert str(main_grid.styles.layout) == "<grid>"
|
||||
assert main_grid.has_class("paused")
|
||||
assert len(main_grid.styles.grid_rows) == 5
|
||||
banner = str(paused_banner.render()).lower()
|
||||
assert "monitoring: paused — deliberate disable" in banner
|
||||
assert "paused time is excluded from your usage habit · resume: fenris monitor resume" in banner
|
||||
assert paused_banner.region.height >= 5
|
||||
assert paused_banner.region.y < app.query_one("#usage-history").region.y
|
||||
assert app.query_one("#usage-history").region.bottom <= app.query_one(
|
||||
"#service-strip"
|
||||
).region.y
|
||||
screenshot = app.export_screenshot()
|
||||
visible_text = " ".join(
|
||||
"".join(ElementTree.fromstring(screenshot).itertext()).split()
|
||||
)
|
||||
assert "paused time is excluded from your usage habit" in visible_text
|
||||
assert "resume: fenris monitor resume" in visible_text
|
||||
assert "monitoring: does not start on next boot" in str(
|
||||
app.query_one("#service-strip").render()
|
||||
).lower()
|
||||
dashboard_scroll = app.query_one("#dashboard-scroll")
|
||||
assert dashboard_scroll.max_scroll_y > 0
|
||||
dashboard_scroll.focus()
|
||||
await pilot.press("end")
|
||||
assert dashboard_scroll.scroll_y == dashboard_scroll.max_scroll_y
|
||||
footer_text = " ".join(
|
||||
"".join(
|
||||
ElementTree.fromstring(app.export_screenshot()).itertext()
|
||||
).split()
|
||||
)
|
||||
assert "q QUIT TUI" in footer_text
|
||||
await pilot.press("q")
|
||||
assert not app.is_running
|
||||
subprocess_run.assert_not_called()
|
||||
run_helper.assert_not_called()
|
||||
|
||||
conn = sqlite3.connect(db)
|
||||
row = conn.execute(
|
||||
"SELECT ended_at, end_cause FROM monitoring_periods"
|
||||
).fetchone()
|
||||
conn.close()
|
||||
assert row == ("2026-09-01T10:00:00+00:00", "user_disabled")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_quit_preserves_an_active_monitoring_period(self, tmp_path):
|
||||
"""Quitting an active dashboard never closes or mutates its period."""
|
||||
db = tmp_path / "test.db"
|
||||
conn = init_store(db)
|
||||
_open_period(conn, "2026-09-01T09:00:00+00:00")
|
||||
conn.close()
|
||||
app = FenrisTuiApp(store_path=db)
|
||||
|
||||
with patch("fenris.tui.query_service_state", return_value={
|
||||
"boot_enabled": True, "timer_active": True,
|
||||
"last_collect_ok": None, "last_collect_age_s": None,
|
||||
"last_collect_reason": None,
|
||||
}), patch("fenris.tui.subprocess.run") as subprocess_run, patch.object(
|
||||
app, "_run_helper"
|
||||
) as run_helper:
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.press("q")
|
||||
assert not app.is_running
|
||||
subprocess_run.assert_not_called()
|
||||
run_helper.assert_not_called()
|
||||
|
||||
conn = sqlite3.connect(db)
|
||||
row = conn.execute(
|
||||
"SELECT started_at, ended_at, end_cause FROM monitoring_periods"
|
||||
).fetchone()
|
||||
conn.close()
|
||||
assert row == ("2026-09-01T09:00:00+00:00", None, None)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_branding_and_one_time_auth_banner(self, tmp_path):
|
||||
"""Identity is visible at launch; auth notice clears once per session."""
|
||||
app = FenrisTuiApp(
|
||||
store_path=tmp_path / "nonexistent.db",
|
||||
refresh_interval_s=0.2,
|
||||
)
|
||||
auth_notice = "privileged actions will prompt for authentication (polkit)"
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
headline = str(app.query_one("#headline-band").render())
|
||||
assert "Fenris — NVMe endurance monitor" in headline
|
||||
assert auth_notice in headline
|
||||
assert "by Bongbetic" in str(app.query_one("#service-strip").render())
|
||||
|
||||
await pilot.pause(0.25)
|
||||
assert auth_notice not in str(app.query_one("#headline-band").render())
|
||||
|
||||
await pilot.pause(0.25)
|
||||
assert auth_notice not in str(app.query_one("#headline-band").render())
|
||||
|
||||
fresh_app = FenrisTuiApp(
|
||||
store_path=tmp_path / "nonexistent.db",
|
||||
refresh_interval_s=0.2,
|
||||
)
|
||||
async with fresh_app.run_test():
|
||||
assert auth_notice in str(fresh_app.query_one("#headline-band").render())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -524,4 +679,3 @@ class TestStateMatrixCombinations:
|
||||
# Incomplete provenance → UNVERIFIED tier
|
||||
assert proj.baseline_tier.value == "unverified_override"
|
||||
conn.close()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user