Files
Fenris/fenris.py
T
xavierk 7f006c7df7 feat(status): read-only CLI status command (issue #27)
Implement fenris status as the read-only CLI twin of the TUI,
composing from the observation store and allow-listed systemctl
properties per spec section 8.8.

New module src/fenris/status.py:
- Freshness grading with shared constants (section 8.9, LC-10)
- Configuration error from direct config reads (section 8.3, LC-4)
- Store fault / newer-schema exact phrases (section 9.4-9.5, FL-4/FL-5)
- Drive anomalies as ordinary facts (section 9.7, FL-7)
- Four separate service facts (section 7.3, LC-9, CI-2)
- Projection recomputed on read, never stored (section 6.10)
- Retired command rejection with migration pointers (section 8.8)
- Six disclosures via --disclosures flag (section 6.11, CI-4)

Updated fenris.py:
- Replaced old cmd_status with new status module integration
- Added retired command handlers (start/stop/run)
- Added global --device flag rejection

Tests: 43 new, 182 total passing, zero regressions.
Closes #27.
2026-09-01 23:49:23 +05:30

1062 lines
41 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Fenris — NVMe Wear Dashboard</title>
<link rel="icon" href="/assets/bongbetic-brand/favicon.ico" sizes="any">
<link rel="icon" type="image/png" sizes="32x32" href="/assets/bongbetic-brand/favicon-32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/assets/bongbetic-brand/favicon-16.png">
<link rel="apple-touch-icon" href="/assets/bongbetic-brand/apple-touch-icon.png">
<link rel="manifest" href="/assets/bongbetic-brand/site.webmanifest">
<script src="https://cdn.tailwindcss.com"></script>
<script>tailwind.config={darkMode:'class'}</script>
<style>
:root{color-scheme:dark;--bg:#0b0f14;--card:#161b22;--border:#30363d;--fg:#e6edf3;--muted:#8b949e;--primary:#58a6ff;--success:#3fb950;--warn:#f0883e;--danger:#f85149}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif}
.container{max-width:1400px;margin:0 auto;padding:0 1rem}
header{position:sticky;top:0;z-index:20;backdrop-filter:blur(8px);background:rgba(11,15,20,.82);border-bottom:1px solid var(--border)}
.card{background:var(--card);border:1px solid var(--border);border-radius:.5rem;padding:.75rem;min-height:92px;display:flex;flex-direction:column}
.label{color:var(--muted);font-size:.70rem;text-transform:uppercase;letter-spacing:.06em}
.value{font-size:1.25rem;font-weight:700;margin-top:.25rem;line-height:1.2}
.sub{color:var(--muted);font-size:.75rem;margin-top:.2rem;line-height:1.3}
.badge{font-size:.70rem;padding:.15rem .4rem;border-radius:999px;border:1px solid var(--border);background:#0d1117;color:var(--muted)}
.badge-live{color:#3fb950;border-color:#2a6a3a;background:rgba(63,185,80,.12)}
.hero{grid-column:span 2}
@media(min-width:768px){.dense{grid-template-columns:repeat(3,1fr)}}
@media(min-width:1280px){.dense{grid-template-columns:repeat(5,1fr)}}
.dense{display:grid;grid-template-columns:repeat(2,1fr);gap:.75rem;margin-bottom:1rem}
.charts{display:grid;grid-template-columns:1fr;gap:1rem;margin-bottom:1rem}
@media(min-width:1024px){.charts{grid-template-columns:1fr 1fr}}
.chart-card{background:var(--card);border:1px solid var(--border);border-radius:.5rem;padding:1rem;min-width:0}
canvas{width:100%;height:200px;display:block}
.progress{height:6px;background:#0d1117;border:1px solid var(--border);border-radius:999px;overflow:hidden;margin-top:.4rem}
.progress-fill{height:100%;background:var(--primary);transition:width .3s}
.good{color:var(--success)} .warn{color:var(--warn)} .bad{color:var(--danger)}
.stale{color:var(--danger);font-weight:700}
footer{color:#6e7681;font-size:.75rem;text-align:center;padding:1.2rem 0;border-top:1px solid var(--border);margin-top:.5rem}
.bar{fill:var(--primary)}
.pulse{animation:pulse .6s ease}
@keyframes pulse{0%{box-shadow:0 0 0 0 rgba(88,166,255,.5)}100%{box-shadow:0 0 0 8px rgba(88,166,255,0)}}
</style>
</head>
<body class="dark">
<header>
<div class="container" style="display:flex;align-items:center;justify-content:space-between;gap:1rem;padding:.7rem 1rem">
<div style="display:flex;align-items:center;gap:.75rem;min-width:0">
<a href="https://bongbetic.com" target="_blank" rel="noopener" style="display:flex;align-items:center;gap:.5rem;text-decoration:none;flex-shrink:0" title="Bongbetic">
<img src="/assets/bongbetic-brand/icon-dark-512.png" alt="Bongbetic" style="width:32px;height:32px;border-radius:6px;object-fit:cover">
<picture>
<source srcset="/assets/bongbetic-brand/wordmark-light.png" media="(prefers-color-scheme: dark)">
<img src="/assets/bongbetic-brand/wordmark-dark.png" alt="Bongbetic" style="height:18px;object-fit:contain">
</picture>
</a>
<span style="color:var(--border);font-size:1.1rem;flex-shrink:0">·</span>
<div style="min-width:0">
<div style="font-weight:800;letter-spacing:.08em;font-size:.95rem;white-space:nowrap">FENRIS</div>
<div id="devInfo" style="font-size:.72rem;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">loading…</div>
</div>
</div>
<div style="display:flex;align-items:center;gap:.5rem;flex-shrink:0">
<span id="liveBadge" class="badge badge-live">● Live</span>
<span id="intervalBadge" class="badge">every —</span>
<span id="countdown" class="badge">next —</span>
</div>
</div>
</header>
<main class="container" style="padding:1rem 1rem 0">
<div id="warmingBanner" style="display:none;margin-bottom:.75rem;padding:.55rem .75rem;border-radius:.5rem;border:1px solid #a3711a;background:rgba(240,136,62,.12);color:#f0c27a;font-size:.78rem"></div>
<div id="staleBanner" style="display:none;margin-bottom:.75rem;padding:.55rem .75rem;border-radius:.5rem;border:1px solid #7a1f1f;background:rgba(248,81,73,.12);color:#ff8f8f;font-size:.78rem"></div>
<div class="dense" id="cards"></div>
<div class="charts">
<div class="chart-card">
<div style="font-size:.85rem;color:#c9d1d9;margin-bottom:.4rem;display:flex;justify-content:space-between;gap:.5rem"><span>Wear (% used) over time</span><span id="wearCaption" style="color:var(--muted);font-size:.72rem"></span></div>
<canvas id="wearChart" height="200"></canvas>
</div>
<div class="chart-card">
<div style="font-size:.85rem;color:#c9d1d9;margin-bottom:.4rem;display:flex;justify-content:space-between;gap:.5rem"><span>Trailing 24h — GB written per hour</span><span id="hourlyCaption" style="color:var(--muted);font-size:.72rem"></span></div>
<canvas id="hourlyChart" height="200"></canvas>
</div>
</div>
</main>
<footer class="container" style="display:flex;align-items:center;justify-content:center;gap:.5rem;flex-wrap:wrap">
<a href="https://bongbetic.com" target="_blank" rel="noopener" style="display:inline-flex;align-items:center;gap:.35rem;text-decoration:none;color:inherit">
<img src="/assets/bongbetic-brand/b_glyph.svg" alt="" style="width:16px;height:16px;opacity:.7"> © Bongbetic
</a>
— Fenris · <span id="footerMeta"></span>
</footer>
<script>
let POLL_MS = 300000;
let deviceLabel = '';
let lastHash = '';
let etags = {};
let timer = null;
let countdownTimer = null;
let nextAt = 0;
let backoff = 1000;
let paused = false;
function fmtBytes(b){
const gb=b/1e9;
if(gb>1000) return (gb/1000).toFixed(2)+' TB';
return gb.toFixed(1)+' GB';
}
function fmtGB(g){ return g.toFixed(2)+' GB'; }
function card(label, value, sub, cls, extra){
const prog = extra && extra.progress != null ? `<div class="progress"><div class="progress-fill" style="width:${Math.min(100,extra.progress)}%;background:${extra.progress>=90?'var(--danger)':extra.progress>=70?'var(--warn)':'var(--primary)'}"></div></div>` : '';
return `<div class="card${cls?' pulse':''}"><div class="label">${label}</div><div class="value ${extra&&extra.valueCls||''}">${value}</div><div class="sub">${sub||''}</div>${prog}${extra&&extra.foot||''}</div>`;
}
function drawLine(canvas, points, opts){
const ctx=canvas.getContext('2d');
const dpr=window.devicePixelRatio||1;
const cssW=canvas.clientWidth, cssH=200;
canvas.width=cssW*dpr; canvas.height=cssH*dpr;
canvas.style.height=cssH+'px';
ctx.setTransform(dpr,0,0,dpr,0,0);
ctx.clearRect(0,0,cssW,cssH);
if(points.length<2){
ctx.fillStyle='#8b949e'; ctx.font='12px sans-serif';
ctx.fillText('Not enough samples yet.', 12, cssH/2);
return;
}
const pad=36;
const xs=points.map(p=>p[0]), ys=points.map(p=>p[1]);
const minX=Math.min(...xs), maxX=Math.max(...xs);
const minY=Math.min(...ys, opts.minYFloor??Infinity);
const maxY=Math.max(...ys, opts.maxYCeil??-Infinity);
const spanY=(maxY-minY)||1;
const sx=x=> pad + (x-minX)/((maxX-minX)||1)*(cssW-pad*1.2);
const sy=y=> cssH-pad + (y-minY)/spanY*-(cssH-pad*1.4);
ctx.strokeStyle='#30363d'; ctx.lineWidth=1;
ctx.beginPath(); ctx.moveTo(pad,cssH-pad); ctx.lineTo(cssW-8,cssH-pad); ctx.moveTo(pad,cssH-pad); ctx.lineTo(pad,10); ctx.stroke();
ctx.fillStyle='#8b949e'; ctx.font='10px sans-serif';
ctx.fillText(maxY.toFixed(1)+(opts.unit||''), 2, 12);
ctx.fillText(minY.toFixed(1)+(opts.unit||''), 2, cssH-pad+10);
ctx.strokeStyle=opts.color||'#58a6ff'; ctx.lineWidth=2;
ctx.beginPath();
points.forEach((p,i)=>{ const x=sx(p[0]), y=sy(p[1]); if(i===0) ctx.moveTo(x,y); else ctx.lineTo(x,y); });
ctx.stroke();
ctx.fillStyle=opts.color||'#58a6ff';
points.forEach(p=>{ ctx.beginPath(); ctx.arc(sx(p[0]),sy(p[1]),2.2,0,Math.PI*2); ctx.fill(); });
}
function drawBars(canvas, bars, opts){
const ctx=canvas.getContext('2d');
const dpr=window.devicePixelRatio||1;
const cssW=canvas.clientWidth, cssH=200;
canvas.width=cssW*dpr; canvas.height=cssH*dpr;
canvas.style.height=cssH+'px';
ctx.setTransform(dpr,0,0,dpr,0,0);
ctx.clearRect(0,0,cssW,cssH);
if(!bars.length){
ctx.fillStyle='#8b949e'; ctx.font='12px sans-serif';
ctx.fillText('No hourly data yet.', 12, cssH/2);
return;
}
const pad=36;
const maxY=Math.max(...bars.map(b=>b.v), 1);
const n=bars.length;
const gap=4;
const availW=cssW-pad-8;
const barW=Math.max(2, (availW - gap*(n-1))/n);
const sy=v=> cssH-pad + (v/maxY)*-(cssH-pad*1.35);
ctx.strokeStyle='#30363d'; ctx.lineWidth=1;
ctx.beginPath(); ctx.moveTo(pad,cssH-pad); ctx.lineTo(cssW-8,cssH-pad); ctx.moveTo(pad,cssH-pad); ctx.lineTo(pad,10); ctx.stroke();
ctx.fillStyle='#8b949e'; ctx.font='10px sans-serif';
ctx.fillText(maxY.toFixed(1)+' GB', 2, 12);
ctx.fillText('0 GB', 2, cssH-pad+10);
bars.forEach((b,i)=>{
const x=pad + i*(barW+gap);
const y=sy(b.v);
const h=(cssH-pad)-y;
ctx.fillStyle=opts.color||'#58a6ff';
if(h>0){ ctx.fillRect(x, y, barW, h); }
});
ctx.fillStyle='#8b949e'; ctx.font='9px sans-serif';
const step=Math.ceil(n/6);
bars.forEach((b,i)=>{
if(i%step===0 || i===n-1){
const x=pad + i*(barW+gap) + barW/2;
ctx.fillText(b.label, x-14, cssH-8);
}
});
}
function fmtCountdown(ms){
if(ms<=0) return 'now';
const s=Math.ceil(ms/1000);
const m=Math.floor(s/60), sec=s%60;
return `${String(m).padStart(2,'0')}:${String(sec).padStart(2,'0')}`;
}
async function fetchJSON(url){
const headers={};
if(etags[url]) headers['If-None-Match']=etags[url];
const r=await fetch(url,{headers});
if(r.status===304) return {notModified:true};
const tag=r.headers.get('ETag');
if(tag) etags[url]=tag;
const j=await r.json();
return {data:j, notModified:false};
}
async function tick(){
if(paused || document.hidden){
schedule();
return;
}
if(!navigator.onLine){
backoff=Math.min(backoff*2,60000);
schedule(backoff);
return;
}
try{
const [cfgRes, dataRes, hourlyRes, summaryRes] = await Promise.all([
fetch('/api/config').then(r=>r.json()).catch(()=>null),
fetchJSON('/api/data'),
fetchJSON('/api/hourly'),
fetch('/api/summary').then(r=>r.json()).catch(()=>null),
]);
if(cfgRes && cfgRes.interval){
const want=cfgRes.interval*1000;
if(Math.abs(want-POLL_MS)>100) POLL_MS=Math.max(5000,Math.min(3600000,want));
document.getElementById('intervalBadge').textContent='every '+cfgRes.interval+'s';
document.getElementById('footerMeta').textContent=`interval ${cfgRes.interval}s · v${cfgRes.version||''} · ${cfgRes.device||''}`;
deviceLabel=cfgRes.device||'';
}
let rows = null, hourly=null, summary=null;
if(dataRes && !dataRes.notModified) rows=dataRes.data;
else if(dataRes && dataRes.notModified) rows=null;
else rows=[];
// Need rows even on 304: fetch cached via summary fallback
let needRender=false;
if(rows!==null){
const latest = rows.length? rows[rows.length-1].ts+':'+rows.length : 'empty';
const h=String(latest);
if(h!==lastHash){ lastHash=h; needRender=true; }
window._rows=rows;
} else {
rows=window._rows||[];
}
if(hourlyRes && !hourlyRes.notModified) { hourly=hourlyRes.data; window._hourly=hourly; needRender=true; }
else hourly=window._hourly||[];
if(summaryRes){ summary=summaryRes; window._summary=summary; needRender=true; }
else summary=window._summary||null;
if(needRender || !window._didFirst){
window._didFirst=true;
render(rows, hourly, summary);
}
backoff=1000;
}catch(e){
backoff=Math.min(backoff*2,60000);
}
schedule();
}
function schedule(delay){
if(timer) clearTimeout(timer);
const d = delay!=null ? delay : POLL_MS;
nextAt=Date.now()+d;
timer=setTimeout(tick, d);
if(!countdownTimer){
countdownTimer=setInterval(()=>{
const el=document.getElementById('countdown');
if(el) el.textContent='next '+fmtCountdown(nextAt-Date.now());
}, 500);
}
}
function render(rows, hourly, summary){
if(!rows || !rows.length){
document.getElementById('devInfo').textContent='No data yet. Waiting for first sample...';
return;
}
const latest=rows[rows.length-1];
const first=rows[0];
document.getElementById('devInfo').textContent=`${latest.model||'NVMe'} (${latest.device}) — ${fmtBytes(latest.capacity_bytes)} — last ${new Date(latest.ts).toLocaleString()}`;
const ageSec=(Date.now()-new Date(latest.ts).getTime())/1000;
const staleEl=document.getElementById('staleBanner');
if(ageSec > Math.max(120, POLL_MS/1000*2)){
staleEl.style.display='block';
staleEl.textContent=`Stale — last sample ${Math.round(ageSec/60)}m ago. Is the daemon running?`;
} else staleEl.style.display='none';
const warmEl=document.getElementById('warmingBanner');
if(summary && summary.preliminary){
warmEl.style.display='block';
warmEl.textContent=`Warming up — ${summary.window24h.coverage_hours.toFixed(1)}h of 24h coverage. Estimate is preliminary and will tighten as history fills.`;
} else if(summary && summary.notes && summary.notes.length){
// no warm, but still show notes if any besides wear flat
const n = summary.notes.filter(x=>!x.includes('Wear flat'));
if(n.length){ warmEl.style.display='block'; warmEl.textContent=n.join(' · '); }
else warmEl.style.display='none';
} else warmEl.style.display='none';
const pct=latest.percentage_used??0;
const pctCls=pct>=90?'bad':pct>=70?'warn':'good';
const spare=latest.available_spare;
const spareCls=spare!=null && spare<20 ? 'bad' : 'good';
let heroVal='—', heroSub='', heroExtra='';
if(summary && summary.seconds_remaining){
heroVal=summary.breakdown.human;
const d=summary.breakdown.days, h=summary.breakdown.hours;
heroSub=`≈ ${d.toFixed(0)} days · ≈ ${h.toFixed(0)} hours`;
const basis=`${summary.gb_per_day.toFixed(1)} GB/day · ${summary.gb_per_hour.toFixed(2)} GB/h (rolling 24h) · ${summary.remaining_tb.toFixed(2)} TB left of ${summary.endurance_tb!=null?summary.endurance_tb.toFixed(1)+' TB':''} total`;
const wearLine = summary.wear_model_days ? `also ~${summary.wear_model_days.toFixed(0)} days at current wear rate` : 'wear flat — write model only';
heroExtra=`<div class="sub" style="margin-top:.35rem">${basis}</div><div class="sub" style="opacity:.85">${wearLine}${summary.preliminary?' · preliminary':''}</div>`;
} else if(summary && summary.gb_per_day===0){
heroVal='No writes in window';
heroSub='Drive idle in the last 24h — longevity clock paused';
} else {
heroVal='Calculating…';
}
const w24 = summary ? summary.window24h : {gb:0, coverage_hours:0};
const totalDays = Math.max((new Date(latest.ts)-new Date(first.ts))/86400000, 0.001);
const cardsEl=document.getElementById('cards');
const heroCard = `<div class="card hero" style="border-color:${summary&&summary.preliminary?'#a3711a':'var(--border)'}">
<div class="label">Projected life remaining</div>
<div class="value" style="font-size:1.55rem">${heroVal}</div>
<div class="sub">${heroSub}</div>
${heroExtra}
${summary&&summary.preliminary?'<div class="badge" style="margin-top:.5rem;align-self:flex-start">preliminary</div>':''}
</div>`;
cardsEl.innerHTML = heroCard + [
card('Data written (24h)', fmtGB(w24.gb), `${w24.coverage_hours.toFixed(1)}h coverage · rolling window`, false, {valueCls: w24.gb>50?'warn':''}),
card('Write rate', `${(summary?summary.gb_per_hour:0).toFixed(2)} GB/h`, `${(summary?summary.gb_per_day:0).toFixed(1)} GB/day`),
card('Wear level', pct+'%', pct>=90?'Critical':pct>=70?'Elevated':'Healthy', pctCls, {progress:pct, valueCls:pctCls}),
card('Total data written', fmtBytes(latest.bytes_written), `${(latest.bytes_written/latest.capacity_bytes).toFixed(1)}× capacity`),
card('Total data read', fmtBytes(latest.bytes_read)),
card('Available spare', (spare??'—')+(spare!=null?'%':''), spare!=null&&spare<20?'Low spare capacity!':'', spareCls),
card('Media errors', String(latest.media_errors??0), '', (latest.media_errors>0?'bad':'good')),
card('Temperature', (latest.temperature_c??'—')+' °C'),
card('Power-on hours', latest.power_on_hours??'—', latest.power_on_hours? (latest.power_on_hours/24).toFixed(0)+' days':''),
card('Samples', String(rows.length), `since ${new Date(first.ts).toLocaleDateString()}`),
].join('');
drawLine(document.getElementById('wearChart'),
rows.filter(r=>r.percentage_used!=null).map(r=>[new Date(r.ts).getTime(), r.percentage_used]),
{color:'#f0883e', unit:'%', maxYCeil:100, minYFloor:0});
document.getElementById('wearCaption').textContent = rows.length+' samples';
const bars = (hourly||[]).slice(-24).map(r=>({
v: (r.bytes_written||0)/1e9,
label: r.hour.slice(11,13)+'h'
}));
// Append live partial current hour if not yet flushed
if(summary && rows.length){
const lastHour = hourly.length? hourly[hourly.length-1].hour : null;
const curHk = new Date(latest.ts).toISOString().slice(0,13)+':00:00Z';
if(curHk !== lastHour){
const hourStartBytes = (()=>{
const sameHourRows = rows.filter(r=> r.ts.slice(0,13)===latest.ts.slice(0,13));
if(sameHourRows.length>1) return sameHourRows[0].bytes_written;
return latest.bytes_written;
})();
const curGB = (latest.bytes_written - hourStartBytes)/1e9;
if(curGB>0) bars.push({v:curGB, label:'now'});
if(bars.length>24) bars.shift();
}
}
drawBars(document.getElementById('hourlyChart'), bars, {color:'#58a6ff'});
const liveHourGB = bars.length? bars[bars.length-1].v : 0;
document.getElementById('hourlyCaption').textContent = `${bars.length}h · live ${liveHourGB.toFixed(2)} GB this hour`;
}
fetch('/api/config').then(r=>r.json()).then(c=>{
if(c && c.interval) POLL_MS=Math.max(5000,Math.min(3600000,c.interval*1000));
document.getElementById('intervalBadge').textContent='every '+ (c.interval||'?')+'s';
document.getElementById('footerMeta').textContent=`interval ${c.interval}s · v${c.version||''} · ${c.device||''}`;
}).finally(()=>{
if(!window._didFirst) tick();
else schedule();
});
document.addEventListener('visibilitychange', ()=>{
if(document.hidden){ paused=true; }
else { paused=false; tick(); }
});
window.addEventListener('online', ()=>{ backoff=1000; tick(); });
window.addEventListener('resize', ()=>{
if(window._rows) render(window._rows, window._hourly||[], window._summary||null);
});
</script>
</body>
</html>
"""
class Handler(BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
pass
def do_GET(self):
path = self.path.split("?")[0]
if path == "/" or path.startswith("/index"):
body = DASHBOARD_HTML.encode()
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return
if path.startswith("/assets/"):
rel = path[len("/assets/"):]
if ".." in rel or rel.startswith("/"):
self.send_response(404); self.end_headers(); return
fp = os.path.join(ASSETS_DIR, rel)
if not os.path.isfile(fp):
self.send_response(404); self.end_headers(); return
ctype = mimetypes.guess_type(fp)[0] or "application/octet-stream"
with open(fp, "rb") as f:
body = f.read()
self.send_response(200)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return
if path == "/api/config":
body = json.dumps(_CONFIG).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return
if path == "/api/status":
hist = load_history()
latest = hist[-1] if hist else None
summ = compute_summary(hist)
body = json.dumps({
"alive": True,
"samples": len(hist),
"last_ts": latest["ts"] if latest else None,
"version": VERSION,
"config": _CONFIG,
"summary": summ,
}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return
if path.startswith("/api/data"):
hist = load_history()
body = json.dumps(hist).encode()
etag = f'"{hashlib.md5(body).hexdigest()}"'
inm = self.headers.get("If-None-Match")
if inm and inm == etag:
self.send_response(304); self.end_headers(); return
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("ETag", etag)
self.send_header("Cache-Control", "no-cache")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return
if path.startswith("/api/hourly"):
hourly = load_hourly()
body = json.dumps(hourly).encode()
etag = f'"{hashlib.md5(body).hexdigest()}"'
inm = self.headers.get("If-None-Match")
if inm and inm == etag:
self.send_response(304); self.end_headers(); return
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("ETag", etag)
self.send_header("Cache-Control", "no-cache")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return
if path.startswith("/api/summary"):
summ = compute_summary()
body = json.dumps(summ).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return
self.send_response(404)
self.end_headers()
def run_foreground(device, interval, port):
global _CONFIG, _current_hour
_CONFIG = {"interval": interval, "device": device, "port": port, "version": VERSION}
stop_event = threading.Event()
def handle_sig(signum, frame):
stop_event.set()
signal.signal(signal.SIGTERM, handle_sig)
signal.signal(signal.SIGINT, handle_sig)
def cleanup_pid():
try:
os.remove(PID_FILE)
except OSError:
pass
import atexit
atexit.register(cleanup_pid)
print(f"Fenris starting — device={device} interval={interval}s port={port}")
print(f"Data log: {DATA_FILE}")
rebuild_hourly_from_history()
rec = sample(device)
if rec:
append_sample(rec)
update_hour_bucket(rec)
else:
print("WARNING: initial sample failed — check smartctl/sudo setup. "
"The daemon will keep retrying.", file=sys.stderr)
t = threading.Thread(target=collector_loop, args=(device, interval, stop_event), daemon=True)
t.start()
server = ThreadingHTTPServer(("0.0.0.0", port), Handler)
server.timeout = 1
print(f"Dashboard: http://localhost:{port}")
def server_loop():
while not stop_event.is_set():
server.handle_request()
st = threading.Thread(target=server_loop, daemon=True)
st.start()
while not stop_event.is_set():
time.sleep(0.5)
print("Fenris stopping.")
def cmd_start(args):
if os.path.exists(PID_FILE):
with open(PID_FILE) as f:
pid = int(f.read().strip())
if pid_alive(pid):
print(f"Fenris already running (pid {pid}). Use 'status' or 'stop'.")
return
os.remove(PID_FILE)
log_f = open(LOG_FILE, "a")
proc = subprocess.Popen(
[sys.executable, os.path.abspath(__file__), "run",
"--device", args.device, "--interval", str(args.interval), "--port", str(args.port)],
stdout=log_f, stderr=log_f, stdin=subprocess.DEVNULL,
start_new_session=True,
)
with open(PID_FILE, "w") as f:
f.write(str(proc.pid))
time.sleep(0.5)
print(f"Fenris started in background (pid {proc.pid}).")
print(f"Dashboard: http://localhost:{args.port}")
print(f"Logs: {LOG_FILE}")
def pid_alive(pid):
try:
os.kill(pid, 0)
return True
except OSError:
return False
def cmd_stop(args):
if not os.path.exists(PID_FILE):
print("Fenris is not running (no pid file).")
return
with open(PID_FILE) as f:
pid = int(f.read().strip())
if pid_alive(pid):
os.kill(pid, signal.SIGTERM)
for _ in range(50):
if not pid_alive(pid):
break
time.sleep(0.1)
if pid_alive(pid):
print(f"SIGTERM did not stop pid {pid}, sending SIGKILL...")
os.kill(pid, signal.SIGKILL)
for _ in range(20):
if not pid_alive(pid):
break
time.sleep(0.1)
if pid_alive(pid):
print(f"ERROR: could not stop pid {pid}. Manual intervention needed.")
return
print(f"Stopped Fenris (pid {pid}).")
else:
print("Stale pid file — process was not running.")
try:
os.remove(PID_FILE)
except OSError:
pass
def cmd_status(args):
try:
from fenris.status import render_status, check_retired_flag
from pathlib import Path
show_disclosures = getattr(args, "disclosures", False)
store_path = Path("/var/lib/fenris/observations.db")
output = render_status(store_path=store_path, show_disclosures=show_disclosures)
print(output)
except ImportError:
# Fallback if fenris package not importable
print("Error: cannot import fenris.status module. Is the package installed?")
sys.exit(1)
def cmd_run(args):
run_foreground(args.device, args.interval, args.port)
def cmd_sample_once(args):
rec = sample(args.device)
if rec:
append_sample(rec)
update_hour_bucket(rec)
print(json.dumps(rec, indent=2))
summ = compute_summary()
print(f"\n24h: {summ['window24h']['gb']:.2f} GB rate {summ['gb_per_hour']:.2f} GB/h remaining {summ['breakdown']['human']}")
else:
sys.exit(1)
def cmd_retired(args):
"""Handle retired commands with migration pointers (§8.8)."""
from fenris.status import check_retired_command
cmd = sys.argv[1] if len(sys.argv) > 1 else ""
ptr = check_retired_command(cmd)
if ptr:
print(ptr)
else:
print("Unknown command. Use 'fenris status' or 'fenris sample'.")
sys.exit(1)
def main():
p = argparse.ArgumentParser(description="Fenris — NVMe wear monitor & dashboard (by Bongbetic)")
p.add_argument("--device", default=None,
help="(retired — device is configured in /etc/fenris/fenris.conf)")
sub = p.add_subparsers(dest="cmd", required=True)
def add_common(sp):
sp.add_argument("--device", default=detect_device(), help="NVMe device, e.g. /dev/nvme0")
sp.add_argument("--interval", type=int, default=300, help="seconds between samples (default 300)")
sp.add_argument("--port", type=int, default=8420, help="dashboard HTTP port (default 8420)")
sp = sub.add_parser("start", help="(retired — use 'fenris monitor resume')")
add_common(sp); sp.set_defaults(func=cmd_retired)
sp = sub.add_parser("stop", help="(retired — use 'fenris monitor pause')")
sp.set_defaults(func=cmd_retired)
sp = sub.add_parser("status", help="show read-only status (§8.8)")
sp.add_argument("-d", "--disclosures", action="store_true",
help="show the six disclosures (§6.11)")
sp.set_defaults(func=cmd_status)
sp = sub.add_parser("run", help="(retired — use 'fenris monitor resume')")
add_common(sp); sp.set_defaults(func=cmd_retired)
sp = sub.add_parser("sample", help="take one sample immediately and print it")
sp.add_argument("--device", default=detect_device())
sp.set_defaults(func=cmd_sample_once)
args = p.parse_args()
# Reject retired --device flag (§8.8) — only if explicitly passed
if getattr(args, "device", None) is not None:
from fenris.status import check_retired_flag
ptr = check_retired_flag("--device")
if ptr:
print(ptr)
sys.exit(1)
args.func(args)
if __name__ == "__main__":
main()