prototype(tui): throwaway TUI information-architecture prototype (ticket #3)
Three structurally different variants (Panes / Pages / Ledger), switchable live, plus a six-state scenario rotator (steady, warming up, habit changed, stale, no baseline, paused) driving the ADR-0002 section-13 contract and the four separate ADR-0003 section-8 service facts. Pause/resume/collect suspend the TUI and run a polkit stand-in on the real terminal to validate tty passthrough. Headless smoke test + SVG screenshots included.
@@ -0,0 +1,2 @@
|
|||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# Fenris TUI information-architecture PROTOTYPE (throwaway)
|
||||||
|
|
||||||
|
**This is throwaway code answering [ticket #3](https://git.bongbetic.com/xavierk/Fenris/issues/3).** It is not the redesign, reads nothing real, and never ships. Branch: `prototype/tui-information-architecture`.
|
||||||
|
|
||||||
|
## Question
|
||||||
|
|
||||||
|
What screen hierarchy, navigation, and action model makes Fenris's projection contract (ADR 0002 §13), the four separate service facts (ADR 0003 §8), warming-up, unexplained gaps, and changing habits understandable in a keyboard-first terminal?
|
||||||
|
|
||||||
|
## Run (one command)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./run
|
||||||
|
```
|
||||||
|
|
||||||
|
(creates `.venv` and installs `textual` on first use)
|
||||||
|
|
||||||
|
## What to flip through
|
||||||
|
|
||||||
|
**Variants (← / →)** — three structurally different answers, not restylings:
|
||||||
|
|
||||||
|
| Key | Variant | Idea |
|
||||||
|
|-----|---------|------|
|
||||||
|
| A | **Panes** | everything on one dense screen, btop-style; no navigation, panes are zones |
|
||||||
|
| B | **Pages** | persistent three-fact header (lifespan · confidence · freshness) + pages 1–5 |
|
||||||
|
| C | **Ledger** | one scrolling document in reading order, headline sentence first |
|
||||||
|
|
||||||
|
**States (s)** — same variants, six shapes of the contract:
|
||||||
|
|
||||||
|
1. steady · Supported (with one unexplained 3-hour gap)
|
||||||
|
2. warming up · Limited (11 of 14 days)
|
||||||
|
3. habit changed · Limited (regime 6 days old, scenario spread visible)
|
||||||
|
4. stale · Supported→Limited (last collect FAILED, 61 h old)
|
||||||
|
5. no baseline · Unavailable (wear too coarse to imply endurance)
|
||||||
|
6. paused · Limited (period closed by deliberate disable)
|
||||||
|
|
||||||
|
**Actions** — `p` pause (asks confirmation) · `r` resume (doesn't) · `c` collect now (synchronous outcome). Each suspends the TUI and runs `polkit_stub.py` on the real terminal: this validates the tty passthrough ADR 0003 requires for the polkit prompt. Results land in the tty log (variant B service page; every variant's log is the same list).
|
||||||
|
|
||||||
|
## What to react to
|
||||||
|
|
||||||
|
- Which variant's hierarchy matches how you think about the drive? (Mixing — "header from B, density of A" — is a valid answer and the point.)
|
||||||
|
- Are the four service facts separable at a glance?
|
||||||
|
- Do confidence states + contributing facts read as evidence, not as a percentage?
|
||||||
|
- Is the pause confirmation the right amount of friction?
|
||||||
|
- Did the polkit tty stub actually prompt in your terminal? (That's the mechanism check.)
|
||||||
|
|
||||||
|
`screenshots/` holds headless captures (`smoke_test.py`) of each variant at 80×24 and 140×40.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""STUB polkit-agent stand-in for the Fenris TUI prototype (throwaway).
|
||||||
|
|
||||||
|
Runs attached to the real terminal while the Textual app is suspended, exactly
|
||||||
|
where the platform polkit agent would prompt for `com.bongbetic.fenris.monitor`.
|
||||||
|
Accepts any password; the point is validating tty passthrough, not auth.
|
||||||
|
"""
|
||||||
|
import getpass
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
op = sys.argv[1] if len(sys.argv) > 1 else "unknown"
|
||||||
|
print("=" * 56)
|
||||||
|
print(" polkit STUB · com.bongbetic.fenris.monitor")
|
||||||
|
print(f" operation: {op}")
|
||||||
|
print(" Authentication required to manage Fenris monitoring")
|
||||||
|
print("=" * 56)
|
||||||
|
try:
|
||||||
|
getpass.getpass(" password (anything works): ")
|
||||||
|
except (EOFError, KeyboardInterrupt):
|
||||||
|
print("\n(cancelled — operation not performed)")
|
||||||
|
sys.exit(1)
|
||||||
|
time.sleep(0.6) # pretend systemctl + monitoring-period bookkeeping
|
||||||
|
print(f" fenris-monitor {op}: done")
|
||||||
|
try:
|
||||||
|
input(" [press Enter to return to the TUI] ")
|
||||||
|
except EOFError:
|
||||||
|
pass
|
||||||
|
sys.exit(0)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
textual>=0.60
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# PROTOTYPE runner — throwaway, see README.md
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
if [ ! -x .venv/bin/python ]; then
|
||||||
|
if command -v uv >/dev/null 2>&1; then
|
||||||
|
uv venv -q .venv
|
||||||
|
uv pip install -q --python .venv/bin/python -r requirements.txt
|
||||||
|
else
|
||||||
|
python3 -m venv .venv
|
||||||
|
.venv/bin/pip -q install -r requirements.txt
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
exec .venv/bin/python tui_prototype.py
|
||||||
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 42 KiB |
@@ -0,0 +1,67 @@
|
|||||||
|
# Headless smoke test for the prototype: drives every variant × state through
|
||||||
|
# Textual's test pilot, exports SVG screenshots, and asserts contract strings render.
|
||||||
|
import asyncio, inspect, os, sys
|
||||||
|
os.environ["FENRIS_PROTOTYPE_NO_TTY"] = "1"
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
from tui_prototype import FenrisPrototypeApp, VARIANTS
|
||||||
|
|
||||||
|
OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "screenshots")
|
||||||
|
os.makedirs(OUT, exist_ok=True)
|
||||||
|
|
||||||
|
async def snap(app, name):
|
||||||
|
r = app.export_screenshot()
|
||||||
|
if inspect.isawaitable(r):
|
||||||
|
r = await r
|
||||||
|
with open(os.path.join(OUT, name + ".svg"), "w") as f:
|
||||||
|
f.write(r)
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
checks = []
|
||||||
|
for size in [(80, 24), (140, 40)]:
|
||||||
|
app = FenrisPrototypeApp()
|
||||||
|
async with app.run_test(size=size) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
for i, (key, _name) in enumerate(VARIANTS):
|
||||||
|
if i:
|
||||||
|
await pilot.press("right"); await pilot.pause()
|
||||||
|
tag = f"v{key}_{size[0]}x{size[1]}"
|
||||||
|
await snap(app, tag + "_steady")
|
||||||
|
# rotate states on variant B
|
||||||
|
await pilot.press("left"); await pilot.pause() # back to A
|
||||||
|
await pilot.press("right"); await pilot.pause() # B
|
||||||
|
for st in ["warming", "changed", "stale", "nobaseline", "paused", "steady"]:
|
||||||
|
await pilot.press("s"); await pilot.pause()
|
||||||
|
await snap(app, f"vB_{size[0]}x{size[1]}_{st}")
|
||||||
|
# pages on B
|
||||||
|
for k in ["1", "2", "3", "4", "5"]:
|
||||||
|
await pilot.press(k); await pilot.pause()
|
||||||
|
body = app.query_one("#vB-service").content
|
||||||
|
checks.append(("service facts", "boot enablement" in str(body) and "freshness" in str(body)))
|
||||||
|
# pause flow: confirm modal, y, stub skipped headless -> state becomes paused? (skip branch)
|
||||||
|
await pilot.press("4"); await pilot.pause()
|
||||||
|
await pilot.press("p"); await pilot.pause()
|
||||||
|
await pilot.press("n"); await pilot.pause() # cancel, no state change
|
||||||
|
head = str(app.query_one("#vB-overview").content)
|
||||||
|
checks.append(("confidence renders", "Projection confidence" in head))
|
||||||
|
# disclosure modal
|
||||||
|
await pilot.press("d"); await pilot.pause()
|
||||||
|
await pilot.press("escape"); await pilot.pause()
|
||||||
|
steady = FenrisPrototypeApp()
|
||||||
|
async with steady.run_test(size=(140, 40)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
doc = str(steady.query_one("#vC-doc").content)
|
||||||
|
checks += [
|
||||||
|
("headline", "Usage-adjusted theoretical lifespan" in doc),
|
||||||
|
("scenario range", "Scenario range" in doc),
|
||||||
|
("wear line", "Vendor wear" in doc),
|
||||||
|
("gap marker", "unexplained gap" in doc),
|
||||||
|
]
|
||||||
|
failed = [n for n, ok in checks if not ok]
|
||||||
|
print("CHECKS:", "all ok" if not failed else f"FAILED: {failed}")
|
||||||
|
for n, ok in checks:
|
||||||
|
print(f" {'ok ' if ok else 'FAIL'} {n}")
|
||||||
|
if failed:
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,573 @@
|
|||||||
|
# PROTOTYPE (THROWAWAY) — Fenris TUI information-architecture prototype.
|
||||||
|
# Question (ticket #3): what screen hierarchy, navigation, and action model makes
|
||||||
|
# the projection contract (ADR 0002 §13) and the service facts (ADR 0003 §8)
|
||||||
|
# understandable in a keyboard-first terminal?
|
||||||
|
# Plan: three structurally different variants (A Panes / B Pages / C Ledger),
|
||||||
|
# switchable live with ←/→, plus a scenario rotator (s) that drives the same
|
||||||
|
# variants through warming-up / habit-change / stale / paused / no-baseline states.
|
||||||
|
# Data is synthetic but modeled on the real drive (Micron 2400 512GB, ~101 TB
|
||||||
|
# written, 50 % used). Nothing here reads or writes the observation store.
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import random
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from textual.app import App, ComposeResult
|
||||||
|
from textual.binding import Binding
|
||||||
|
from textual.containers import Horizontal, Vertical, VerticalScroll
|
||||||
|
from textual.screen import ModalScreen
|
||||||
|
from textual.widgets import Static
|
||||||
|
|
||||||
|
STUB = Path(__file__).with_name("polkit_stub.py")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- fake data
|
||||||
|
|
||||||
|
DRIVE = {
|
||||||
|
"model": "Micron_2400_MTFDKBA512QFM",
|
||||||
|
"capacity": "512 GB",
|
||||||
|
"percentage_used": 50,
|
||||||
|
"written_tb": 101.1,
|
||||||
|
"temp": 38,
|
||||||
|
"spare": 100,
|
||||||
|
"media_errors": 0,
|
||||||
|
"power_on_hours": 11126,
|
||||||
|
"power_cycles": 5188,
|
||||||
|
"unsafe_shutdowns": 142,
|
||||||
|
}
|
||||||
|
|
||||||
|
BASELINE = {"tb": 220.0, "source": "Micron 2400 datasheet (PROTOTYPE placeholder provenance)"}
|
||||||
|
|
||||||
|
|
||||||
|
def _years(gb_per_day: float) -> str:
|
||||||
|
days = (BASELINE["tb"] - DRIVE["written_tb"]) * 1000.0 / gb_per_day
|
||||||
|
if days >= 365.25:
|
||||||
|
return f"~{days / 365.25:.1f} years"
|
||||||
|
return f"~{days:.0f} days"
|
||||||
|
|
||||||
|
|
||||||
|
def _days_hist(n: int, rate: float, seed: int) -> list[float]:
|
||||||
|
rng = random.Random(seed)
|
||||||
|
return [max(2.0, rate + rng.gauss(0, rate * 0.18)) for _ in range(n)]
|
||||||
|
|
||||||
|
|
||||||
|
def _sparkline(vals: list[float], width: int = 40) -> str:
|
||||||
|
if not vals:
|
||||||
|
return ""
|
||||||
|
mx = max(vals) or 1.0
|
||||||
|
blocks = " ▁▂▃▄▅▆▇█"
|
||||||
|
step = max(1, len(vals) // width or 1)
|
||||||
|
picked = vals[-width * step:][::step][-width:]
|
||||||
|
return "".join(blocks[min(len(blocks) - 1, int(v / mx * (len(blocks) - 1)) + (1 if v > 0 else 0))] for v in picked)
|
||||||
|
|
||||||
|
|
||||||
|
def _habit_bar(active: float, idle: float, off: float, unknown: float, width: int = 44) -> str:
|
||||||
|
total = active + idle + off + unknown or 1.0
|
||||||
|
segs = [("active", active, "green"), ("idle", idle, "yellow"), ("off", off, "cyan"), ("?", unknown, "magenta")]
|
||||||
|
out = []
|
||||||
|
for label, v, color in segs:
|
||||||
|
n = max(1 if v else 0, round(v / total * width))
|
||||||
|
out.append((f"[{color}]{label[0] * n}[/{color}]"))
|
||||||
|
legend = f" active {active / total:.0%} · idle {idle / total:.0%} · powered-off {off / total:.0%} · unknown {unknown / total:.0%}"
|
||||||
|
return "".join(out) + "\n " + legend
|
||||||
|
|
||||||
|
|
||||||
|
def build_scenarios() -> list[dict]:
|
||||||
|
"""Six states of the ADR-0002 §13 contract + ADR-0003 service facts."""
|
||||||
|
hist_steady = _days_hist(34, 55, seed=7)
|
||||||
|
hist_warm = _days_hist(11, 62, seed=11)
|
||||||
|
hist_changed = _days_hist(35, 70, seed=3) + _days_hist(6, 130, seed=4)
|
||||||
|
hist_stale = _days_hist(34, 55, seed=7)
|
||||||
|
hist_nobase = _days_hist(26, 48, seed=5)
|
||||||
|
hist_paused = _days_hist(28, 51, seed=9)
|
||||||
|
|
||||||
|
def svc(enabled, timer, outcome, freshness, period):
|
||||||
|
return {"enabled": enabled, "timer": timer, "outcome": outcome, "freshness": freshness, "period": period}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"key": "steady",
|
||||||
|
"name": "steady · Supported",
|
||||||
|
"days": 34,
|
||||||
|
"history": hist_steady,
|
||||||
|
"rate": 55,
|
||||||
|
"headline": _years(55),
|
||||||
|
"confidence": "Supported",
|
||||||
|
"facts": [
|
||||||
|
"34 qualifying days (≥ 14), coverage 92 %",
|
||||||
|
"7- and 28-day rates within a factor of 2",
|
||||||
|
"no single day ≥ 50 % of trailing 28-day writes",
|
||||||
|
"1 day with 3 unknown hours — unexplained gap inside the period",
|
||||||
|
],
|
||||||
|
"horizons": [("last 7 days", _years(48)), ("last 28 days", _years(57))],
|
||||||
|
"habit": (0.34, 0.52, 0.10, 0.04),
|
||||||
|
"gap_days": {-9},
|
||||||
|
"habit_change": None,
|
||||||
|
"service": svc(True, True, "ok · 3 min ago (5 min cadence)", "fresh · newest sample 3 min old", "open since Aug 3 · deliberate disables: 0"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "warming",
|
||||||
|
"name": "warming up · Limited",
|
||||||
|
"days": 11,
|
||||||
|
"history": hist_warm,
|
||||||
|
"rate": 62,
|
||||||
|
"headline": _years(62),
|
||||||
|
"confidence": "Limited",
|
||||||
|
"facts": [
|
||||||
|
"warming up: 11 of 14 qualifying days",
|
||||||
|
"coverage 84 %",
|
||||||
|
],
|
||||||
|
"horizons": [("last 7 days", _years(66))],
|
||||||
|
"habit": (0.38, 0.46, 0.12, 0.04),
|
||||||
|
"gap_days": set(),
|
||||||
|
"habit_change": None,
|
||||||
|
"service": svc(True, True, "ok · 2 min ago", "fresh · newest sample 2 min old", "open since Aug 20"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "changed",
|
||||||
|
"name": "habit changed · Limited",
|
||||||
|
"days": 41,
|
||||||
|
"history": hist_changed,
|
||||||
|
"rate": 130,
|
||||||
|
"headline": _years(130),
|
||||||
|
"confidence": "Limited",
|
||||||
|
"facts": [
|
||||||
|
"usage habit changed 6 days ago — new regime adopted",
|
||||||
|
"regime 6 days old (young — Limited evidence)",
|
||||||
|
"coverage 88 %",
|
||||||
|
"vendor wear line disagrees ×2.1 with observed write rate",
|
||||||
|
],
|
||||||
|
"horizons": [("last 7 days", _years(128)), ("last 28 days", _years(71))],
|
||||||
|
"habit": (0.47, 0.41, 0.08, 0.04),
|
||||||
|
"gap_days": set(),
|
||||||
|
"habit_change": 6,
|
||||||
|
"service": svc(True, True, "ok · 4 min ago", "fresh · newest sample 4 min old", "open since Jul 15 · habit change noted Aug 26"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "stale",
|
||||||
|
"name": "stale · Supported→Limited",
|
||||||
|
"days": 34,
|
||||||
|
"history": hist_stale,
|
||||||
|
"rate": 55,
|
||||||
|
"headline": _years(55),
|
||||||
|
"confidence": "Limited",
|
||||||
|
"facts": [
|
||||||
|
"newest evidence 61 h old (missed — older than 48 h)",
|
||||||
|
"34 qualifying days, coverage 92 %",
|
||||||
|
],
|
||||||
|
"horizons": [("last 7 days", _years(48)), ("last 28 days", _years(57))],
|
||||||
|
"habit": (0.34, 0.52, 0.10, 0.04),
|
||||||
|
"gap_days": {-9},
|
||||||
|
"habit_change": None,
|
||||||
|
"service": svc(True, True, "FAILED · exit 1 · 61 h ago (device busy)", "missed · newest sample 61 h old", "open since Aug 3 · gap is unknown time inside the period"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "nobaseline",
|
||||||
|
"name": "no baseline · Unavailable",
|
||||||
|
"days": 26,
|
||||||
|
"history": hist_nobase,
|
||||||
|
"rate": 48,
|
||||||
|
"headline": None,
|
||||||
|
"confidence": "Unavailable",
|
||||||
|
"facts": [
|
||||||
|
"no verified rated-TBW override on record",
|
||||||
|
"vendor wear estimate too coarse to imply endurance (1 of ≥ 2 Percentage Used increments)",
|
||||||
|
],
|
||||||
|
"horizons": [("last 7 days", "48 GB/day (no baseline to project)"), ("last 28 days", "44 GB/day (no baseline to project)")],
|
||||||
|
"habit": (0.31, 0.55, 0.10, 0.04),
|
||||||
|
"gap_days": set(),
|
||||||
|
"habit_change": None,
|
||||||
|
"service": svc(True, True, "ok · 3 min ago", "fresh · newest sample 3 min old", "open since Aug 8"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "paused",
|
||||||
|
"name": "paused · Limited",
|
||||||
|
"days": 28,
|
||||||
|
"history": hist_paused,
|
||||||
|
"rate": 51,
|
||||||
|
"headline": _years(51),
|
||||||
|
"confidence": "Limited",
|
||||||
|
"facts": [
|
||||||
|
"no open monitoring period — paused 2 days ago",
|
||||||
|
"paused time is excluded from the usage habit by your choice",
|
||||||
|
],
|
||||||
|
"horizons": [("last 7 days (pre-pause)", _years(53))],
|
||||||
|
"habit": (0.33, 0.51, 0.12, 0.04),
|
||||||
|
"gap_days": set(),
|
||||||
|
"habit_change": None,
|
||||||
|
"service": svc(False, False, "ok · 2 d ago (period closed by pause)", "stale · monitoring paused 2 days ago", "closed 2 days ago · end cause: deliberate disable"),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- renderers
|
||||||
|
|
||||||
|
|
||||||
|
def headline_block(sc: dict) -> str:
|
||||||
|
if sc["headline"]:
|
||||||
|
return (
|
||||||
|
f"[bold]Usage-adjusted theoretical lifespan: [white]{sc['headline']}[/white][/bold]\n"
|
||||||
|
f" if current habits continue · sustained regime: {sc['days'] if not sc['habit_change'] else sc['habit_change']} days at {sc['rate']} GB/day"
|
||||||
|
)
|
||||||
|
return "[bold]Usage-adjusted theoretical lifespan: [red]no projection from this history yet[/red][/bold]\n " + "\n ".join(sc["facts"][:2])
|
||||||
|
|
||||||
|
|
||||||
|
def confidence_block(sc: dict) -> str:
|
||||||
|
color = {"Supported": "green", "Limited": "yellow", "Unavailable": "red"}[sc["confidence"]]
|
||||||
|
lines = [f"[bold]Projection confidence: [{color}]{sc['confidence']}[/{color}][/bold]"]
|
||||||
|
lines += [f" · {f}" for f in sc["facts"]]
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def horizon_block(sc: dict) -> str:
|
||||||
|
rows = [f" {label:<28} → [cyan]{value}[/cyan]" for label, value in sc["horizons"]]
|
||||||
|
return "[bold]Scenario range[/bold] (same endurance, other horizons)\n" + "\n".join(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def wear_line(sc: dict) -> str:
|
||||||
|
return f"Vendor wear: {DRIVE['percentage_used']} % used · {DRIVE['written_tb']} TB of {BASELINE['tb']:.0f} TB rated (context, not a second projection)"
|
||||||
|
|
||||||
|
|
||||||
|
def disclosure_lines() -> list[str]:
|
||||||
|
return [
|
||||||
|
"Rated endurance is a vendor guarantee boundary, not a predicted failure date.",
|
||||||
|
"Powered-off time counts toward the projection while monitoring is enabled; deliberately paused time does not.",
|
||||||
|
"The scenario range is a spread of horizons, not a statistical interval.",
|
||||||
|
f"Baseline provenance: {BASELINE['source']}.",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def history_block(sc: dict, width: int = 60) -> str:
|
||||||
|
vals = sc["history"]
|
||||||
|
spark = _sparkline(vals, width)
|
||||||
|
marks = [" "] * len(spark)
|
||||||
|
if sc["habit_change"]:
|
||||||
|
idx = len(spark) - max(1, round(sc["habit_change"] / max(1, len(vals) // width or 1)))
|
||||||
|
if 0 <= idx < len(marks):
|
||||||
|
marks[idx] = "▲"
|
||||||
|
for g in sc["gap_days"]:
|
||||||
|
idx = len(spark) + g - 1
|
||||||
|
if 0 <= idx < len(marks) and marks[idx] == " ":
|
||||||
|
marks[idx] = "?"
|
||||||
|
head = f"[bold]Usage history[/bold] · {sc['days']} days · {min(vals):.0f}–{max(vals):.0f} GB/day"
|
||||||
|
bar = f"[green]{spark}[/green]"
|
||||||
|
markline = "".join(marks)
|
||||||
|
a, i, o, u = sc["habit"]
|
||||||
|
return head + "\n " + bar + "\n " + markline + " ▲ habit change · ? unexplained gap\n " + _habit_bar(a, i, o, u)
|
||||||
|
|
||||||
|
|
||||||
|
def health_block() -> str:
|
||||||
|
d = DRIVE
|
||||||
|
rows = [
|
||||||
|
f"[bold]Drive health[/bold] · {d['model']}",
|
||||||
|
f" temperature {d['temp']} °C · spare {d['spare']} %",
|
||||||
|
f" media errors {d['media_errors']} · unsafe shutdowns {d['unsafe_shutdowns']}",
|
||||||
|
f" power-on {d['power_on_hours']:,} h · {d['power_cycles']:,} cycles · {d['capacity']}",
|
||||||
|
]
|
||||||
|
return "\n".join(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def service_block(sc: dict) -> str:
|
||||||
|
s = sc["service"]
|
||||||
|
en = "[green]enabled[/green]" if s["enabled"] else "[red]disabled[/red]"
|
||||||
|
tm = "[green]timer active[/green]" if s["timer"] else "[red]timer inactive[/red]"
|
||||||
|
return "\n".join([
|
||||||
|
"[bold]Service[/bold] (four separate facts)",
|
||||||
|
f" boot enablement: {en}",
|
||||||
|
f" runtime activity: {tm}",
|
||||||
|
f" last collect outcome: {s['outcome']}",
|
||||||
|
f" freshness: {s['freshness']}",
|
||||||
|
f" monitoring period: {s['period']}",
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def settings_block() -> str:
|
||||||
|
return "\n".join([
|
||||||
|
"[bold]Settings[/bold] (read view · edit via CLI / drop-ins)",
|
||||||
|
" device: /dev/disk/by-id/nvme-Micron_2400_MTFDKBA512QFM_2341ABCD",
|
||||||
|
" cadence: every 5 min (systemd drop-in to change) · raw retention 14 d",
|
||||||
|
f" endurance baseline: {BASELINE['tb']:.0f} TB rated — {BASELINE['source']}",
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def actions_legend(paused: bool) -> str:
|
||||||
|
resume = "[bold green]r resume[/bold green]" if paused else "r resume"
|
||||||
|
pause = "[bold yellow]p pause[/bold yellow]" if not paused else "p pause"
|
||||||
|
return f"{pause} (asks) · {resume} · c collect now · s state · ←/→ variant · d disclosures · q quit"
|
||||||
|
|
||||||
|
|
||||||
|
VARIANTS = [
|
||||||
|
("A", "Panes — one dense screen"),
|
||||||
|
("B", "Pages — persistent header + tabbed body"),
|
||||||
|
("C", "Ledger — scrolling narrative document"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------- screens
|
||||||
|
|
||||||
|
|
||||||
|
class ConfirmPause(ModalScreen[bool]):
|
||||||
|
"""Pause asks for confirmation (ADR 0003 §8)."""
|
||||||
|
|
||||||
|
BINDINGS = [
|
||||||
|
Binding("y", "yes", "Pause"),
|
||||||
|
Binding("n", "no", "Cancel"),
|
||||||
|
Binding("escape", "no", "Cancel", show=False),
|
||||||
|
]
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Static(
|
||||||
|
"[bold]Pause monitoring?[/bold]\n\n"
|
||||||
|
"This closes the current monitoring period.\n"
|
||||||
|
"Paused time is [bold]excluded[/bold] from your usage habit\n"
|
||||||
|
"(powered-off time would still count).\n\n"
|
||||||
|
"[dim]y pause · n cancel[/dim]",
|
||||||
|
id="confirm-text",
|
||||||
|
)
|
||||||
|
|
||||||
|
def action_yes(self) -> None:
|
||||||
|
self.dismiss(True)
|
||||||
|
|
||||||
|
def action_no(self) -> None:
|
||||||
|
self.dismiss(False)
|
||||||
|
|
||||||
|
|
||||||
|
class Disclosures(ModalScreen):
|
||||||
|
BINDINGS = [Binding("escape", "close", "Close"), Binding("d", "close", "Close")]
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield VerticalScroll(Static("\n".join(["[bold]Disclosures[/bold]"] + [f" · {d}" for d in disclosure_lines()]) + "\n\n[dim]esc to close[/dim]", id="disc-text"), id="disc-wrap")
|
||||||
|
|
||||||
|
def action_close(self) -> None:
|
||||||
|
self.dismiss()
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------- app
|
||||||
|
|
||||||
|
|
||||||
|
class FenrisPrototypeApp(App):
|
||||||
|
TITLE = "Fenris — PROTOTYPE (throwaway)"
|
||||||
|
SUB_TITLE = "TUI information architecture · ticket #3"
|
||||||
|
|
||||||
|
BINDINGS = [
|
||||||
|
Binding("left", "prev_variant", "‹ variant", show=False),
|
||||||
|
Binding("right", "next_variant", "variant ›", show=False),
|
||||||
|
Binding("s", "cycle_state", "state", show=False),
|
||||||
|
Binding("p", "pause", "pause", show=False),
|
||||||
|
Binding("r", "resume", "resume", show=False),
|
||||||
|
Binding("c", "collect", "collect now", show=False),
|
||||||
|
Binding("d", "disclose", "disclosures", show=False),
|
||||||
|
Binding("q", "quit", "quit", show=False),
|
||||||
|
Binding("1", "page('overview')", "overview", show=False),
|
||||||
|
Binding("2", "page('history')", "history", show=False),
|
||||||
|
Binding("3", "page('drive')", "drive", show=False),
|
||||||
|
Binding("4", "page('service')", "service", show=False),
|
||||||
|
Binding("5", "page('settings')", "settings", show=False),
|
||||||
|
Binding("j", "scroll_down", "scroll down", show=False),
|
||||||
|
Binding("k", "scroll_up", "scroll up", show=False),
|
||||||
|
]
|
||||||
|
|
||||||
|
CSS = """
|
||||||
|
#vhost { height: 1fr; }
|
||||||
|
#vA { layout: grid; grid-size: 2 3; grid-columns: 3fr 2fr; grid-rows: 8 1fr 7; height: 1fr; }
|
||||||
|
#vA-head, #vA-bottom { column-span: 2; }
|
||||||
|
#vA-history { overflow-y: auto; }
|
||||||
|
.pane { border: round #555555; padding: 0 1; }
|
||||||
|
#vB-head { height: 6; border-bottom: thick #555555; padding: 0 1; }
|
||||||
|
#vB-body { height: 1fr; padding: 0 1; }
|
||||||
|
#vB-legend { height: 3; }
|
||||||
|
.page { height: 1fr; padding: 0 1; }
|
||||||
|
#vC { height: 1fr; padding: 0 2; }
|
||||||
|
#switchbar { height: 3; dock: bottom; background: $boost; }
|
||||||
|
#sb-variant { width: 1fr; text-style: reverse; }
|
||||||
|
#sb-state { width: 1fr; }
|
||||||
|
#sb-actions { width: 2fr; }
|
||||||
|
#confirm-text { padding: 1 2; }
|
||||||
|
#disc-wrap { padding: 1 2; height: auto; max-height: 80%; }
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.scenarios = build_scenarios()
|
||||||
|
self.scenario_idx = 0
|
||||||
|
self.variant_idx = 0
|
||||||
|
self.b_page = "overview"
|
||||||
|
self.tty_log: list[str] = []
|
||||||
|
|
||||||
|
# ---- composition
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
with Vertical(id="vhost"):
|
||||||
|
with Vertical(id="vA"):
|
||||||
|
yield Static("", id="vA-head", classes="pane")
|
||||||
|
yield Static("", id="vA-history", classes="pane")
|
||||||
|
yield Static("", id="vA-health", classes="pane")
|
||||||
|
yield Static("", id="vA-bottom", classes="pane")
|
||||||
|
with Vertical(id="vB"):
|
||||||
|
yield Static("", id="vB-head")
|
||||||
|
with Vertical(id="vB-body"):
|
||||||
|
yield Static("", id="vB-overview", classes="page")
|
||||||
|
yield Static("", id="vB-history", classes="page")
|
||||||
|
yield Static("", id="vB-drive", classes="page")
|
||||||
|
yield Static("", id="vB-service", classes="page")
|
||||||
|
yield Static("", id="vB-settings", classes="page")
|
||||||
|
yield Static("", id="vB-legend")
|
||||||
|
with VerticalScroll(id="vC"):
|
||||||
|
yield Static("", id="vC-doc")
|
||||||
|
with Horizontal(id="switchbar"):
|
||||||
|
yield Static("", id="sb-variant")
|
||||||
|
yield Static("", id="sb-state")
|
||||||
|
yield Static("", id="sb-actions")
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
for vid in ("vA-head", "vA-history", "vA-health", "vA-bottom"):
|
||||||
|
w = self.query_one(f"#{vid}", Static)
|
||||||
|
w.border_title = {"vA-head": "headline", "vA-history": "usage history", "vA-health": "drive", "vA-bottom": "service + actions"}[vid]
|
||||||
|
self.render_all()
|
||||||
|
|
||||||
|
# ---- helpers
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sc(self) -> dict:
|
||||||
|
return self.scenarios[self.scenario_idx]
|
||||||
|
|
||||||
|
def w(self, vid: str) -> Static:
|
||||||
|
return self.query_one(f"#{vid}", Static)
|
||||||
|
|
||||||
|
def render_all(self) -> None:
|
||||||
|
sc = self.sc
|
||||||
|
paused = not sc["service"]["enabled"]
|
||||||
|
key, name = VARIANTS[self.variant_idx]
|
||||||
|
|
||||||
|
# variant A: everything on one dense screen
|
||||||
|
self.w("vA-head").update(headline_block(sc) + "\n" + confidence_block(sc))
|
||||||
|
self.w("vA-history").update(history_block(sc) + "\n" + horizon_block(sc))
|
||||||
|
self.w("vA-health").update(health_block() + "\n\n" + settings_block())
|
||||||
|
self.w("vA-bottom").update(service_block(sc) + "\n " + actions_legend(paused))
|
||||||
|
|
||||||
|
# variant B: persistent header, tabbed pages
|
||||||
|
head = "\n".join([
|
||||||
|
headline_block(sc),
|
||||||
|
confidence_block(sc).split("\n")[0] + f" · {sc['confidence']}",
|
||||||
|
f"freshness: {sc['service']['freshness']} · wear: {DRIVE['percentage_used']} %",
|
||||||
|
])
|
||||||
|
self.w("vB-head").update(head)
|
||||||
|
self.w("vB-overview").update(confidence_block(sc) + "\n\n" + horizon_block(sc) + "\n\n" + wear_line(sc))
|
||||||
|
self.w("vB-history").update(history_block(sc, 70))
|
||||||
|
self.w("vB-drive").update(health_block() + "\n\n" + wear_line(sc))
|
||||||
|
self.w("vB-service").update(service_block(sc) + "\n\n " + actions_legend(paused) + "\n\n tty log:\n" + ("\n".join(self.tty_log) if self.tty_log else " (no privileged action taken yet)"))
|
||||||
|
self.w("vB-settings").update(settings_block() + "\n\n" + "\n".join(" · " + d for d in disclosure_lines()))
|
||||||
|
self.w("vB-legend").update(f"pages: 1 overview · 2 history · 3 drive · 4 service · 5 settings [now: {self.b_page}]")
|
||||||
|
for p in ("overview", "history", "drive", "service", "settings"):
|
||||||
|
self.w(f"vB-{p}").styles.display = "block" if p == self.b_page else "none"
|
||||||
|
|
||||||
|
# variant C: one scrolling document in reading order
|
||||||
|
doc = "\n\n".join([
|
||||||
|
"[dim]═" * 70 + "[/dim]",
|
||||||
|
headline_block(sc),
|
||||||
|
confidence_block(sc),
|
||||||
|
horizon_block(sc),
|
||||||
|
wear_line(sc),
|
||||||
|
history_block(sc, 70),
|
||||||
|
health_block(),
|
||||||
|
service_block(sc),
|
||||||
|
settings_block(),
|
||||||
|
"[bold]Disclosures[/bold]\n" + "\n".join(" · " + d for d in disclosure_lines()),
|
||||||
|
"[dim]═" * 70 + "[/dim]",
|
||||||
|
])
|
||||||
|
self.w("vC-doc").update(doc)
|
||||||
|
|
||||||
|
# variant visibility + switcher
|
||||||
|
for i, vid in enumerate(("vA", "vB", "vC")):
|
||||||
|
self.query_one(f"#{vid}").styles.display = "block" if i == self.variant_idx else "none"
|
||||||
|
self.w("sb-variant").update(f" ← {key} · {name} → ")
|
||||||
|
self.w("sb-state").update(f" state [{self.scenario_idx + 1}/{len(self.scenarios)}]: {sc['name']} (s to cycle) ")
|
||||||
|
self.w("sb-actions").update(" " + actions_legend(paused))
|
||||||
|
|
||||||
|
# ---- actions
|
||||||
|
|
||||||
|
def action_prev_variant(self) -> None:
|
||||||
|
self.variant_idx = (self.variant_idx - 1) % len(VARIANTS)
|
||||||
|
self.render_all()
|
||||||
|
|
||||||
|
def action_next_variant(self) -> None:
|
||||||
|
self.variant_idx = (self.variant_idx + 1) % len(VARIANTS)
|
||||||
|
self.render_all()
|
||||||
|
|
||||||
|
def action_cycle_state(self) -> None:
|
||||||
|
self.scenario_idx = (self.scenario_idx + 1) % len(self.scenarios)
|
||||||
|
self.render_all()
|
||||||
|
|
||||||
|
def action_page(self, page: str) -> None:
|
||||||
|
if VARIANTS[self.variant_idx][0] != "B":
|
||||||
|
return
|
||||||
|
self.b_page = page
|
||||||
|
self.render_all()
|
||||||
|
|
||||||
|
def action_scroll_down(self) -> None:
|
||||||
|
if VARIANTS[self.variant_idx][0] == "C":
|
||||||
|
self.query_one("#vC").scroll_down(animated=False)
|
||||||
|
|
||||||
|
def action_scroll_up(self) -> None:
|
||||||
|
if VARIANTS[self.variant_idx][0] == "C":
|
||||||
|
self.query_one("#vC").scroll_up(animated=False)
|
||||||
|
|
||||||
|
def action_disclose(self) -> None:
|
||||||
|
self.push_screen(Disclosures())
|
||||||
|
|
||||||
|
def action_pause(self) -> None:
|
||||||
|
self.push_screen(ConfirmPause(), callback=self._pause_confirmed)
|
||||||
|
|
||||||
|
def _pause_confirmed(self, confirmed: bool) -> None:
|
||||||
|
if not confirmed:
|
||||||
|
self.tty_log.append("pause: cancelled at confirmation")
|
||||||
|
self.render_all()
|
||||||
|
return
|
||||||
|
result = self._run_tty_stub("pause (disable --now)")
|
||||||
|
if "OK" in result:
|
||||||
|
self.scenarios[self.scenario_idx]["service"] = {
|
||||||
|
"enabled": False, "timer": False,
|
||||||
|
"outcome": "ok · period closed by pause",
|
||||||
|
"freshness": "paused · no collection while disabled",
|
||||||
|
"period": "closed just now · end cause: deliberate disable",
|
||||||
|
}
|
||||||
|
self.tty_log.append(result)
|
||||||
|
self.render_all()
|
||||||
|
|
||||||
|
def action_resume(self) -> None:
|
||||||
|
result = self._run_tty_stub("resume (enable --now)") # no confirmation (ADR 0003 §8)
|
||||||
|
if "OK" in result or "skipped" in result:
|
||||||
|
self.scenarios[self.scenario_idx]["service"] = {
|
||||||
|
"enabled": True, "timer": True,
|
||||||
|
"outcome": "ok · resumed just now",
|
||||||
|
"freshness": "fresh · collection resuming",
|
||||||
|
"period": "open just now",
|
||||||
|
}
|
||||||
|
self.tty_log.append(result)
|
||||||
|
self.render_all()
|
||||||
|
|
||||||
|
def action_collect(self) -> None:
|
||||||
|
result = self._run_tty_stub("collect now", blocking=True) # synchronous outcome (ADR 0003 §7)
|
||||||
|
self.tty_log.append(result)
|
||||||
|
self.render_all()
|
||||||
|
|
||||||
|
# ---- tty passthrough validation (the point of the stub)
|
||||||
|
|
||||||
|
def _run_tty_stub(self, op: str, blocking: bool = False) -> str:
|
||||||
|
if os.environ.get("FENRIS_PROTOTYPE_NO_TTY") or not sys.stdin.isatty():
|
||||||
|
return f"{op}: tty stub SKIPPED (headless run — mechanism not exercised)"
|
||||||
|
try:
|
||||||
|
with self.suspend():
|
||||||
|
proc = subprocess.run([sys.executable, str(STUB), op])
|
||||||
|
verdict = "OK — suspend + terminal passthrough works" if proc.returncode == 0 else f"FAILED (exit {proc.returncode})"
|
||||||
|
return f"{op}: {verdict}"
|
||||||
|
except Exception as exc: # SuspendNotSupported and friends
|
||||||
|
return f"{op}: suspend failed: {type(exc).__name__} — this terminal may not support passthrough"
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app = FenrisPrototypeApp()
|
||||||
|
print("[fenris prototype] throwaway UI for ticket #3 — variants switch with ←/→, states with s\n")
|
||||||
|
app.run()
|
||||||