diff --git a/prototype/tui-ia/.gitignore b/prototype/tui-ia/.gitignore new file mode 100644 index 0000000..a230a78 --- /dev/null +++ b/prototype/tui-ia/.gitignore @@ -0,0 +1,2 @@ +.venv/ +__pycache__/ diff --git a/prototype/tui-ia/README.md b/prototype/tui-ia/README.md new file mode 100644 index 0000000..cab38aa --- /dev/null +++ b/prototype/tui-ia/README.md @@ -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. diff --git a/prototype/tui-ia/polkit_stub.py b/prototype/tui-ia/polkit_stub.py new file mode 100755 index 0000000..a87bb25 --- /dev/null +++ b/prototype/tui-ia/polkit_stub.py @@ -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) diff --git a/prototype/tui-ia/requirements.txt b/prototype/tui-ia/requirements.txt new file mode 100644 index 0000000..7f46a43 --- /dev/null +++ b/prototype/tui-ia/requirements.txt @@ -0,0 +1 @@ +textual>=0.60 diff --git a/prototype/tui-ia/run b/prototype/tui-ia/run new file mode 100755 index 0000000..8179479 --- /dev/null +++ b/prototype/tui-ia/run @@ -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 diff --git a/prototype/tui-ia/screenshots/vA_140x40_steady.svg b/prototype/tui-ia/screenshots/vA_140x40_steady.svg new file mode 100644 index 0000000..ba945d6 --- /dev/null +++ b/prototype/tui-ia/screenshots/vA_140x40_steady.svg @@ -0,0 +1,224 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Fenris — PROTOTYPE (throwaway) + + + + + + + + + + ╭─ headline ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│Usage-adjusted theoretical lifespan: ~5.9 years│ +│ if current habits continue · sustained regime: 34 days at 55 GB/day│ +│Projection confidence: Supported│ +│ · 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│ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ +╭─ usage history ──────────────────────────────────────────────────────────────────╮╭─ drive ──────────────────────────────────────────────╮ +│Usage history · 34 days · 38–72 GB/day││Drive health · Micron_2400_MTFDKBA512QFM│ +│▆▇▆▆▆▆█▇█▇▇▇▅█▇▇▅▅▆▆▇▇▇▆▇▇▆█▇█▆▆▆▆││ temperature 38 °C · spare 100 %│ +│                         ?           ▲ habit change · ? unexplained gap││ media errors 0 · unsafe shutdowns 142│ +│aaaaaaaaaaaaaaaiiiiiiiiiiiiiiiiiiiiiiioooo??││ power-on 11,126 h · 5,188 cycles · 512 GB│ +│  active 34% · idle 52% · powered-off 10% · unknown 4%│││ +│Scenario range (same endurance, other horizons)││Settings (read view · edit via CLI / drop-ins)│ +│ last 7 days                  → ~6.8 years││ device:│ +│ last 28 days                 → ~5.7 years││/dev/disk/by-id/nvme-Micron_2400_MTFDKBA512QFM_2341A│ +╰──────────────────────────────────────────────────────────────────────────────────╯│BCD│ +│ cadence: every 5 min (systemd drop-in to change) ·│ +│raw retention 14 d│ +│ endurance baseline: 220 TB rated — Micron 2400│ +│datasheet (PROTOTYPE placeholder provenance)│ +╰──────────────────────────────────────────────────────╯ + + + + + + + +╭─ service + actions ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│Service (four separate facts)│ +│ boot enablement: enabled│ +│ runtime activity: timer active│ +│ last collect outcome: ok · 3 min ago (5 min cadence)│ +│ freshness: fresh · newest sample 3 min old│ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ + ← A · Panes — one dense screen →  state [1/6]: steady · Supported (sp pause (asks) · r resume · c collect now · s state · ←/→ variant · d +to cycle) disclosures · q quit + + + + diff --git a/prototype/tui-ia/screenshots/vA_80x24_steady.svg b/prototype/tui-ia/screenshots/vA_80x24_steady.svg new file mode 100644 index 0000000..c31a428 --- /dev/null +++ b/prototype/tui-ia/screenshots/vA_80x24_steady.svg @@ -0,0 +1,157 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Fenris — PROTOTYPE (throwaway) + + + + + + + + + + ╭─ headline ───────────────────────────────────────────────────────────────────╮ +│Usage-adjusted theoretical lifespan: ~5.9 years│ +│ if current habits continue · sustained regime: 34 days at 55 GB/day│ +│Projection confidence: Supported│ +│ · 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│ +╰──────────────────────────────────────────────────────────────────────────────╯ +╭─ usage history ──────────────────────────────╮╭─ drive ──────────────────────╮ +│Usage history · 34 days · 38–72 GB/day││Drive health ·│ +│▆▇▆▆▆▆█▇█▇▇▇▅█▇▇▅▅▆▆▇▇▇▆▇▇▆█▇█▆▆▆▆││Micron_2400_MTFDKBA512QFM│ +│                         ?           ▲ habit││ temperature 38 °C · spare│ +│change · ? unexplained gap││100 %│ +╰──────────────────────────────────────────────╯╰──────────────────────────────╯ +╭─ service + actions ──────────────────────────────────────────────────────────╮ +│Service (four separate facts)│ +│ boot enablement: enabled│ +│ runtime activity: timer active│ +│ last collect outcome: ok · 3 min ago (5 min cadence)│ +│ freshness: fresh · newest sample 3 min old│ +╰──────────────────────────────────────────────────────────────────────────────╯ + ← A · Panes — one state [1/6]: steadyp pause (asks) · r resume · c collect +dense screen → · Supported (s tonow · s state · ←/→ variant · d +cycle) disclosures · q quit + + + diff --git a/prototype/tui-ia/screenshots/vB_140x40_changed.svg b/prototype/tui-ia/screenshots/vB_140x40_changed.svg new file mode 100644 index 0000000..80ee7f7 --- /dev/null +++ b/prototype/tui-ia/screenshots/vB_140x40_changed.svg @@ -0,0 +1,226 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Fenris — PROTOTYPE (throwaway) + + + + + + + + + + ══════════════════════════════════════════════════════════════════════ + +Usage-adjusted theoretical lifespan: ~2.5 years + if current habits continue · sustained regime: 6 days at 130 GB/day + +Projection confidence: Limited + · 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 + +Scenario range (same endurance, other horizons) + last 7 days                  → ~2.5 years + last 28 days                 → ~4.6 years + +Vendor wear: 50 % used · 101.1 TB of 220 TB rated (context, not a second projection) + +Usage history · 41 days · 42–152 GB/day +▄▅▄▅▄▄▅▄▄▅▅▄▅▄▄▄▃▃▃▄▄▄▄▃▄▄▅▄▄▃▄▃▃▅▃▇█▇███ +                                    ▲       ▲ habit change · ? unexplained gap +aaaaaaaaaaaaaaaaaaaaaiiiiiiiiiiiiiiiiiioooo?? +  active 47% · idle 41% · powered-off 8% · unknown 4% + +Drive health · Micron_2400_MTFDKBA512QFM + temperature 38 °C · spare 100 % + media errors 0 · unsafe shutdowns 142 + power-on 11,126 h · 5,188 cycles · 512 GB + +Service (four separate facts) + boot enablement: enabled▆▆ + runtime activity: timer active + last collect outcome: ok · 4 min ago + freshness: fresh · newest sample 4 min old + monitoring period: open since Jul 15 · habit change noted Aug 26 + +Settings (read view · edit via CLI / drop-ins) + device: /dev/disk/by-id/nvme-Micron_2400_MTFDKBA512QFM_2341ABCD + ← C · Ledger — scrolling narrative state [3/6]: habit changed ·p pause (asks) · r resume · c collect now · s state · ←/→ variant · d +document → Limited (s to cycle) disclosures · q quit + + + + diff --git a/prototype/tui-ia/screenshots/vB_140x40_nobaseline.svg b/prototype/tui-ia/screenshots/vB_140x40_nobaseline.svg new file mode 100644 index 0000000..64d4f93 --- /dev/null +++ b/prototype/tui-ia/screenshots/vB_140x40_nobaseline.svg @@ -0,0 +1,225 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Fenris — PROTOTYPE (throwaway) + + + + + + + + + + ══════════════════════════════════════════════════════════════════════ + +Usage-adjusted theoretical lifespan: no projection from this history yet + no verified rated-TBW override on record + vendor wear estimate too coarse to imply endurance (1 of ≥ 2 Percentage Used increments) + +Projection confidence: Unavailable + · no verified rated-TBW override on record + · vendor wear estimate too coarse to imply endurance (1 of ≥ 2 Percentage Used increments) + +Scenario range (same endurance, other horizons) + last 7 days                  → 48 GB/day (no baseline to project) + last 28 days                 → 44 GB/day (no baseline to project) + +Vendor wear: 50 % used · 101.1 TB of 220 TB rated (context, not a second projection) + +Usage history · 26 days · 28–67 GB/day +▅▅▇▄▆▄▇▆█▆▇▆▅▆▅▆▇▆▆█▆▆▆▆▆▆ +                             ▲ habit change · ? unexplained gap +aaaaaaaaaaaaaaiiiiiiiiiiiiiiiiiiiiiiiioooo?? +  active 31% · idle 55% · powered-off 10% · unknown 4% + +Drive health · Micron_2400_MTFDKBA512QFM + temperature 38 °C · spare 100 % + media errors 0 · unsafe shutdowns 142 + power-on 11,126 h · 5,188 cycles · 512 GB + +Service (four separate facts) + boot enablement: enabled + runtime activity: timer active▁▁ + last collect outcome: ok · 3 min ago + freshness: fresh · newest sample 3 min old + monitoring period: open since Aug 8 + +Settings (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 + ← C · Ledger — scrolling narrative state [5/6]: no baseline ·p pause (asks) · r resume · c collect now · s state · ←/→ variant · d +document → Unavailable (s to cycle) disclosures · q quit + + + + diff --git a/prototype/tui-ia/screenshots/vB_140x40_paused.svg b/prototype/tui-ia/screenshots/vB_140x40_paused.svg new file mode 100644 index 0000000..c6c134f --- /dev/null +++ b/prototype/tui-ia/screenshots/vB_140x40_paused.svg @@ -0,0 +1,227 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Fenris — PROTOTYPE (throwaway) + + + + + + + + + + ══════════════════════════════════════════════════════════════════════ + +Usage-adjusted theoretical lifespan: ~6.4 years + if current habits continue · sustained regime: 28 days at 51 GB/day + +Projection confidence: Limited + · no open monitoring period — paused 2 days ago + · paused time is excluded from the usage habit by your choice + +Scenario range (same endurance, other horizons) + last 7 days (pre-pause)      → ~6.1 years + +Vendor wear: 50 % used · 101.1 TB of 220 TB rated (context, not a second projection) + +Usage history · 28 days · 30–65 GB/day +▆▇███▇▇▆▅▆█▇▆▆▇▆▇▇▄▇▅▆▅██▇▆▅ +                               ▲ habit change · ? unexplained gap +aaaaaaaaaaaaaaaiiiiiiiiiiiiiiiiiiiiiiooooo?? +  active 33% · idle 51% · powered-off 12% · unknown 4% + +Drive health · Micron_2400_MTFDKBA512QFM + temperature 38 °C · spare 100 % + media errors 0 · unsafe shutdowns 142 + power-on 11,126 h · 5,188 cycles · 512 GB + +Service (four separate facts) + boot enablement: disabled + runtime activity: timer inactive + last collect outcome: ok · 2 d ago (period closed by pause) + freshness: stale · monitoring paused 2 days ago + monitoring period: closed 2 days ago · end cause: deliberate disable +▇▇ +Settings (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 + endurance baseline: 220 TB rated — Micron 2400 datasheet (PROTOTYPE placeholder provenance) + + ← C · Ledger — scrolling narrative state [6/6]: paused · Limited (s p pause (asks) · r resume · c collect now · s state · ←/→ variant · d +document → to cycle) disclosures · q quit + + + + diff --git a/prototype/tui-ia/screenshots/vB_140x40_stale.svg b/prototype/tui-ia/screenshots/vB_140x40_stale.svg new file mode 100644 index 0000000..e4471d2 --- /dev/null +++ b/prototype/tui-ia/screenshots/vB_140x40_stale.svg @@ -0,0 +1,226 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Fenris — PROTOTYPE (throwaway) + + + + + + + + + + ══════════════════════════════════════════════════════════════════════ + +Usage-adjusted theoretical lifespan: ~5.9 years + if current habits continue · sustained regime: 34 days at 55 GB/day + +Projection confidence: Limited + · newest evidence 61 h old (missed — older than 48 h) + · 34 qualifying days, coverage 92 % + +Scenario range (same endurance, other horizons) + last 7 days                  → ~6.8 years + last 28 days                 → ~5.7 years + +Vendor wear: 50 % used · 101.1 TB of 220 TB rated (context, not a second projection) + +Usage history · 34 days · 38–72 GB/day +▆▇▆▆▆▆█▇█▇▇▇▅█▇▇▅▅▆▆▇▇▇▆▇▇▆█▇█▆▆▆▆ +                         ?           ▲ habit change · ? unexplained gap +aaaaaaaaaaaaaaaiiiiiiiiiiiiiiiiiiiiiiioooo?? +  active 34% · idle 52% · powered-off 10% · unknown 4% + +Drive health · Micron_2400_MTFDKBA512QFM + temperature 38 °C · spare 100 % + media errors 0 · unsafe shutdowns 142 + power-on 11,126 h · 5,188 cycles · 512 GB + +Service (four separate facts) + boot enablement: enabled + runtime activity: timer active + last collect outcome: FAILED · exit 1 · 61 h ago (device busy) + freshness: missed · newest sample 61 h old▄▄ + monitoring period: open since Aug 3 · gap is unknown time inside the period + +Settings (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 + endurance baseline: 220 TB rated — Micron 2400 datasheet (PROTOTYPE placeholder provenance) + ← C · Ledger — scrolling narrative state [4/6]: stale ·p pause (asks) · r resume · c collect now · s state · ←/→ variant · d +document → Supported→Limited (s to cycle) disclosures · q quit + + + + diff --git a/prototype/tui-ia/screenshots/vB_140x40_steady.svg b/prototype/tui-ia/screenshots/vB_140x40_steady.svg new file mode 100644 index 0000000..236056e --- /dev/null +++ b/prototype/tui-ia/screenshots/vB_140x40_steady.svg @@ -0,0 +1,226 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Fenris — PROTOTYPE (throwaway) + + + + + + + + + + ══════════════════════════════════════════════════════════════════════ + +Usage-adjusted theoretical lifespan: ~5.9 years + if current habits continue · sustained regime: 34 days at 55 GB/day + +Projection confidence: Supported + · 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 + +Scenario range (same endurance, other horizons) + last 7 days                  → ~6.8 years + last 28 days                 → ~5.7 years + +Vendor wear: 50 % used · 101.1 TB of 220 TB rated (context, not a second projection) + +Usage history · 34 days · 38–72 GB/day +▆▇▆▆▆▆█▇█▇▇▇▅█▇▇▅▅▆▆▇▇▇▆▇▇▆█▇█▆▆▆▆ +                         ?           ▲ habit change · ? unexplained gap +aaaaaaaaaaaaaaaiiiiiiiiiiiiiiiiiiiiiiioooo?? +  active 34% · idle 52% · powered-off 10% · unknown 4% + +Drive health · Micron_2400_MTFDKBA512QFM + temperature 38 °C · spare 100 % + media errors 0 · unsafe shutdowns 142 + power-on 11,126 h · 5,188 cycles · 512 GB + +Service (four separate facts) + boot enablement: enabled▆▆ + runtime activity: timer active + last collect outcome: ok · 3 min ago (5 min cadence) + freshness: fresh · newest sample 3 min old + monitoring period: open since Aug 3 · deliberate disables: 0 + +Settings (read view · edit via CLI / drop-ins) + device: /dev/disk/by-id/nvme-Micron_2400_MTFDKBA512QFM_2341ABCD + ← C · Ledger — scrolling narrative state [1/6]: steady · Supported (sp pause (asks) · r resume · c collect now · s state · ←/→ variant · d +document → to cycle) disclosures · q quit + + + + diff --git a/prototype/tui-ia/screenshots/vB_140x40_warming.svg b/prototype/tui-ia/screenshots/vB_140x40_warming.svg new file mode 100644 index 0000000..1e43ea9 --- /dev/null +++ b/prototype/tui-ia/screenshots/vB_140x40_warming.svg @@ -0,0 +1,226 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Fenris — PROTOTYPE (throwaway) + + + + + + + + + + ══════════════════════════════════════════════════════════════════════ + +Usage-adjusted theoretical lifespan: ~5.3 years + if current habits continue · sustained regime: 11 days at 62 GB/day + +Projection confidence: Limited + · warming up: 11 of 14 qualifying days + · coverage 84 % + +Scenario range (same endurance, other horizons) + last 7 days                  → ~4.9 years + +Vendor wear: 50 % used · 101.1 TB of 220 TB rated (context, not a second projection) + +Usage history · 11 days · 47–74 GB/day +▆██▇▆▇██▆▆█ +              ▲ habit change · ? unexplained gap +aaaaaaaaaaaaaaaaaiiiiiiiiiiiiiiiiiiiiooooo?? +  active 38% · idle 46% · powered-off 12% · unknown 4% + +Drive health · Micron_2400_MTFDKBA512QFM + temperature 38 °C · spare 100 % + media errors 0 · unsafe shutdowns 142 + power-on 11,126 h · 5,188 cycles · 512 GB + +Service (four separate facts) + boot enablement: enabled + runtime activity: timer active + last collect outcome: ok · 2 min ago + freshness: fresh · newest sample 2 min old + monitoring period: open since Aug 20 +▇▇ +Settings (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 + endurance baseline: 220 TB rated — Micron 2400 datasheet (PROTOTYPE placeholder provenance) + + ← C · Ledger — scrolling narrative state [2/6]: warming up · Limitedp pause (asks) · r resume · c collect now · s state · ←/→ variant · d +document → (s to cycle) disclosures · q quit + + + + diff --git a/prototype/tui-ia/screenshots/vB_80x24_changed.svg b/prototype/tui-ia/screenshots/vB_80x24_changed.svg new file mode 100644 index 0000000..8538a6b --- /dev/null +++ b/prototype/tui-ia/screenshots/vB_80x24_changed.svg @@ -0,0 +1,160 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Fenris — PROTOTYPE (throwaway) + + + + + + + + + + ══════════════════════════════════════════════════════════════════════ + +Usage-adjusted theoretical lifespan: ~2.5 years + if current habits continue · sustained regime: 6 days at 130 GB/day + +Projection confidence: Limited + · 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 + +Scenario range (same endurance, other horizons) + last 7 days                  → ~2.5 years + last 28 days                 → ~4.6 years + +Vendor wear: 50 % used · 101.1 TB of 220 TB rated (context, not a second +projection) + +Usage history · 41 days · 42–152 GB/day +▄▅▄▅▄▄▅▄▄▅▅▄▅▄▄▄▃▃▃▄▄▄▄▃▄▄▅▄▄▃▄▃▃▅▃▇█▇███ +                                    ▲       ▲ habit change · ? unexplained + ← C · Ledger — state [3/6]: habitp pause (asks) · r resume · c collect +scrolling narrativechanged · Limited (snow · s state · ←/→ variant · d +document → to cycle) disclosures · q quit + + + diff --git a/prototype/tui-ia/screenshots/vB_80x24_nobaseline.svg b/prototype/tui-ia/screenshots/vB_80x24_nobaseline.svg new file mode 100644 index 0000000..3c314f1 --- /dev/null +++ b/prototype/tui-ia/screenshots/vB_80x24_nobaseline.svg @@ -0,0 +1,159 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Fenris — PROTOTYPE (throwaway) + + + + + + + + + + ══════════════════════════════════════════════════════════════════════ + +Usage-adjusted theoretical lifespan: no projection from this history yet + no verified rated-TBW override on record + vendor wear estimate too coarse to imply endurance (1 of ≥ 2 Percentage +Used increments) + +Projection confidence: Unavailable + · no verified rated-TBW override on record▅▅ + · vendor wear estimate too coarse to imply endurance (1 of ≥ 2 Percentage +Used increments) + +Scenario range (same endurance, other horizons) + last 7 days                  → 48 GB/day (no baseline to project) + last 28 days                 → 44 GB/day (no baseline to project) + +Vendor wear: 50 % used · 101.1 TB of 220 TB rated (context, not a second +projection) + +Usage history · 26 days · 28–67 GB/day +▅▅▇▄▆▄▇▆█▆▇▆▅▆▅▆▇▆▆█▆▆▆▆▆▆ + ← C · Ledger — state [5/6]: nop pause (asks) · r resume · c collect +scrolling narrativebaseline ·now · s state · ←/→ variant · d +document → Unavailable (s todisclosures · q quit + + + diff --git a/prototype/tui-ia/screenshots/vB_80x24_paused.svg b/prototype/tui-ia/screenshots/vB_80x24_paused.svg new file mode 100644 index 0000000..6bb0ed0 --- /dev/null +++ b/prototype/tui-ia/screenshots/vB_80x24_paused.svg @@ -0,0 +1,161 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Fenris — PROTOTYPE (throwaway) + + + + + + + + + + ══════════════════════════════════════════════════════════════════════ + +Usage-adjusted theoretical lifespan: ~6.4 years + if current habits continue · sustained regime: 28 days at 51 GB/day + +Projection confidence: Limited + · no open monitoring period — paused 2 days ago + · paused time is excluded from the usage habit by your choice + +Scenario range (same endurance, other horizons) + last 7 days (pre-pause)      → ~6.1 years + +Vendor wear: 50 % used · 101.1 TB of 220 TB rated (context, not a second +projection) + +Usage history · 28 days · 30–65 GB/day +▆▇███▇▇▆▅▆█▇▆▆▇▆▇▇▄▇▅▆▅██▇▆▅ +                               ▲ habit change · ? unexplained gap +aaaaaaaaaaaaaaaiiiiiiiiiiiiiiiiiiiiiiooooo?? +  active 33% · idle 51% · powered-off 12% · unknown 4% + + ← C · Ledger — state [6/6]: paused p pause (asks) · r resume · c collect +scrolling narrative· Limited (s tonow · s state · ←/→ variant · d +document → cycle) disclosures · q quit + + + diff --git a/prototype/tui-ia/screenshots/vB_80x24_stale.svg b/prototype/tui-ia/screenshots/vB_80x24_stale.svg new file mode 100644 index 0000000..4f2b7bb --- /dev/null +++ b/prototype/tui-ia/screenshots/vB_80x24_stale.svg @@ -0,0 +1,162 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Fenris — PROTOTYPE (throwaway) + + + + + + + + + + ══════════════════════════════════════════════════════════════════════ + +Usage-adjusted theoretical lifespan: ~5.9 years + if current habits continue · sustained regime: 34 days at 55 GB/day + +Projection confidence: Limited + · newest evidence 61 h old (missed — older than 48 h) + · 34 qualifying days, coverage 92 % +▂▂ +Scenario range (same endurance, other horizons) + last 7 days                  → ~6.8 years + last 28 days                 → ~5.7 years + +Vendor wear: 50 % used · 101.1 TB of 220 TB rated (context, not a second +projection) + +Usage history · 34 days · 38–72 GB/day +▆▇▆▆▆▆█▇█▇▇▇▅█▇▇▅▅▆▆▇▇▇▆▇▇▆█▇█▆▆▆▆ +                         ?           ▲ habit change · ? unexplained gap +aaaaaaaaaaaaaaaiiiiiiiiiiiiiiiiiiiiiiioooo?? +  active 34% · idle 52% · powered-off 10% · unknown 4% + ← C · Ledger — state [4/6]: stalep pause (asks) · r resume · c collect +scrolling narrative· Supported→Limitednow · s state · ←/→ variant · d +document → (s to cycle) disclosures · q quit + + + diff --git a/prototype/tui-ia/screenshots/vB_80x24_steady.svg b/prototype/tui-ia/screenshots/vB_80x24_steady.svg new file mode 100644 index 0000000..3806dac --- /dev/null +++ b/prototype/tui-ia/screenshots/vB_80x24_steady.svg @@ -0,0 +1,160 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Fenris — PROTOTYPE (throwaway) + + + + + + + + + + ══════════════════════════════════════════════════════════════════════ + +Usage-adjusted theoretical lifespan: ~5.9 years + if current habits continue · sustained regime: 34 days at 55 GB/day + +Projection confidence: Supported + · 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 + +Scenario range (same endurance, other horizons) + last 7 days                  → ~6.8 years + last 28 days                 → ~5.7 years + +Vendor wear: 50 % used · 101.1 TB of 220 TB rated (context, not a second +projection) + +Usage history · 34 days · 38–72 GB/day +▆▇▆▆▆▆█▇█▇▇▇▅█▇▇▅▅▆▆▇▇▇▆▇▇▆█▇█▆▆▆▆ +                         ?           ▲ habit change · ? unexplained gap + ← C · Ledger — state [1/6]: steadyp pause (asks) · r resume · c collect +scrolling narrative· Supported (s tonow · s state · ←/→ variant · d +document → cycle) disclosures · q quit + + + diff --git a/prototype/tui-ia/screenshots/vB_80x24_warming.svg b/prototype/tui-ia/screenshots/vB_80x24_warming.svg new file mode 100644 index 0000000..4e0fc66 --- /dev/null +++ b/prototype/tui-ia/screenshots/vB_80x24_warming.svg @@ -0,0 +1,161 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Fenris — PROTOTYPE (throwaway) + + + + + + + + + + ══════════════════════════════════════════════════════════════════════ + +Usage-adjusted theoretical lifespan: ~5.3 years + if current habits continue · sustained regime: 11 days at 62 GB/day + +Projection confidence: Limited + · warming up: 11 of 14 qualifying days + · coverage 84 % + +Scenario range (same endurance, other horizons) + last 7 days                  → ~4.9 years + +Vendor wear: 50 % used · 101.1 TB of 220 TB rated (context, not a second +projection) + +Usage history · 11 days · 47–74 GB/day +▆██▇▆▇██▆▆█ +              ▲ habit change · ? unexplained gap +aaaaaaaaaaaaaaaaaiiiiiiiiiiiiiiiiiiiiooooo?? +  active 38% · idle 46% · powered-off 12% · unknown 4% + + ← C · Ledger — state [2/6]:p pause (asks) · r resume · c collect +scrolling narrativewarming up · Limitednow · s state · ←/→ variant · d +document → (s to cycle) disclosures · q quit + + + diff --git a/prototype/tui-ia/screenshots/vC_140x40_steady.svg b/prototype/tui-ia/screenshots/vC_140x40_steady.svg new file mode 100644 index 0000000..236056e --- /dev/null +++ b/prototype/tui-ia/screenshots/vC_140x40_steady.svg @@ -0,0 +1,226 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Fenris — PROTOTYPE (throwaway) + + + + + + + + + + ══════════════════════════════════════════════════════════════════════ + +Usage-adjusted theoretical lifespan: ~5.9 years + if current habits continue · sustained regime: 34 days at 55 GB/day + +Projection confidence: Supported + · 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 + +Scenario range (same endurance, other horizons) + last 7 days                  → ~6.8 years + last 28 days                 → ~5.7 years + +Vendor wear: 50 % used · 101.1 TB of 220 TB rated (context, not a second projection) + +Usage history · 34 days · 38–72 GB/day +▆▇▆▆▆▆█▇█▇▇▇▅█▇▇▅▅▆▆▇▇▇▆▇▇▆█▇█▆▆▆▆ +                         ?           ▲ habit change · ? unexplained gap +aaaaaaaaaaaaaaaiiiiiiiiiiiiiiiiiiiiiiioooo?? +  active 34% · idle 52% · powered-off 10% · unknown 4% + +Drive health · Micron_2400_MTFDKBA512QFM + temperature 38 °C · spare 100 % + media errors 0 · unsafe shutdowns 142 + power-on 11,126 h · 5,188 cycles · 512 GB + +Service (four separate facts) + boot enablement: enabled▆▆ + runtime activity: timer active + last collect outcome: ok · 3 min ago (5 min cadence) + freshness: fresh · newest sample 3 min old + monitoring period: open since Aug 3 · deliberate disables: 0 + +Settings (read view · edit via CLI / drop-ins) + device: /dev/disk/by-id/nvme-Micron_2400_MTFDKBA512QFM_2341ABCD + ← C · Ledger — scrolling narrative state [1/6]: steady · Supported (sp pause (asks) · r resume · c collect now · s state · ←/→ variant · d +document → to cycle) disclosures · q quit + + + + diff --git a/prototype/tui-ia/screenshots/vC_80x24_steady.svg b/prototype/tui-ia/screenshots/vC_80x24_steady.svg new file mode 100644 index 0000000..3806dac --- /dev/null +++ b/prototype/tui-ia/screenshots/vC_80x24_steady.svg @@ -0,0 +1,160 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Fenris — PROTOTYPE (throwaway) + + + + + + + + + + ══════════════════════════════════════════════════════════════════════ + +Usage-adjusted theoretical lifespan: ~5.9 years + if current habits continue · sustained regime: 34 days at 55 GB/day + +Projection confidence: Supported + · 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 + +Scenario range (same endurance, other horizons) + last 7 days                  → ~6.8 years + last 28 days                 → ~5.7 years + +Vendor wear: 50 % used · 101.1 TB of 220 TB rated (context, not a second +projection) + +Usage history · 34 days · 38–72 GB/day +▆▇▆▆▆▆█▇█▇▇▇▅█▇▇▅▅▆▆▇▇▇▆▇▇▆█▇█▆▆▆▆ +                         ?           ▲ habit change · ? unexplained gap + ← C · Ledger — state [1/6]: steadyp pause (asks) · r resume · c collect +scrolling narrative· Supported (s tonow · s state · ←/→ variant · d +document → cycle) disclosures · q quit + + + diff --git a/prototype/tui-ia/smoke_test.py b/prototype/tui-ia/smoke_test.py new file mode 100644 index 0000000..6be34de --- /dev/null +++ b/prototype/tui-ia/smoke_test.py @@ -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()) diff --git a/prototype/tui-ia/tui_prototype.py b/prototype/tui-ia/tui_prototype.py new file mode 100644 index 0000000..767006c --- /dev/null +++ b/prototype/tui-ia/tui_prototype.py @@ -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()