feat(tui): identify Fenris and explain polkit authentication

This commit is contained in:
xavierk
2026-09-10 13:28:05 +05:30
parent bb5bc9a72e
commit 7bbe5cede7
4 changed files with 96 additions and 13 deletions
+6
View File
@@ -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 start` / `stop` / `run` | Rejected with a one-line migration pointer — never aliased. |
| `fenris --device` | Rejected with a pointer to the configuration file. | | `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 ## Retired menu options
The legacy `fenris.sh` menu script and the `fenris.py` monolith have been removed. Here's where the old options went: The legacy `fenris.sh` menu script and the `fenris.py` monolith have been removed. Here's where the old options went:
+38 -13
View File
@@ -286,8 +286,8 @@ class DisclosuresScreen(ModalScreen[None]):
class FenrisTuiApp(App): class FenrisTuiApp(App):
"""Fenris Panes TUI — keyboard-first, one dense screen (spec §7).""" """Fenris Panes TUI — keyboard-first, one dense screen (spec §7)."""
TITLE = "Fenris" TITLE = "Fenris — NVMe endurance monitor"
SUB_TITLE = "NVMe endurance monitor" SUB_TITLE = ""
CSS = """ CSS = """
#main-grid { #main-grid {
@@ -317,12 +317,15 @@ class FenrisTuiApp(App):
store_path: Optional[Path] = None, store_path: Optional[Path] = None,
config_path: Optional[Path] = None, config_path: Optional[Path] = None,
helper_path: Optional[str] = None, helper_path: Optional[str] = None,
refresh_interval_s: float = CADENCE_DEFAULT_S,
**kwargs, **kwargs,
) -> None: ) -> None:
super().__init__(**kwargs) super().__init__(**kwargs)
self.store_path = store_path or Path("/var/lib/fenris/observations.db") self.store_path = store_path or Path("/var/lib/fenris/observations.db")
self.config_path = config_path self.config_path = config_path
self.helper_path = helper_path or "/usr/libexec/fenris/fenris-monitor" 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._conn: Optional[sqlite3.Connection] = None
self._clock_now = datetime.now(timezone.utc) 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("#usage-history").border_title = "usage history"
self.query_one("#drive-health").border_title = "drive" self.query_one("#drive-health").border_title = "drive"
self.query_one("#service-strip").border_title = "service + actions" 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() 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]: def _open_store(self) -> Optional[sqlite3.Connection]:
"""Open store read-only, handling faults.""" """Open store read-only, handling faults."""
try: try:
@@ -368,25 +393,27 @@ class FenrisTuiApp(App):
"""Render empty store greeting or store fault.""" """Render empty store greeting or store fault."""
if not self.store_path.exists(): if not self.store_path.exists():
# Empty store — greeting with enable hint (IN-3) # Empty store — greeting with enable hint (IN-3)
self.query_one("#headline-band").update( self._render_headline(
"[bold]No observations yet[/bold]\n\n" "[bold]No observations yet[/bold]\n\n"
"Enable monitoring: fenris monitor resume" "Enable monitoring: fenris monitor resume"
) )
self.query_one("#usage-history").update("") self.query_one("#usage-history").update("")
self.query_one("#drive-health").update("") self.query_one("#drive-health").update("")
self.query_one("#service-strip").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" "p pause · r resume · c collect · d disclosures · q quit"
) )
else: else:
# Store fault (FL-4) # Store fault (FL-4)
self.query_one("#headline-band").update( self._render_headline(
"[bold red]Observation store unreadable[/bold red]\n" "[bold red]Observation store unreadable[/bold red]\n"
"Check journalctl -u fenris-collect.service" "Check journalctl -u fenris-collect.service"
) )
self.query_one("#usage-history").update("") self.query_one("#usage-history").update("")
self.query_one("#drive-health").update("") self.query_one("#drive-health").update("")
self.query_one("#service-strip").update( self.query_one("#service-strip").update(
"[dim]by Bongbetic[/dim]\n"
"p pause · r resume · c collect · d disclosures · q quit" "p pause · r resume · c collect · d disclosures · q quit"
) )
@@ -398,13 +425,9 @@ class FenrisTuiApp(App):
headline = self._format_headline(proj) headline = self._format_headline(proj)
confidence = self._format_confidence(proj) confidence = self._format_confidence(proj)
scenario = self._format_scenario(proj) scenario = self._format_scenario(proj)
self.query_one("#headline-band").update( self._render_headline(headline + "\n" + confidence + "\n" + scenario)
headline + "\n" + confidence + "\n" + scenario
)
except Exception: except Exception:
self.query_one("#headline-band").update( self._render_headline("[bold]No projection available[/bold]")
"[bold]No projection available[/bold]"
)
# --- Usage-history pane (§7.2 left) --- # --- Usage-history pane (§7.2 left) ---
history = _query_usage_history(conn) history = _query_usage_history(conn)
@@ -448,14 +471,16 @@ class FenrisTuiApp(App):
collect = "ok" if svc.get("last_collect_ok") else "FAILED" collect = "ok" if svc.get("last_collect_ok") else "FAILED"
freshness = svc.get("freshness", "unknown") freshness = svc.get("freshness", "unknown")
self.query_one("#service-strip").update( 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" "%s\n"
"p pause · r resume · c collect · d disclosures · q quit" "p pause · r resume · c collect · d disclosures · q quit"
% (boot, activity, collect, freshness, svc.get("period", "")) % (boot, activity, collect, freshness, svc.get("period", ""))
) )
except Exception: except Exception:
self.query_one("#service-strip").update( 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" "p pause · r resume · c collect · d disclosures · q quit"
) )
+23
View File
@@ -441,6 +441,29 @@ class TestStatusOutput:
assert isinstance(result, str) assert isinstance(result, str)
assert len(result) > 0 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): def test_status_never_writes(self, tmp_path):
"""Status never writes to the store.""" """Status never writes to the store."""
from fenris.status import get_status from fenris.status import get_status
+29
View File
@@ -384,6 +384,35 @@ class TestDenseScreen:
assert "timer:" in strip assert "timer:" in strip
assert "last collect:" in strip assert "last collect:" in strip
assert "freshness:" 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())
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------