diff --git a/Makefile b/Makefile
index 2eb9814..8c2dff9 100644
--- a/Makefile
+++ b/Makefile
@@ -122,7 +122,7 @@ upgrade: dist/fenris-*.whl
@echo "=== Installing new wheel with pinned dependencies ==="
@sudo $(VENV_DIR)/bin/pip install dist/fenris-*.whl --quiet
- @echo "=== Syncing units ==="
+ @echo "=== Syncing units against manifest ==="
@sudo install -m 0644 units/fenris-collect.timer $(UNIT_DIR)/
@sudo install -m 0644 units/fenris-collect.service $(UNIT_DIR)/
@sudo install -m 0644 polkit/com.bongbetic.fenris.monitor.policy $(POLKIT_DIR)/
@@ -146,10 +146,21 @@ upgrade: dist/fenris-*.whl
@echo "$(CONF_DIR)" | sudo tee -a $(MANIFEST) > /dev/null
@echo "$(MANIFEST)" | sudo tee -a $(MANIFEST) > /dev/null
- @if systemctl is-active --quiet fenris-collect.timer; then \
- echo "=== Restarting timer (active) ==="; \
- sudo systemctl restart fenris-collect.timer; \
- fi
+ @echo "=== Restarting timer only if contents changed and active (IN-5) ==="
+ @for unit in fenris-collect.timer fenris-collect.service; do \
+ TMPFILE=$$(mktemp); \
+ sudo systemctl cat $$unit > $$TMPFILE 2>/dev/null || true; \
+ if ! diff -q $$TMPFILE $(UNIT_DIR)/$$unit > /dev/null 2>&1; then \
+ if systemctl is-active --quiet $$unit; then \
+ echo " $$unit changed and active β restarting"; \
+ sudo systemctl restart $$unit; \
+ fi; \
+ fi; \
+ rm -f $$TMPFILE; \
+ done
+
+ @echo "=== Applying forward-only schema migrations (IN-5, IN-6) ==="
+ @sudo $(VENV_DIR)/bin/python3 -c "from fenris.store import migrate_to_latest; from pathlib import Path; n = migrate_to_latest(Path('$(DATA_DIR)/observations.db')); print(f' Migration steps applied: {n}') if n else print(' Schema already current')"
@echo "=== Upgrade complete ==="
diff --git a/README.md b/README.md
index e3af9e4..a444334 100644
--- a/README.md
+++ b/README.md
@@ -1,157 +1,126 @@
-
-
-
-
-
-
- crafted with stubborn curiosity by Bongbetic
-
+# Fenris πΊ
-
-
-
+*Observes an NVMe drive's real-world use and translates that history into an understandable endurance outlook.*
-
Fenris πΊ β Your SSD's Tell-All Diary
-
-
- Your NVMe drive has been keeping secrets. Fenris makes it confess β in real time.
-
- How much did you write today? How long until it taps out? No fairy dust β just your actual bytes.
-
+Fenris is a persistent TUI monitor backed by a short-lived privileged collector on a systemd timer. It reads SMART data every few minutes, stores compact observation history in SQLite, and recomputes a usage-adjusted theoretical lifespan on every screen render β no fairy dust, just your actual bytes.
---
-Fenris is a tiny, stubborn daemon that eavesdrops on your NVMe drive's SMART gossip, writes it down every few minutes, and serves you a live dashboard that actually means something. Not "vibes" β **real GB written in the last 24 hours, real GB/hour, and a real countdown in hours, days, and years until your drive's endurance runs out**.
+## Requirements
-> Think of it as a Fitbit for your SSD. Except it doesn't nag you to drink water.
+- **Python β₯ 3.9** (verified at install time)
+- **smartmontools** (`smartctl` β verified at install time)
+- **systemd** with a polkit agent (the collector runs as root oneshot; elevation is exclusively polkit)
-## What it actually does (no hand-waving)
+No other OS packages or Python dependencies beyond [Textual](https://textual.textualize.io/) (pinned in the lockfile).
-- **Listens** β polls `smartctl -j` on your NVMe device (default every 5 minutes, you pick).
-- **Remembers** β appends every sample to `data/history.jsonl` and rolls up per-hour totals into `data/hourly.jsonl` (survives restarts, rebuilds itself if you yank the power).
-- **Calculates** β rolling 24-hour window: *exact* bytes written in the last 24h, GB/h, GB/day, implied total TBW from `percentage_used`, remaining TB, and a projected life-remaining breakdown. Warming-up badge until it has 24h of coverage β no fake confidence.
-- **Shows off** β dense, live dashboard with wear-over-time + trailing-24h per-hour bars, sticky header, live countdown, and stale warnings if the daemon dozes off.
-
-## You need
-
-- **Python 3.7+**
-- **smartmontools** (`smartctl`)
-- Root-ish access to read NVMe SMART (passwordless `smartctl` or just run with `sudo` β your call)
-
-### The sudo dance (one time)
-
-Fenris runs `sudo -n smartctl ...` so it doesn't get stuck asking for a password mid-nap:
+## Install
```bash
-sudo visudo
-# add this line (swap in your username):
-youruser ALL=(root) NOPASSWD: /usr/sbin/smartctl
+sudo make install
```
-No sudo? Run the whole thing with `sudo` and it'll still behave.
+What it does:
+1. Builds a wheel from the checkout and installs it β with pinned dependencies β into the dedicated venv at `/opt/fenris`.
+2. Places the `fenris` wrapper in `/usr/local/bin`, helpers in `/usr/libexec/fenris`, systemd units in `/etc/systemd/system`, and the polkit policy in `/usr/share/polkit-1/actions/`.
+3. Creates `/var/lib/fenris` (root-written, group-readable) β the observation store is created lazily by the first collection run.
+4. Records every placed file in a manifest consumed by upgrade and uninstall.
+5. Detects `./data/history.jsonl` beside the source checkout and runs the idempotent legacy import if present.
-## Get it running β 30 seconds
-
-### The cozy way
+**A fresh install is fully dormant.** Units are present but disabled; nothing runs. The only opt-in is the sanctioned toggle:
```bash
-./fenris.sh
-# pick 1) Start monitoring β choose device / interval / port β done
+fenris monitor resume # enable timer + open first monitoring period
+fenris monitor pause # close the period, disable timer
```
-### The no-nonsense way
+## Upgrade
```bash
-python3 fenris.py start # defaults: /dev/nvme0, every 300s, port 8420
-python3 fenris.py start --interval 60 --port 9000 # if you're impatient
-python3 fenris.py status # "are we live? how's the drive?"
-python3 fenris.py sample # one sneaky sample right now
-python3 fenris.py stop # tuck it back in
+sudo make upgrade
```
-Dashboard lives at **http://localhost:8420** (or whatever port you chose).
+What it does:
+1. Snapshots `observations.db` to a one-generation backup (`.bak`).
+2. Installs the new wheel into the same venv with pinned dependencies.
+3. Syncs units and polkit against the manifest; runs `daemon-reload`.
+4. Restarts the timer **only** if unit contents changed **and** it is active β a running collection run finishes on its mapped interpreter; the next run uses the new code.
+5. Applies forward-only schema migrations (the store directory is never rebuilt; automatic downgrade does not exist).
-## The menu, demystified
+Rollback: reinstall the previous version and restore `observations.db.bak`.
-Run `./fenris.sh` and you'll get:
+## Uninstall and purge
-```
- 1) Start monitoring (background daemon + dashboard)
- 2) Stop monitoring
- 3) Status / current wear stats
- 4) Take one sample right now
- 5) Open dashboard URL
- ---
- h) Help / how this works
- q) Exit (go touch grass)
+```bash
+make uninstall # removes artifacts, preserves config and observation history
+make purge # also removes /etc/fenris and /var/lib/fenris
```
-## What Fenris jots down
+Uninstall performs the sanctioned disable first (`fenris-monitor disable --now`) β an open period closes `user_disabled` β then removes the venv, helpers, units, polkit policy, and wrapper while keeping `/etc/fenris` and the observation store. Reinstalling resumes from the preserved store.
-| Field | What's the gossip? |
-|-------|---------------------|
-| `percentage_used` | The drive's own wear-o-meter (0β100%) |
-| `bytes_written` / `bytes_read` | Lifetime totals β the receipts |
-| `available_spare` | Spare blocks left (%) |
-| `media_errors` | Uncorrectable boo-boos |
-| `power_on_hours` | How long it's been awake |
-| `temperature_c` | Is it sweating? |
-| `critical_warning` | NVMe's panic flags |
+## Cadence drop-ins
-Hourly rollups also stash `bytes_written` per hour, `pct_start`/`pct_end`, and temp peaks β so the 24h math stays honest.
+The default collection cadence is **5 minutes** (`OnUnitInactiveSec=5min` in the timer unit). To change it, place a systemd drop-in:
-## The dashboard β what's on screen
+```bash
+sudo systemctl edit fenris-collect.timer
+# Add:
+# [Timer]
+# OnUnitInactiveSec=10min
+```
-- **Hero card: Projected life remaining** β big, friendly `361 d 2 h` (plus `β 361 days Β· β 8666 hours Β· β 0.99 years`), backed by `~280 GB/day` and `~101 TB left of ~202 TB total` on the test box.
-- **Data written (24h)** β exact GB in the rolling window + coverage (`10.4h of 24h` until warmed up).
-- **Write rate** β GB/h and GB/day, live.
-- **Wear, spare, temp, errors, power-on** β the usual suspects, with progress bars and polite color-coding.
-- **Two charts, side by side:** wear over time + trailing-24h hourly write bars (with a cheeky "now" bar for the current partial hour).
-- **Live plumbing:** polling synced to your interval, ETag-cached, countdown to next sample, warming-up + stale banners, pauses when you hide the tab (saves your battery, you're welcome).
+No interval key exists in `/etc/fenris/fenris.conf`. Cadence is a systemd concern, not a Fenris configuration key.
-**API for the tinkerers:** `GET /api/data` Β· `/api/hourly` Β· `/api/summary` Β· `/api/config` Β· `/api/status` β all JSON, all friendly.
+## CLI reference
+
+| Command | Behavior |
+|---|---|
+| `fenris` | Opens the TUI (no arguments). |
+| `fenris status` | Projection facts, enabled/active state, last collect outcome, journal hint on failure or staleness. Never auto-samples. |
+| `fenris sample` | On-demand collection via the privileged helper. Blocks until the run completes. |
+| `fenris monitor pause` | Sanctioned disable β asks for confirmation, then disables the timer and closes the monitoring period. |
+| `fenris monitor resume` | Sanctioned enable β enables the timer and opens a monitoring period. No confirmation. |
+| `fenris baseline set ` | CLI-side validation, then polkit-guarded persistence. |
+| `fenris baseline clear` | Remove the endurance baseline. |
+| `fenris import ` | Idempotent single-transaction legacy import. |
+| `fenris start` / `stop` / `run` | Rejected with a one-line migration pointer β never aliased. |
+| `fenris --device` | Rejected with a pointer to the configuration file. |
+
+## Retired menu options
+
+The legacy `fenris.sh` menu script and the `fenris.py` monolith have been removed. Here's where the old options went:
+
+| Legacy option | Successor |
+|---|---|
+| 1) Start monitoring | `fenris monitor resume` |
+| 2) Stop monitoring | `fenris monitor pause` |
+| 3) Status / current wear stats | `fenris status` |
+| 4) Take one sample right now | `fenris sample` |
+| 5) Open dashboard URL | Removed β the HTML dashboard and HTTP server are gone; the TUI is the primary interface. |
+
+## Configuration
+
+`/etc/fenris/fenris.conf` holds exactly one key β the device selector:
+
+```
+device = /dev/disk/by-id/nvme-Samsung_SSD_980_PRO_2TB_S6BENS0Txxxxx
+```
+
+Use a stable `/dev/disk/by-id/` path. Raw `/dev/nvmeX` paths are warned against. The file is re-read every collection run.
## Where's my stuff?
-```
-fenris/
-βββ fenris.py # the whole show β daemon + server + math
-βββ fenris.sh # the cozy menu
-βββ README.md # hi β you're here
-βββ assets/bongbetic-brand/ # Bongbetic wordmarks & glyphs (for Gitea + dashboard)
-βββ data/
- βββ history.jsonl # raw samples (JSONL, append-only)
- βββ hourly.jsonl # per-hour rollups (auto-rebuilt on restart)
- βββ fenris.pid # daemon PID
- βββ fenris.log # daemon chatter
-```
-
-## CLI cheat sheet
-
-```bash
-python3 fenris.py start [--device /dev/nvme0] [--interval 300] [--port 8420]
-python3 fenris.py stop
-python3 fenris.py status
-python3 fenris.py sample [--device /dev/nvme0]
-python3 fenris.py run # foreground mode β what `start` spawns internally
-```
-
-## Oops β troubleshooting without the tears
-
-**"smartctl not found"**
-```bash
-sudo apt install smartmontools # Debian/Ubuntu
-sudo pacman -S smartmontools # Arch β you already knew
-```
-
-**"needs root" / permission denied**
-Set up the passwordless line above, or just `sudo ./fenris.sh`.
-
-**Dashboard says "stale"**
-Daemon napped or crashed. `python3 fenris.py status` will tell you. Kick it again with `start`.
-
-**Only 10 hours of data and it says "preliminary"?**
-That's honesty, not a bug. It needs 24h of real writes to give a tight estimate. Let it simmer β the number gets sharper every hour.
+| Artifact | Location |
+|---|---|
+| Wrapper | `/usr/local/bin/fenris` |
+| Helpers | `/usr/libexec/fenris/fenris-collect`, `fenris-monitor` |
+| Units | `/etc/systemd/system/fenris-collect.{timer,service}` |
+| Polkit policy | `/usr/share/polkit-1/actions/com.bongbetic.fenris.monitor.policy` |
+| Configuration | `/etc/fenris/fenris.conf` |
+| Observation store | `/var/lib/fenris/observations.db` |
+| Venv | `/opt/fenris` |
+| Manifest | `/var/lib/fenris/manifest.txt` |
+| Legacy history | `./data/history.jsonl` (auto-imported on install if present) |
---
diff --git a/fenris.py b/fenris.py
deleted file mode 100755
index 87f9d0b..0000000
--- a/fenris.py
+++ /dev/null
@@ -1,1061 +0,0 @@
-#!/usr/bin/env python3
-"""
-Fenris β NVMe wear monitor + live dashboard.
-Created by Bongbetic.
-
-Samples NVMe SMART health data (via smartctl -j), logs it over time,
-and serves a self-contained HTML dashboard estimating SSD lifespan
-from your actual daily usage trend.
-"""
-
-import json
-import os
-import subprocess
-import sys
-import time
-import signal
-import argparse
-import threading
-import hashlib
-import mimetypes
-from datetime import datetime, timezone, timedelta
-from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
-
-# Add src/ to path for package imports
-_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
-sys.path.insert(0, os.path.join(_SCRIPT_DIR, "src"))
-
-SCRIPT_DIR = _SCRIPT_DIR
-DATA_DIR = os.path.join(SCRIPT_DIR, "data")
-DATA_FILE = os.path.join(DATA_DIR, "history.jsonl")
-HOURLY_FILE = os.path.join(DATA_DIR, "hourly.jsonl")
-PID_FILE = os.path.join(DATA_DIR, "fenris.pid")
-LOG_FILE = os.path.join(DATA_DIR, "fenris.log")
-ASSETS_DIR = os.path.join(SCRIPT_DIR, "assets")
-VERSION = "0.2.0"
-
-os.makedirs(DATA_DIR, exist_ok=True)
-
-_CONFIG = {"interval": 300, "device": "/dev/nvme0", "port": 8420, "version": VERSION}
-_hour_lock = threading.Lock()
-_current_hour = None
-
-
-def detect_device():
- for cand in ("/dev/nvme0", "/dev/nvme1"):
- if os.path.exists(cand):
- return cand
- return "/dev/nvme0"
-
-
-def sample(device):
- try:
- out = subprocess.run(
- ["sudo", "-n", "smartctl", "-a", "-j", device],
- capture_output=True, text=True, timeout=15
- )
- except FileNotFoundError:
- print("ERROR: smartctl not found. Install smartmontools.", file=sys.stderr)
- return None
- if out.returncode not in (0, 4):
- print(f"smartctl failed (exit {out.returncode}): {out.stderr.strip()}", file=sys.stderr)
- print("Hint: needs root. Run 'sudo visudo' and allow passwordless "
- "'smartctl' for your user, or run Fenris with sudo.", file=sys.stderr)
- return None
- try:
- d = json.loads(out.stdout)
- except json.JSONDecodeError:
- return None
- log = d.get("nvme_smart_health_information_log")
- if not log:
- print("No NVMe SMART data in smartctl output (not an NVMe device?).", file=sys.stderr)
- return None
- capacity_bytes = (d.get("user_capacity") or {}).get("bytes", 0)
- units_written = log.get("data_units_written", 0)
- units_read = log.get("data_units_read", 0)
- return {
- "ts": datetime.now(timezone.utc).isoformat(),
- "device": device,
- "model": d.get("model_name", "unknown"),
- "capacity_bytes": capacity_bytes,
- "percentage_used": log.get("percentage_used"),
- "available_spare": log.get("available_spare"),
- "media_errors": log.get("media_errors"),
- "power_on_hours": log.get("power_on_hours"),
- "power_cycles": log.get("power_cycles"),
- "unsafe_shutdowns": log.get("unsafe_shutdowns"),
- "temperature_c": log.get("temperature"),
- "data_units_written": units_written,
- "data_units_read": units_read,
- "bytes_written": units_written * 512000,
- "bytes_read": units_read * 512000,
- "critical_warning": log.get("critical_warning"),
- }
-
-
-def append_sample(rec):
- with open(DATA_FILE, "a") as f:
- f.write(json.dumps(rec) + "\n")
-
-
-def load_history():
- if not os.path.exists(DATA_FILE):
- return []
- out = []
- with open(DATA_FILE) as f:
- for line in f:
- line = line.strip()
- if line:
- try:
- out.append(json.loads(line))
- except json.JSONDecodeError:
- continue
- return out
-
-
-def hour_key(ts_str):
- try:
- dt = datetime.fromisoformat(ts_str)
- if dt.tzinfo is None:
- dt = dt.replace(tzinfo=timezone.utc)
- else:
- dt = dt.astimezone(timezone.utc)
- dt = dt.replace(minute=0, second=0, microsecond=0)
- return dt.isoformat().replace("+00:00", "Z")
- except Exception:
- return ts_str[:13] + ":00:00Z"
-
-
-def load_hourly():
- if not os.path.exists(HOURLY_FILE):
- return []
- out = []
- with open(HOURLY_FILE) as f:
- for line in f:
- line = line.strip()
- if line:
- try:
- out.append(json.loads(line))
- except json.JSONDecodeError:
- continue
- out.sort(key=lambda r: r.get("hour", ""))
- return out
-
-
-def append_hourly(rec):
- with _hour_lock:
- with open(HOURLY_FILE, "a") as f:
- f.write(json.dumps(rec) + "\n")
-
-
-def _hourly_record_for_bucket(hour_str, recs):
- if not recs:
- return None
- first = recs[0]
- last = recs[-1]
- bw = (last.get("bytes_written", 0) - first.get("bytes_written", 0)) if len(recs) > 1 else 0
- br = (last.get("bytes_read", 0) - first.get("bytes_read", 0)) if len(recs) > 1 else 0
- temps = [r.get("temperature_c") for r in recs if r.get("temperature_c") is not None]
- return {
- "hour": hour_str,
- "samples": len(recs),
- "bytes_written": max(bw, 0),
- "bytes_read": max(br, 0),
- "pct_start": first.get("percentage_used"),
- "pct_end": last.get("percentage_used"),
- "temp_avg": round(sum(temps) / len(temps), 1) if temps else None,
- "temp_max": max(temps) if temps else None,
- "media_errors": last.get("media_errors", 0),
- "available_spare": last.get("available_spare"),
- }
-
-
-def rebuild_hourly_from_history():
- history = load_history()
- if not history:
- return
- buckets = {}
- for r in history:
- hk = hour_key(r["ts"])
- buckets.setdefault(hk, []).append(r)
- existing = {r["hour"]: r for r in load_hourly()}
- for hk in sorted(buckets.keys()):
- if hk in existing:
- continue
- rec = _hourly_record_for_bucket(hk, buckets[hk])
- if rec:
- append_hourly(rec)
-
-
-def update_hour_bucket(rec):
- global _current_hour
- hk = hour_key(rec["ts"])
- if _current_hour is None or _current_hour["hour"] != hk:
- if _current_hour is not None:
- flushed = _hourly_record_for_bucket(_current_hour["hour"], _current_hour["recs"])
- if flushed:
- existing_hours = {r["hour"] for r in load_hourly()}
- if flushed["hour"] not in existing_hours:
- append_hourly(flushed)
- _current_hour = {"hour": hk, "recs": [rec]}
- else:
- _current_hour["recs"].append(rec)
-
-
-def _parse_ts(ts_str):
- try:
- dt = datetime.fromisoformat(ts_str)
- if dt.tzinfo is None:
- dt = dt.replace(tzinfo=timezone.utc)
- return dt.astimezone(timezone.utc)
- except Exception:
- return None
-
-
-def compute_summary(history=None, hourly=None):
- if history is None:
- history = load_history()
- if not history:
- return {
- "window24h": {"bytes": 0, "gb": 0, "coverage_hours": 0},
- "gb_per_hour": 0,
- "gb_per_day": 0,
- "endurance_tb": None,
- "endurance_estimated": True,
- "remaining_tb": None,
- "remaining_bytes": 0,
- "seconds_remaining": None,
- "breakdown": {"years": 0, "days": 0, "hours": 0, "human": "β"},
- "wear_model_days": None,
- "preliminary": True,
- "notes": ["No data yet"],
- "latest": None,
- }
- latest = history[-1]
- latest_ts = _parse_ts(latest["ts"])
- earliest_ts = _parse_ts(history[0]["ts"])
- total_span_sec = (latest_ts - earliest_ts).total_seconds() if latest_ts and earliest_ts else 0
- window_sec = 24 * 3600
- window_start_ts = latest_ts - timedelta(seconds=window_sec) if latest_ts else None
-
- idx = 0
- if window_start_ts:
- for i, r in enumerate(history):
- dt = _parse_ts(r["ts"])
- if dt and dt >= window_start_ts:
- idx = i
- break
- else:
- idx = 0
- window_start_rec = history[idx]
- window_start_ts_actual = _parse_ts(window_start_rec["ts"])
- coverage_sec = (latest_ts - window_start_ts_actual).total_seconds() if latest_ts and window_start_ts_actual else 0
- if coverage_sec < 1:
- coverage_sec = total_span_sec if total_span_sec > 0 else 1
-
- bytes_in_window = latest.get("bytes_written", 0) - window_start_rec.get("bytes_written", 0)
- if bytes_in_window < 0:
- bytes_in_window = 0
-
- rate = bytes_in_window / coverage_sec if coverage_sec > 0 else 0
- gb_per_hour = rate * 3600 / 1e9
- gb_per_day = gb_per_hour * 24
- coverage_hours = coverage_sec / 3600
-
- pct = latest.get("percentage_used")
- cap = latest.get("capacity_bytes") or 0
- bw_total = latest.get("bytes_written", 0)
- if pct is not None and pct > 0:
- endurance_bytes = bw_total / (pct / 100)
- endurance_estimated = False
- else:
- endurance_bytes = cap * 600 if cap else 0
- endurance_estimated = True
- remaining_bytes = max(endurance_bytes - bw_total, 0) if endurance_bytes else 0
- endurance_tb = endurance_bytes / 1e12 if endurance_bytes else None
- remaining_tb = remaining_bytes / 1e12 if remaining_bytes else 0
-
- seconds_remaining = None
- if rate > 0 and remaining_bytes > 0:
- seconds_remaining = remaining_bytes / rate
-
- def _humanize(secs):
- if secs is None or secs <= 0:
- return "β"
- years = int(secs // 31557600)
- rem = secs % 31557600
- days = int(rem // 86400)
- rem %= 86400
- hours = int(rem // 3600)
- parts = []
- if years:
- parts.append(f"{years} yr")
- if days or years:
- parts.append(f"{days} d")
- parts.append(f"{hours} h")
- return " ".join(parts)
-
- human = _humanize(seconds_remaining)
- breakdown = {
- "years": (seconds_remaining / 31557600) if seconds_remaining else 0,
- "days": (seconds_remaining / 86400) if seconds_remaining else 0,
- "hours": (seconds_remaining / 3600) if seconds_remaining else 0,
- "human": human,
- }
-
- wear_days = None
- pts = [( _parse_ts(r["ts"]).timestamp(), r["percentage_used"]) for r in history if r.get("percentage_used") is not None and _parse_ts(r["ts"]) is not None]
- if len(pts) >= 2:
- win_pts = [p for p in pts if p[0] >= (latest_ts.timestamp() - window_sec)] if latest_ts else pts
- if len(win_pts) < 2:
- win_pts = pts
- n = len(win_pts)
- mean_x = sum(p[0] for p in win_pts) / n
- mean_y = sum(p[1] for p in win_pts) / n
- num = sum((p[0]-mean_x)*(p[1]-mean_y) for p in win_pts)
- den = sum((p[0]-mean_x)**2 for p in win_pts)
- if den != 0:
- slope = num/den
- if slope > 0:
- last_pct = win_pts[-1][1]
- secs_to_100 = (100 - last_pct) / slope
- wear_days = secs_to_100 / 86400
-
- preliminary = coverage_hours < 24
- notes = []
- if preliminary:
- notes.append(f"Warming up β {coverage_hours:.1f}h of 24h")
- if endurance_estimated:
- notes.append("TBW estimated from capacity (pct=0)")
- if rate <= 0:
- notes.append("No writes in window")
- if wear_days is None:
- notes.append("Wear flat β write model only")
-
- return {
- "window24h": {"bytes": bytes_in_window, "gb": bytes_in_window / 1e9, "coverage_hours": coverage_hours},
- "gb_per_hour": gb_per_hour,
- "gb_per_day": gb_per_day,
- "endurance_tb": endurance_tb,
- "endurance_estimated": endurance_estimated,
- "remaining_tb": remaining_tb,
- "remaining_bytes": remaining_bytes,
- "seconds_remaining": seconds_remaining,
- "breakdown": breakdown,
- "wear_model_days": wear_days,
- "preliminary": preliminary,
- "notes": notes,
- "latest": latest,
- }
-
-
-def collector_loop(device, interval, stop_event):
- while not stop_event.is_set():
- rec = sample(device)
- if rec:
- append_sample(rec)
- update_hour_bucket(rec)
- stop_event.wait(interval)
-
-
-DASHBOARD_HTML = r"""
-
-
-
-
-Fenris β NVMe Wear Dashboard
-
-
-
-
-
-
-
-
-
-
-
-