From 7bbe5cede7af9ae5f587a0ac40e695c6227208fc Mon Sep 17 00:00:00 2001 From: xavierk Date: Thu, 10 Sep 2026 13:28:05 +0530 Subject: [PATCH] feat(tui): identify Fenris and explain polkit authentication --- README.md | 6 ++++++ src/fenris/tui.py | 51 +++++++++++++++++++++++++++++++++----------- tests/test_status.py | 23 ++++++++++++++++++++ tests/test_tui.py | 29 +++++++++++++++++++++++++ 4 files changed, 96 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 39eb768..6ec24ce 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,12 @@ 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. + +- **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: diff --git a/src/fenris/tui.py b/src/fenris/tui.py index cfaf884..d744633 100644 --- a/src/fenris/tui.py +++ b/src/fenris/tui.py @@ -286,8 +286,8 @@ 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 { @@ -317,12 +317,15 @@ 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) @@ -339,8 +342,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: @@ -368,25 +393,27 @@ class FenrisTuiApp(App): """Render empty store greeting or store fault.""" 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" + "boot: disabled · timer: inactive · last collect: unknown · freshness: empty · " + "[dim]by Bongbetic[/dim]\n" "p pause · r resume · c collect · d disclosures · q quit" ) 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( + "[dim]by Bongbetic[/dim]\n" "p pause · r resume · c collect · d disclosures · q quit" ) @@ -398,13 +425,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,14 +471,16 @@ 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" "%s\n" "p pause · r resume · c collect · d disclosures · q quit" % (boot, activity, collect, freshness, svc.get("period", "")) ) except Exception: self.query_one("#service-strip").update( - "boot: unknown · timer: unknown · last collect: unknown · freshness: unknown\n" + "boot: unknown · timer: unknown · last collect: unknown · freshness: unknown · " + "[dim]by Bongbetic[/dim]\n" "p pause · r resume · c collect · d disclosures · q quit" ) diff --git a/tests/test_status.py b/tests/test_status.py index d3cd5cd..7352913 100644 --- a/tests/test_status.py +++ b/tests/test_status.py @@ -441,6 +441,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 diff --git a/tests/test_tui.py b/tests/test_tui.py index 8d20e38..c31f6e5 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -384,6 +384,35 @@ 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_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()) # ---------------------------------------------------------------------------