feat(#73): publish trustworthy first usage history
- Schema migration 1-2: add segment_id to samples, unattributed bytes to day_aggregates - Collector now derives hour observations and day aggregates from sample pairs - Cross-hour deltas tracked as unattributed (no proportional allocation) - Display states: 0 samples -> awaiting first, 1 sample -> awaiting another - Monitoring period ensured open on each collection run - Derivation failures preserve prior history Closes #73
This commit is contained in:
+40
-5
@@ -16,6 +16,8 @@ from pathlib import Path
|
|||||||
from typing import Any, Dict, Optional, Tuple
|
from typing import Any, Dict, Optional, Tuple
|
||||||
|
|
||||||
from .store import init_store, get_store_path
|
from .store import init_store, get_store_path
|
||||||
|
from .monitoring_periods import ensure_period_open
|
||||||
|
from .derive import find_previous_sample, derive_hours_from_interval
|
||||||
|
|
||||||
|
|
||||||
class AcquisitionError(Exception):
|
class AcquisitionError(Exception):
|
||||||
@@ -221,7 +223,11 @@ def write_sample(
|
|||||||
open_segment(conn, now, identity, identity_key, identity_degraded)
|
open_segment(conn, now, identity, identity_key, identity_degraded)
|
||||||
segment_opened = True
|
segment_opened = True
|
||||||
|
|
||||||
# Insert sample
|
# Get current segment_id for provenance
|
||||||
|
current_segment = find_current_segment(conn)
|
||||||
|
segment_id = current_segment["id"] if current_segment else None
|
||||||
|
|
||||||
|
# Insert sample with segment_id
|
||||||
cursor = conn.execute(
|
cursor = conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO samples (
|
INSERT INTO samples (
|
||||||
@@ -229,8 +235,8 @@ def write_sample(
|
|||||||
percentage_used, available_spare, media_errors, power_on_hours,
|
percentage_used, available_spare, media_errors, power_on_hours,
|
||||||
power_cycles, unsafe_shutdowns, temperature_c,
|
power_cycles, unsafe_shutdowns, temperature_c,
|
||||||
data_units_written, data_units_read, bytes_written, bytes_read,
|
data_units_written, data_units_read, bytes_written, bytes_read,
|
||||||
critical_warning
|
critical_warning, segment_id
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
sample["ts"],
|
sample["ts"],
|
||||||
@@ -252,6 +258,7 @@ def write_sample(
|
|||||||
sample["bytes_written"],
|
sample["bytes_written"],
|
||||||
sample["bytes_read"],
|
sample["bytes_read"],
|
||||||
sample["critical_warning"],
|
sample["critical_warning"],
|
||||||
|
segment_id,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -262,6 +269,7 @@ def write_sample(
|
|||||||
"segment_reason": reason,
|
"segment_reason": reason,
|
||||||
"identity_key": identity_key,
|
"identity_key": identity_key,
|
||||||
"identity_degraded": identity_degraded,
|
"identity_degraded": identity_degraded,
|
||||||
|
"segment_id": segment_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -303,11 +311,38 @@ def run_collection(
|
|||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# Ensure monitoring period is open (issue #73 AC2)
|
||||||
|
ensure_period_open(conn, clock.utcnow())
|
||||||
|
|
||||||
# Validate invariants
|
# Validate invariants
|
||||||
validate_sample_invariants(sample, conn)
|
validate_sample_invariants(sample, conn)
|
||||||
|
|
||||||
# Write sample
|
# Write sample and get segment info
|
||||||
write_sample(sample, identity, conn, clock)
|
seg_info = write_sample(sample, identity, conn, clock)
|
||||||
|
|
||||||
|
# Derive hour observations from interval with previous sample
|
||||||
|
try:
|
||||||
|
# Find the sample we just wrote
|
||||||
|
cursor = conn.execute("SELECT id FROM samples ORDER BY id DESC LIMIT 1")
|
||||||
|
current_id = cursor.fetchone()[0]
|
||||||
|
|
||||||
|
prev = find_previous_sample(conn, seg_info.get("segment_id"), current_id)
|
||||||
|
if prev is not None:
|
||||||
|
# Build current sample dict for derivation
|
||||||
|
current = {
|
||||||
|
"id": current_id,
|
||||||
|
"ts": sample["ts"],
|
||||||
|
"bytes_written": sample["bytes_written"],
|
||||||
|
"bytes_read": sample["bytes_read"],
|
||||||
|
"power_on_hours": sample["power_on_hours"],
|
||||||
|
"temperature_c": sample["temperature_c"],
|
||||||
|
"data_units_written": sample["data_units_written"],
|
||||||
|
"data_units_read": sample["data_units_read"],
|
||||||
|
}
|
||||||
|
derive_hours_from_interval(conn, prev, current)
|
||||||
|
except Exception:
|
||||||
|
# Derivation failure must not prevent sample persistence (issue #73 AC6)
|
||||||
|
pass
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
"""Interval derivation: samples → hour observations → day aggregates.
|
||||||
|
|
||||||
|
After each collection run, the collector calls into this module to:
|
||||||
|
1. Find the previous sample in the same segment
|
||||||
|
2. Compute deltas (bytes, POH, temperature)
|
||||||
|
3. Classify the hour(s) the interval spans
|
||||||
|
4. Write/update hour_observations for each affected hour
|
||||||
|
5. Update day_aggregates with unattributed cross-hour bytes
|
||||||
|
|
||||||
|
Cross-hour deltas are retained once with unknown shares explicit (issue #73 AC4).
|
||||||
|
No proportional allocation, endpoint assignment, or double counting.
|
||||||
|
"""
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
from .hour_classify import classify_hour, HourSplit
|
||||||
|
|
||||||
|
|
||||||
|
def find_previous_sample(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
segment_id: Optional[int],
|
||||||
|
current_sample_id: int,
|
||||||
|
) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Find the most recent sample before current_sample_id in the same segment.
|
||||||
|
|
||||||
|
Returns None if no previous sample exists (first sample in segment).
|
||||||
|
"""
|
||||||
|
if segment_id is not None:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"SELECT id, ts, bytes_written, bytes_read, power_on_hours, "
|
||||||
|
" temperature_c, data_units_written, data_units_read "
|
||||||
|
"FROM samples WHERE id < ? AND segment_id = ? "
|
||||||
|
"ORDER BY id DESC LIMIT 1",
|
||||||
|
(current_sample_id, segment_id),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"SELECT id, ts, bytes_written, bytes_read, power_on_hours, "
|
||||||
|
" temperature_c, data_units_written, data_units_read "
|
||||||
|
"FROM samples WHERE id < ? "
|
||||||
|
"ORDER BY id DESC LIMIT 1",
|
||||||
|
(current_sample_id,),
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"id": row[0], "ts": row[1], "bytes_written": row[2],
|
||||||
|
"bytes_read": row[3], "power_on_hours": row[4],
|
||||||
|
"temperature_c": row[5], "data_units_written": row[6],
|
||||||
|
"data_units_read": row[7],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_ts(ts: str) -> datetime:
|
||||||
|
"""Parse ISO timestamp to datetime with UTC."""
|
||||||
|
dt = datetime.fromisoformat(ts)
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt
|
||||||
|
|
||||||
|
|
||||||
|
def _hour_floor(dt: datetime) -> datetime:
|
||||||
|
"""Floor a datetime to its UTC hour boundary."""
|
||||||
|
return dt.replace(minute=0, second=0, microsecond=0)
|
||||||
|
|
||||||
|
|
||||||
|
def _hours_spanned(start: datetime, end: datetime) -> List[datetime]:
|
||||||
|
"""Return list of UTC hour boundaries spanned by [start, end)."""
|
||||||
|
hours = []
|
||||||
|
h = _hour_floor(start)
|
||||||
|
while h < end:
|
||||||
|
hours.append(h)
|
||||||
|
h += timedelta(hours=1)
|
||||||
|
return hours
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_sampled_seconds_in_hour(
|
||||||
|
start: datetime, end: datetime, hour_start: datetime
|
||||||
|
) -> int:
|
||||||
|
"""How many seconds of the sample interval fall within this hour."""
|
||||||
|
hour_end = hour_start + timedelta(hours=1)
|
||||||
|
effective_start = max(start, hour_start)
|
||||||
|
effective_end = min(end, hour_end)
|
||||||
|
if effective_start >= effective_end:
|
||||||
|
return 0
|
||||||
|
return int((effective_end - effective_start).total_seconds())
|
||||||
|
|
||||||
|
|
||||||
|
def derive_hours_from_interval(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
prev_sample: Dict[str, Any],
|
||||||
|
next_sample: Dict[str, Any],
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Derive hour observations from a sample pair interval.
|
||||||
|
|
||||||
|
Returns list of hour observation dicts that were written/updated.
|
||||||
|
"""
|
||||||
|
prev_ts = _parse_ts(prev_sample["ts"])
|
||||||
|
next_ts = _parse_ts(next_sample["ts"])
|
||||||
|
|
||||||
|
# Deltas
|
||||||
|
bw_delta = max(0, next_sample["bytes_written"] - prev_sample["bytes_written"])
|
||||||
|
br_delta = max(0, next_sample["bytes_read"] - prev_sample["bytes_read"])
|
||||||
|
poh_delta_s = max(0, (next_sample["power_on_hours"] - prev_sample["power_on_hours"])) * 3600
|
||||||
|
|
||||||
|
hours = _hours_spanned(prev_ts, next_ts)
|
||||||
|
total_span_s = int((next_ts - prev_ts).total_seconds())
|
||||||
|
|
||||||
|
results = []
|
||||||
|
|
||||||
|
if len(hours) == 1:
|
||||||
|
# Same-hour interval: fully attributed to this hour
|
||||||
|
hour_key = hours[0].strftime("%Y-%m-%dT%H:00:00+00:00")
|
||||||
|
sampled_s = total_span_s
|
||||||
|
|
||||||
|
# Classify hour
|
||||||
|
split = classify_hour(
|
||||||
|
wall_clock_seconds=3600,
|
||||||
|
poh_delta=poh_delta_s,
|
||||||
|
duw_delta=bw_delta,
|
||||||
|
dur_delta=br_delta,
|
||||||
|
sampled_seconds=sampled_s,
|
||||||
|
)
|
||||||
|
|
||||||
|
_upsert_hour_observation(
|
||||||
|
conn, hour_key, split,
|
||||||
|
bw_delta, br_delta,
|
||||||
|
prev_sample.get("temperature_c"), next_sample.get("temperature_c"),
|
||||||
|
2, # 2 samples contributed (prev + next)
|
||||||
|
)
|
||||||
|
results.append({"hour": hour_key, "bytes_written": bw_delta, "attributed": True})
|
||||||
|
|
||||||
|
elif len(hours) >= 2:
|
||||||
|
# Cross-hour interval: split wall-clock time, bytes unattributed
|
||||||
|
for h in hours:
|
||||||
|
hour_key = h.strftime("%Y-%m-%dT%H:00:00+00:00")
|
||||||
|
sampled_s = _compute_sampled_seconds_in_hour(prev_ts, next_ts, h)
|
||||||
|
|
||||||
|
# For cross-hour, we classify based on time only (no byte attribution)
|
||||||
|
# The hour gets its time split but NOT the byte delta
|
||||||
|
split = classify_hour(
|
||||||
|
wall_clock_seconds=3600,
|
||||||
|
poh_delta=0, # POH attribution unknown for cross-hour
|
||||||
|
duw_delta=0, # Bytes unattributed
|
||||||
|
dur_delta=0,
|
||||||
|
sampled_seconds=sampled_s,
|
||||||
|
)
|
||||||
|
|
||||||
|
_upsert_hour_observation(
|
||||||
|
conn, hour_key, split,
|
||||||
|
0, 0, # No byte attribution for cross-hour
|
||||||
|
None, None,
|
||||||
|
0, # No sample falls IN this hour
|
||||||
|
)
|
||||||
|
results.append({"hour": hour_key, "bytes_written": 0, "attributed": False})
|
||||||
|
|
||||||
|
# Track unattributed bytes at day level
|
||||||
|
_add_unattributed_bytes(conn, prev_ts, next_ts, bw_delta, br_delta)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _upsert_hour_observation(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
hour_key: str,
|
||||||
|
split: HourSplit,
|
||||||
|
bw_delta: int,
|
||||||
|
br_delta: int,
|
||||||
|
temp_min: Optional[int],
|
||||||
|
temp_max: Optional[int],
|
||||||
|
sample_count: int,
|
||||||
|
) -> None:
|
||||||
|
"""Insert or update an hour observation."""
|
||||||
|
# Check if hour exists
|
||||||
|
existing = conn.execute(
|
||||||
|
"SELECT id, bytes_written_delta, sample_count FROM hour_observations WHERE hour = ?",
|
||||||
|
(hour_key,),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if existing is None:
|
||||||
|
temp_avg = ((temp_min or 0) + (temp_max or 0)) / 2 if temp_min is not None else None
|
||||||
|
coverage = (split.seconds_active + split.seconds_idle + split.seconds_powered_off) / 3600.0
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO hour_observations
|
||||||
|
(hour, active_seconds, idle_seconds, powered_off_seconds, unknown_seconds,
|
||||||
|
bytes_written_delta, bytes_read_delta,
|
||||||
|
temperature_min, temperature_avg, temperature_max,
|
||||||
|
sample_count, coverage)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(hour_key, split.seconds_active, split.seconds_idle,
|
||||||
|
split.seconds_powered_off, split.seconds_unknown,
|
||||||
|
bw_delta, br_delta,
|
||||||
|
temp_min, temp_avg, temp_max,
|
||||||
|
sample_count, coverage),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Merge: accumulate bytes and sample count
|
||||||
|
new_bw = existing[1] + bw_delta
|
||||||
|
new_samples = existing[2] + sample_count
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE hour_observations SET bytes_written_delta = ?, sample_count = ? WHERE id = ?",
|
||||||
|
(new_bw, new_samples, existing[0]),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _add_unattributed_bytes(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
prev_ts: datetime,
|
||||||
|
next_ts: datetime,
|
||||||
|
bw_delta: int,
|
||||||
|
br_delta: int,
|
||||||
|
) -> None:
|
||||||
|
"""Add unattributed byte deltas to day aggregates for each day touched."""
|
||||||
|
prev_day = prev_ts.strftime("%Y-%m-%d")
|
||||||
|
next_day = next_ts.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
days = {prev_day, next_day}
|
||||||
|
for day in days:
|
||||||
|
existing = conn.execute(
|
||||||
|
"SELECT id FROM day_aggregates WHERE day = ?", (day,)
|
||||||
|
).fetchone()
|
||||||
|
if existing is None:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO day_aggregates (day, unattributed_bytes_written, unattributed_bytes_read) "
|
||||||
|
"VALUES (?, ?, ?)",
|
||||||
|
(day, bw_delta, br_delta),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE day_aggregates SET unattributed_bytes_written = unattributed_bytes_written + ?, "
|
||||||
|
"unattributed_bytes_read = unattributed_bytes_read + ? WHERE day = ?",
|
||||||
|
(bw_delta, br_delta, day),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
+24
-2
@@ -331,7 +331,8 @@ def check_retired_flag(flag: str) -> Optional[str]:
|
|||||||
def _format_projection(proj, freshness: str, service: Dict[str, Any],
|
def _format_projection(proj, freshness: str, service: Dict[str, Any],
|
||||||
drive_facts: List[str], config_error: Optional[str],
|
drive_facts: List[str], config_error: Optional[str],
|
||||||
store_fault: Optional[str], newer_schema: Optional[str],
|
store_fault: Optional[str], newer_schema: Optional[str],
|
||||||
journal_hint: Optional[str]) -> str:
|
journal_hint: Optional[str],
|
||||||
|
sample_count: int = 0, day_count: int = 0) -> str:
|
||||||
"""Format the complete status output."""
|
"""Format the complete status output."""
|
||||||
lines = []
|
lines = []
|
||||||
|
|
||||||
@@ -361,6 +362,16 @@ def _format_projection(proj, freshness: str, service: Dict[str, Any],
|
|||||||
_append_service_facts(lines, service)
|
_append_service_facts(lines, service)
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
# --- Single sample: awaiting another sample (issue #73 AC3) ---
|
||||||
|
# Only show awaiting state when there are no day aggregates (e.g., legacy import
|
||||||
|
# or hand-crafted stores can have 1 sample but sufficient day data for projection)
|
||||||
|
if sample_count <= 1 and day_count == 0:
|
||||||
|
lines.append("awaiting another sample")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("Collecting usage data — the first projection requires at least two samples.")
|
||||||
|
_append_service_facts(lines, service)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
# --- Projection headline ---
|
# --- Projection headline ---
|
||||||
headline = _format_headline(proj)
|
headline = _format_headline(proj)
|
||||||
lines.append(headline)
|
lines.append(headline)
|
||||||
@@ -598,6 +609,17 @@ def get_status(store_path: Optional[Path] = None, clock_now: Optional[datetime]
|
|||||||
|
|
||||||
freshness = grade_freshness(newest_ts, clock_now)
|
freshness = grade_freshness(newest_ts, clock_now)
|
||||||
|
|
||||||
|
# --- Sample count for single-sample state (issue #73 AC3) ---
|
||||||
|
sample_count = 0
|
||||||
|
day_count = 0
|
||||||
|
try:
|
||||||
|
cursor = conn.execute("SELECT COUNT(*) FROM samples")
|
||||||
|
sample_count = cursor.fetchone()[0]
|
||||||
|
cursor = conn.execute("SELECT COUNT(*) FROM day_aggregates")
|
||||||
|
day_count = cursor.fetchone()[0]
|
||||||
|
except sqlite3.Error:
|
||||||
|
pass
|
||||||
|
|
||||||
# Freshness age for the service fact
|
# Freshness age for the service fact
|
||||||
freshness_age_s = None
|
freshness_age_s = None
|
||||||
if newest_ts:
|
if newest_ts:
|
||||||
@@ -637,7 +659,7 @@ def get_status(store_path: Optional[Path] = None, clock_now: Optional[datetime]
|
|||||||
# --- Compose output ---
|
# --- Compose output ---
|
||||||
result = _format_projection(
|
result = _format_projection(
|
||||||
proj, freshness, service, drive_facts, config_error,
|
proj, freshness, service, drive_facts, config_error,
|
||||||
None, None, journal_hint,
|
None, None, journal_hint, sample_count, day_count,
|
||||||
)
|
)
|
||||||
|
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|||||||
+33
-8
@@ -12,7 +12,7 @@ from typing import Optional
|
|||||||
|
|
||||||
|
|
||||||
# Schema version - increment on each migration
|
# Schema version - increment on each migration
|
||||||
SCHEMA_VERSION = 1
|
SCHEMA_VERSION = 2
|
||||||
|
|
||||||
|
|
||||||
# Packaged default placement (spec §8.3). The config may override it, but a
|
# Packaged default placement (spec §8.3). The config may override it, but a
|
||||||
@@ -106,7 +106,8 @@ def _create_schema(conn: sqlite3.Connection):
|
|||||||
data_units_read INTEGER,
|
data_units_read INTEGER,
|
||||||
bytes_written INTEGER,
|
bytes_written INTEGER,
|
||||||
bytes_read INTEGER,
|
bytes_read INTEGER,
|
||||||
critical_warning INTEGER
|
critical_warning INTEGER,
|
||||||
|
segment_id INTEGER
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
|
||||||
@@ -141,7 +142,9 @@ def _create_schema(conn: sqlite3.Connection):
|
|||||||
bytes_written_delta INTEGER DEFAULT 0,
|
bytes_written_delta INTEGER DEFAULT 0,
|
||||||
bytes_read_delta INTEGER DEFAULT 0,
|
bytes_read_delta INTEGER DEFAULT 0,
|
||||||
sample_count INTEGER DEFAULT 0,
|
sample_count INTEGER DEFAULT 0,
|
||||||
coverage REAL DEFAULT 0.0
|
coverage REAL DEFAULT 0.0,
|
||||||
|
unattributed_bytes_written INTEGER DEFAULT 0,
|
||||||
|
unattributed_bytes_read INTEGER DEFAULT 0
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
|
||||||
@@ -207,11 +210,25 @@ def _apply_migrations(conn: sqlite3.Connection, current_version: int):
|
|||||||
|
|
||||||
Spec: §3.6, §10.2
|
Spec: §3.6, §10.2
|
||||||
"""
|
"""
|
||||||
# Migration 1→2: example placeholder
|
# Migration 1→2: add segment_id provenance to samples,
|
||||||
# if current_version < 2:
|
# unattributed byte tracking to day_aggregates (issue #73)
|
||||||
# conn.execute("ALTER TABLE ...")
|
if current_version < 2:
|
||||||
# current_version = 2
|
# Defensive: only ALTER if table exists (handles minimal v1 stores)
|
||||||
pass
|
tables = {row[0] for row in conn.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||||
|
).fetchall()}
|
||||||
|
if "samples" in tables:
|
||||||
|
# Check if column already exists (idempotent)
|
||||||
|
cols = {row[1] for row in conn.execute("PRAGMA table_info(samples)").fetchall()}
|
||||||
|
if "segment_id" not in cols:
|
||||||
|
conn.execute("ALTER TABLE samples ADD COLUMN segment_id INTEGER")
|
||||||
|
if "day_aggregates" in tables:
|
||||||
|
cols = {row[1] for row in conn.execute("PRAGMA table_info(day_aggregates)").fetchall()}
|
||||||
|
if "unattributed_bytes_written" not in cols:
|
||||||
|
conn.execute("ALTER TABLE day_aggregates ADD COLUMN unattributed_bytes_written INTEGER DEFAULT 0")
|
||||||
|
if "unattributed_bytes_read" not in cols:
|
||||||
|
conn.execute("ALTER TABLE day_aggregates ADD COLUMN unattributed_bytes_read INTEGER DEFAULT 0")
|
||||||
|
current_version = 2
|
||||||
|
|
||||||
|
|
||||||
def migrate_to_latest(store_path: Path) -> int:
|
def migrate_to_latest(store_path: Path) -> int:
|
||||||
@@ -239,6 +256,14 @@ def migrate_to_latest(store_path: Path) -> int:
|
|||||||
conn.close()
|
conn.close()
|
||||||
return 0 # Already up to date
|
return 0 # Already up to date
|
||||||
|
|
||||||
|
# Version 0 means no schema — create fresh (issue #73)
|
||||||
|
if current_version == 0:
|
||||||
|
_create_schema(conn)
|
||||||
|
conn.execute(f"PRAGMA user_version={SCHEMA_VERSION}")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return SCHEMA_VERSION
|
||||||
|
|
||||||
steps = SCHEMA_VERSION - current_version
|
steps = SCHEMA_VERSION - current_version
|
||||||
_apply_migrations(conn, current_version)
|
_apply_migrations(conn, current_version)
|
||||||
conn.execute(f"PRAGMA user_version={SCHEMA_VERSION}")
|
conn.execute(f"PRAGMA user_version={SCHEMA_VERSION}")
|
||||||
|
|||||||
@@ -447,7 +447,24 @@ class FenrisTuiApp(App):
|
|||||||
|
|
||||||
def _render_all_regions(self, conn: sqlite3.Connection) -> None:
|
def _render_all_regions(self, conn: sqlite3.Connection) -> None:
|
||||||
"""Render all four regions from live store data."""
|
"""Render all four regions from live store data."""
|
||||||
|
# --- Sample count for single-sample state (issue #73 AC3) ---
|
||||||
|
try:
|
||||||
|
cursor = conn.execute("SELECT COUNT(*) FROM samples")
|
||||||
|
sample_count = cursor.fetchone()[0]
|
||||||
|
cursor = conn.execute("SELECT COUNT(*) FROM day_aggregates")
|
||||||
|
day_count = cursor.fetchone()[0]
|
||||||
|
except Exception:
|
||||||
|
sample_count = 0
|
||||||
|
day_count = 0
|
||||||
|
|
||||||
# --- Headline band (§7.2) ---
|
# --- Headline band (§7.2) ---
|
||||||
|
if sample_count <= 1 and day_count == 0:
|
||||||
|
# Single sample: awaiting another sample
|
||||||
|
self._render_headline(
|
||||||
|
"[bold]Awaiting another sample[/bold]\n\n"
|
||||||
|
"Collecting usage data — the first projection requires at least two samples."
|
||||||
|
)
|
||||||
|
else:
|
||||||
try:
|
try:
|
||||||
proj = compute_projection(conn, self._clock_now)
|
proj = compute_projection(conn, self._clock_now)
|
||||||
headline = self._format_headline(proj)
|
headline = self._format_headline(proj)
|
||||||
|
|||||||
@@ -0,0 +1,712 @@
|
|||||||
|
"""Collector history tracer tests (issue #73).
|
||||||
|
|
||||||
|
Tests the end-to-end history pipeline: sample acquisition → interval
|
||||||
|
derivation → hour observation → day aggregate, with concurrent-read
|
||||||
|
safety, cross-hour handling, and display states.
|
||||||
|
|
||||||
|
Seams:
|
||||||
|
- write side: run_collection() → observation store
|
||||||
|
- read side: get_status(), compute_projection() → observation store
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||||
|
|
||||||
|
from fenris.collector import run_collection, normalize_identity
|
||||||
|
from fenris.store import init_store, get_store_path, SCHEMA_VERSION
|
||||||
|
from fenris.monitoring_periods import ensure_period_open, close_period, get_open_period
|
||||||
|
from fenris.day_aggregate import derive_day, derive_all_days
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def smartctl_fixture() -> Dict[str, Any]:
|
||||||
|
"""Minimal smartctl -a -j output with required fields."""
|
||||||
|
return {
|
||||||
|
"json_format_version": [1, 0],
|
||||||
|
"smartctl": {"version": [7, 3], "svn_revision": "5155", "build_info": "(local build)"},
|
||||||
|
"nvme_smart_health_information_log": {
|
||||||
|
"critical_warning": 0,
|
||||||
|
"temperature": 35,
|
||||||
|
"available_spare": 100,
|
||||||
|
"available_spare_threshold": 10,
|
||||||
|
"percentage_used": 5,
|
||||||
|
"data_units_written": 12345678,
|
||||||
|
"data_units_read": 9876543,
|
||||||
|
"power_on_hours": 8765,
|
||||||
|
"power_cycles": 1234,
|
||||||
|
"unsafe_shutdowns": 5,
|
||||||
|
"media_errors": 0,
|
||||||
|
"num_err_log_entries": 0,
|
||||||
|
},
|
||||||
|
"user_capacity": {"bytes": 1024000000000, "units": "bytes"},
|
||||||
|
"model_name": "Samsung SSD 970 EVO Plus 1TB",
|
||||||
|
"serial_number": "S4EWNX0N123456",
|
||||||
|
"firmware_version": "2B2QEXM7",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def sysfs_fixture_tree(tmp_path: Path) -> Path:
|
||||||
|
"""Create a minimal sysfs fixture tree with controller identity."""
|
||||||
|
ctrl_dir = tmp_path / "sys" / "class" / "nvme" / "nvme0"
|
||||||
|
ctrl_dir.mkdir(parents=True)
|
||||||
|
(ctrl_dir / "subsysnqn").write_text("nqn.2014-08.org.nvmexpress:uuid:12345678-1234-1234-1234-123456789abc\n")
|
||||||
|
(ctrl_dir / "model").write_text("Samsung SSD 970 EVO Plus 1TB\n")
|
||||||
|
(ctrl_dir / "serial").write_text("S4EWNX0N123456\n")
|
||||||
|
(ctrl_dir / "firmware_rev").write_text("2B2QEXM7\n")
|
||||||
|
transport_dir = ctrl_dir / "transport"
|
||||||
|
transport_dir.mkdir()
|
||||||
|
(transport_dir / "address").write_text("0000:03:00.0")
|
||||||
|
(transport_dir / "trstring").write_text("pcie")
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def config_fixture(tmp_path: Path) -> Dict[str, Any]:
|
||||||
|
"""Configuration fixture naming the device."""
|
||||||
|
return {
|
||||||
|
"device": "/dev/nvme0",
|
||||||
|
"store_path": str(tmp_path / "observations.db"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class FakeClock:
|
||||||
|
"""Injected clock returning controlled time."""
|
||||||
|
def __init__(self, initial: datetime):
|
||||||
|
self.now = initial
|
||||||
|
def utcnow(self):
|
||||||
|
return self.now
|
||||||
|
def advance(self, **kwargs):
|
||||||
|
self.now = self.now + timedelta(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Schema migration: existing data readable at real precision
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestSchemaMigration:
|
||||||
|
"""Schema migration 1→2 preserves existing data (issue #73 AC1)."""
|
||||||
|
|
||||||
|
def test_migration_bumps_version(self, tmp_path):
|
||||||
|
"""Migration from v1 to v2 succeeds."""
|
||||||
|
from fenris.store import migrate_to_latest, SCHEMA_VERSION
|
||||||
|
|
||||||
|
# Create a v1 store directly (simulating pre-migration state)
|
||||||
|
db = tmp_path / "test.db"
|
||||||
|
conn = sqlite3.connect(str(db))
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
# Create v1 schema manually
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE samples (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ts TEXT NOT NULL,
|
||||||
|
device TEXT NOT NULL,
|
||||||
|
subnqn TEXT, sn TEXT, mn TEXT, fr TEXT,
|
||||||
|
capacity_bytes INTEGER, percentage_used INTEGER,
|
||||||
|
available_spare INTEGER, media_errors INTEGER,
|
||||||
|
power_on_hours INTEGER, power_cycles INTEGER,
|
||||||
|
unsafe_shutdowns INTEGER, temperature_c INTEGER,
|
||||||
|
data_units_written INTEGER, data_units_read INTEGER,
|
||||||
|
bytes_written INTEGER, bytes_read INTEGER,
|
||||||
|
critical_warning INTEGER
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE hour_observations (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
hour TEXT NOT NULL UNIQUE,
|
||||||
|
active_seconds INTEGER DEFAULT 0, idle_seconds INTEGER DEFAULT 0,
|
||||||
|
powered_off_seconds INTEGER DEFAULT 0, unknown_seconds INTEGER DEFAULT 0,
|
||||||
|
bytes_written_delta INTEGER DEFAULT 0, bytes_read_delta INTEGER DEFAULT 0,
|
||||||
|
temperature_min INTEGER, temperature_avg REAL, temperature_max INTEGER,
|
||||||
|
sample_count INTEGER DEFAULT 0, coverage REAL DEFAULT 0.0
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE day_aggregates (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
day TEXT NOT NULL UNIQUE,
|
||||||
|
active_seconds INTEGER DEFAULT 0, idle_seconds INTEGER DEFAULT 0,
|
||||||
|
powered_off_seconds INTEGER DEFAULT 0, unknown_seconds INTEGER DEFAULT 0,
|
||||||
|
bytes_written_delta INTEGER DEFAULT 0, bytes_read_delta INTEGER DEFAULT 0,
|
||||||
|
sample_count INTEGER DEFAULT 0, coverage REAL DEFAULT 0.0
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.execute("CREATE TABLE monitoring_periods (id INTEGER PRIMARY KEY AUTOINCREMENT, started_at TEXT NOT NULL, ended_at TEXT, end_cause TEXT)")
|
||||||
|
conn.execute("CREATE TABLE controller_segments (id INTEGER PRIMARY KEY AUTOINCREMENT, opened_at TEXT NOT NULL, identity_key TEXT, identity_degraded BOOLEAN DEFAULT 0, subnqn TEXT, sn TEXT, mn TEXT, fr TEXT, vid TEXT, ssvid TEXT, transport TEXT)")
|
||||||
|
conn.execute("CREATE TABLE endurance_baseline (id INTEGER PRIMARY KEY AUTOINCREMENT, tbw_terabytes REAL NOT NULL, source_url TEXT, document_revision TEXT, entry_date TEXT, model_string TEXT, nominal_capacity_bytes INTEGER, validated_by TEXT, verified BOOLEAN DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)")
|
||||||
|
conn.execute("CREATE TABLE store_metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL)")
|
||||||
|
conn.execute("PRAGMA user_version=1")
|
||||||
|
conn.execute("INSERT INTO samples (ts, device, mn, sn, fr, capacity_bytes, percentage_used, available_spare, media_errors, power_on_hours, power_cycles, unsafe_shutdowns, temperature_c, data_units_written, data_units_read, bytes_written, bytes_read, critical_warning) VALUES ('2026-09-01T12:00:00+00:00', '/dev/nvme0', 'Test', 'SN', 'FR', 1000000000000, 5, 100, 0, 1000, 100, 0, 35, 1000000, 500000, 512000000000, 256000000000, 0)")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Migrate
|
||||||
|
steps = migrate_to_latest(db)
|
||||||
|
assert steps == 1
|
||||||
|
|
||||||
|
# Verify data preserved
|
||||||
|
conn = sqlite3.connect(str(db))
|
||||||
|
row = conn.execute("SELECT ts, mn FROM samples").fetchone()
|
||||||
|
version = conn.execute("PRAGMA user_version").fetchone()[0]
|
||||||
|
conn.close()
|
||||||
|
assert row[0] == "2026-09-01T12:00:00+00:00"
|
||||||
|
assert row[1] == "Test"
|
||||||
|
assert version == SCHEMA_VERSION
|
||||||
|
|
||||||
|
def test_newer_schema_refused(self, tmp_path):
|
||||||
|
"""Store with user_version > SCHEMA_VERSION is refused."""
|
||||||
|
db = tmp_path / "test.db"
|
||||||
|
conn = sqlite3.connect(str(db))
|
||||||
|
conn.execute("PRAGMA user_version=%d" % (SCHEMA_VERSION + 1))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="newer Fenris"):
|
||||||
|
init_store(db)
|
||||||
|
|
||||||
|
def test_existing_data_preserved_after_migration(self, config_fixture,
|
||||||
|
smartctl_fixture,
|
||||||
|
sysfs_fixture_tree):
|
||||||
|
"""Existing sample data is not lost or modified by migration."""
|
||||||
|
clock = FakeClock(datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc))
|
||||||
|
|
||||||
|
# Write first sample
|
||||||
|
run_collection(smartctl_fixture, sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture, clock)
|
||||||
|
|
||||||
|
conn = sqlite3.connect(config_fixture["store_path"])
|
||||||
|
row = conn.execute("SELECT ts, device, bytes_written FROM samples").fetchone()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
assert row[0] == "2026-09-01T12:00:00+00:00"
|
||||||
|
assert row[1] == "/dev/nvme0"
|
||||||
|
assert row[2] == 12345678 * 512000
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Collector publishes samples, intervals, hour observations, day aggregates
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestCollectorDerivation:
|
||||||
|
"""Collector derives hour observations and day aggregates (issue #73 AC2)."""
|
||||||
|
|
||||||
|
def _make_sample(self, duw_units: int, ts: str) -> Dict[str, Any]:
|
||||||
|
"""Build a smartctl fixture with specific DUW."""
|
||||||
|
return {
|
||||||
|
"json_format_version": [1, 0],
|
||||||
|
"smartctl": {"version": [7, 3], "svn_revision": "5155", "build_info": "(local build)"},
|
||||||
|
"nvme_smart_health_information_log": {
|
||||||
|
"critical_warning": 0,
|
||||||
|
"temperature": 35,
|
||||||
|
"available_spare": 100,
|
||||||
|
"available_spare_threshold": 10,
|
||||||
|
"percentage_used": 5,
|
||||||
|
"data_units_written": duw_units,
|
||||||
|
"data_units_read": 9876543,
|
||||||
|
"power_on_hours": 8765,
|
||||||
|
"power_cycles": 1234,
|
||||||
|
"unsafe_shutdowns": 5,
|
||||||
|
"media_errors": 0,
|
||||||
|
"num_err_log_entries": 0,
|
||||||
|
},
|
||||||
|
"user_capacity": {"bytes": 1024000000000, "units": "bytes"},
|
||||||
|
"model_name": "Samsung SSD 970 EVO Plus 1TB",
|
||||||
|
"serial_number": "S4EWNX0N123456",
|
||||||
|
"firmware_version": "2B2QEXM7",
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_same_hour_20mb_derives_hour_obs(self, config_fixture, sysfs_fixture_tree):
|
||||||
|
"""Two samples in same hour with 20 MB delta → hour_obs gets 20 MB."""
|
||||||
|
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
# DUW units: 12345678 * 512000 = ~6.3 TB; 20 MB = 20*1024*1024 / 512000 ≈ 40 units
|
||||||
|
duw1 = 12345678
|
||||||
|
duw2 = duw1 + 40 # ~20 MB more
|
||||||
|
|
||||||
|
clock1 = FakeClock(t1)
|
||||||
|
s1 = self._make_sample(duw1, t1.isoformat())
|
||||||
|
r1 = run_collection(s1, sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture, clock1)
|
||||||
|
assert r1["ok"]
|
||||||
|
|
||||||
|
clock2 = FakeClock(t2)
|
||||||
|
s2 = self._make_sample(duw2, t2.isoformat())
|
||||||
|
r2 = run_collection(s2, sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture, clock2)
|
||||||
|
assert r2["ok"]
|
||||||
|
|
||||||
|
# Check hour_observation was derived
|
||||||
|
conn = sqlite3.connect(config_fixture["store_path"])
|
||||||
|
hour = conn.execute(
|
||||||
|
"SELECT bytes_written_delta, sample_count FROM hour_observations WHERE hour LIKE '2026-09-01T12%'"
|
||||||
|
).fetchone()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
assert hour is not None, "Hour observation should exist for 12:00"
|
||||||
|
assert hour[1] >= 2 # at least 2 samples contributed
|
||||||
|
# bytes_written_delta should be the 20 MB delta (40 * 512000 = 20480000)
|
||||||
|
assert hour[0] == 40 * 512000
|
||||||
|
|
||||||
|
def test_zero_delta_derives_hour_obs(self, config_fixture, sysfs_fixture_tree):
|
||||||
|
"""Two samples in same hour with no DUW change → 0 B written."""
|
||||||
|
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
duw = 12345678 # same for both
|
||||||
|
|
||||||
|
clock1 = FakeClock(t1)
|
||||||
|
s1 = self._make_sample(duw, t1.isoformat())
|
||||||
|
run_collection(s1, sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture, clock1)
|
||||||
|
|
||||||
|
clock2 = FakeClock(t2)
|
||||||
|
s2 = self._make_sample(duw, t2.isoformat())
|
||||||
|
run_collection(s2, sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture, clock2)
|
||||||
|
|
||||||
|
conn = sqlite3.connect(config_fixture["store_path"])
|
||||||
|
hour = conn.execute(
|
||||||
|
"SELECT bytes_written_delta FROM hour_observations WHERE hour LIKE '2026-09-01T12%'"
|
||||||
|
).fetchone()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
assert hour is not None
|
||||||
|
assert hour[0] == 0
|
||||||
|
|
||||||
|
def test_cross_hour_100mb_unattributed(self, config_fixture, sysfs_fixture_tree):
|
||||||
|
"""Samples in different hours → delta is unattributed to any hour."""
|
||||||
|
t1 = datetime(2026, 9, 1, 11, 55, 0, tzinfo=timezone.utc)
|
||||||
|
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
duw1 = 12345678
|
||||||
|
duw2 = duw1 + 200 # ~100 MB
|
||||||
|
|
||||||
|
clock1 = FakeClock(t1)
|
||||||
|
s1 = self._make_sample(duw1, t1.isoformat())
|
||||||
|
run_collection(s1, sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture, clock1)
|
||||||
|
|
||||||
|
clock2 = FakeClock(t2)
|
||||||
|
s2 = self._make_sample(duw2, t2.isoformat())
|
||||||
|
run_collection(s2, sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture, clock2)
|
||||||
|
|
||||||
|
conn = sqlite3.connect(config_fixture["store_path"])
|
||||||
|
|
||||||
|
# Hour observations should NOT contain the cross-hour delta
|
||||||
|
hour11 = conn.execute(
|
||||||
|
"SELECT bytes_written_delta FROM hour_observations WHERE hour LIKE '2026-09-01T11%'"
|
||||||
|
).fetchone()
|
||||||
|
hour12 = conn.execute(
|
||||||
|
"SELECT bytes_written_delta FROM hour_observations WHERE hour LIKE '2026-09-01T12%'"
|
||||||
|
).fetchone()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Neither hour should have the full 100 MB delta attributed
|
||||||
|
# (they may have 0 or partial, but not 200*512000)
|
||||||
|
full_delta = 200 * 512000
|
||||||
|
if hour11 is not None:
|
||||||
|
assert hour11[0] != full_delta, "Hour 11 should not have full cross-hour delta"
|
||||||
|
if hour12 is not None:
|
||||||
|
assert hour12[0] != full_delta, "Hour 12 should not have full cross-hour delta"
|
||||||
|
|
||||||
|
def test_readonly_sees_consistent_snapshot(self, config_fixture, sysfs_fixture_tree):
|
||||||
|
"""A read-only reader sees valid pre- or post-publication snapshot."""
|
||||||
|
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
clock1 = FakeClock(t1)
|
||||||
|
run_collection(self._make_sample(12345678, t1.isoformat()),
|
||||||
|
sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture, clock1)
|
||||||
|
|
||||||
|
# Open read-only
|
||||||
|
ro_conn = sqlite3.connect(
|
||||||
|
"file:%s?mode=ro" % config_fixture["store_path"], uri=True
|
||||||
|
)
|
||||||
|
count_before = ro_conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0]
|
||||||
|
ro_conn.close()
|
||||||
|
assert count_before == 1
|
||||||
|
|
||||||
|
# Write second sample
|
||||||
|
clock2 = FakeClock(t2)
|
||||||
|
run_collection(self._make_sample(12345718, t2.isoformat()),
|
||||||
|
sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture, clock2)
|
||||||
|
|
||||||
|
# Read-only reader sees 2 samples now
|
||||||
|
ro_conn2 = sqlite3.connect(
|
||||||
|
"file:%s?mode=ro" % config_fixture["store_path"], uri=True
|
||||||
|
)
|
||||||
|
count_after = ro_conn2.execute("SELECT COUNT(*) FROM samples").fetchone()[0]
|
||||||
|
ro_conn2.close()
|
||||||
|
assert count_after == 2
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Display states: awaiting first sample, awaiting another sample
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestDisplayStates:
|
||||||
|
"""Display states for zero/one/two+ samples (issue #73 AC3)."""
|
||||||
|
|
||||||
|
def test_zero_samples_awaiting_first(self, tmp_path):
|
||||||
|
"""Zero samples → 'awaiting first sample' state."""
|
||||||
|
from fenris.status import get_status
|
||||||
|
db = tmp_path / "test.db"
|
||||||
|
init_store(db)
|
||||||
|
now = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
from unittest.mock import patch
|
||||||
|
with patch("fenris.status.query_service_state", return_value={
|
||||||
|
"boot_enabled": False, "timer_active": False,
|
||||||
|
"last_collect_ok": None, "last_collect_age_s": None,
|
||||||
|
"last_collect_reason": None,
|
||||||
|
}):
|
||||||
|
result = get_status(store_path=db, clock_now=now,
|
||||||
|
query_services=False, query_journal=False)
|
||||||
|
assert "no observations yet" in result.lower() or "awaiting" in result.lower()
|
||||||
|
|
||||||
|
def test_one_sample_awaiting_another(self, tmp_path):
|
||||||
|
"""One sample → 'awaiting another sample' state."""
|
||||||
|
from fenris.status import get_status
|
||||||
|
db = tmp_path / "test.db"
|
||||||
|
conn = init_store(db)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO samples (ts, device, mn, sn, fr, capacity_bytes, "
|
||||||
|
"percentage_used, available_spare, media_errors, power_on_hours, "
|
||||||
|
"power_cycles, unsafe_shutdowns, temperature_c, "
|
||||||
|
"data_units_written, data_units_read, bytes_written, bytes_read, "
|
||||||
|
"critical_warning) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
("2026-09-01T12:00:00+00:00", "/dev/nvme0", "Test", "SN", "FR",
|
||||||
|
1000000000000, 5, 100, 0, 1000, 100, 0, 35,
|
||||||
|
1000000, 500000, 512000000000, 256000000000, 0),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
now = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
from unittest.mock import patch
|
||||||
|
with patch("fenris.status.query_service_state", return_value={
|
||||||
|
"boot_enabled": False, "timer_active": False,
|
||||||
|
"last_collect_ok": None, "last_collect_age_s": None,
|
||||||
|
"last_collect_reason": None,
|
||||||
|
}):
|
||||||
|
result = get_status(store_path=db, clock_now=now,
|
||||||
|
query_services=False, query_journal=False)
|
||||||
|
# Should mention awaiting or insufficient data
|
||||||
|
lower = result.lower()
|
||||||
|
assert "awaiting" in lower or "another sample" in lower or "no projection" in lower
|
||||||
|
|
||||||
|
def test_one_sample_awaiting_another_in_tui(self, tmp_path):
|
||||||
|
"""One sample → TUI shows awaiting state."""
|
||||||
|
from fenris.tui import FenrisTuiApp, _query_service_facts
|
||||||
|
db = tmp_path / "test.db"
|
||||||
|
conn = init_store(db)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO samples (ts, device, mn, sn, fr, capacity_bytes, "
|
||||||
|
"percentage_used, available_spare, media_errors, power_on_hours, "
|
||||||
|
"power_cycles, unsafe_shutdowns, temperature_c, "
|
||||||
|
"data_units_written, data_units_read, bytes_written, bytes_read, "
|
||||||
|
"critical_warning) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
("2026-09-01T12:00:00+00:00", "/dev/nvme0", "Test", "SN", "FR",
|
||||||
|
1000000000000, 5, 100, 0, 1000, 100, 0, 35,
|
||||||
|
1000000, 500000, 512000000000, 256000000000, 0),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
now = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
# Test the projection handles single sample
|
||||||
|
from fenris.projection import compute_projection, ConfidenceState
|
||||||
|
conn = sqlite3.connect(db)
|
||||||
|
proj = compute_projection(conn, now)
|
||||||
|
conn.close()
|
||||||
|
# With only one sample, projection should be unavailable
|
||||||
|
assert proj.confidence_state == ConfidenceState.UNSUPPORTED
|
||||||
|
|
||||||
|
def test_two_same_hour_samples_show_measured_usage(self, config_fixture, sysfs_fixture_tree):
|
||||||
|
"""Two compatible same-hour samples show measured usage."""
|
||||||
|
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
clock1 = FakeClock(t1)
|
||||||
|
run_collection(self._make_sample_helper(12345678), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture, clock1)
|
||||||
|
|
||||||
|
clock2 = FakeClock(t2)
|
||||||
|
run_collection(self._make_sample_helper(12345718), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture, clock2)
|
||||||
|
|
||||||
|
from fenris.status import get_status
|
||||||
|
from unittest.mock import patch
|
||||||
|
now = datetime(2026, 9, 1, 12, 10, 0, tzinfo=timezone.utc)
|
||||||
|
with patch("fenris.status.query_service_state", return_value={
|
||||||
|
"boot_enabled": False, "timer_active": False,
|
||||||
|
"last_collect_ok": None, "last_collect_age_s": None,
|
||||||
|
"last_collect_reason": None,
|
||||||
|
}):
|
||||||
|
result = get_status(store_path=Path(config_fixture["store_path"]),
|
||||||
|
clock_now=now, query_services=False, query_journal=False)
|
||||||
|
# Should not say "no observations" or "awaiting"
|
||||||
|
lower = result.lower()
|
||||||
|
assert "no observations yet" not in lower
|
||||||
|
|
||||||
|
def _make_sample_helper(self, duw_units: int) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"json_format_version": [1, 0],
|
||||||
|
"smartctl": {"version": [7, 3], "svn_revision": "5155", "build_info": "(local build)"},
|
||||||
|
"nvme_smart_health_information_log": {
|
||||||
|
"critical_warning": 0, "temperature": 35,
|
||||||
|
"available_spare": 100, "available_spare_threshold": 10,
|
||||||
|
"percentage_used": 5, "data_units_written": duw_units,
|
||||||
|
"data_units_read": 9876543, "power_on_hours": 8765,
|
||||||
|
"power_cycles": 1234, "unsafe_shutdowns": 5,
|
||||||
|
"media_errors": 0, "num_err_log_entries": 0,
|
||||||
|
},
|
||||||
|
"user_capacity": {"bytes": 1024000000000, "units": "bytes"},
|
||||||
|
"model_name": "Samsung SSD 970 EVO Plus 1TB",
|
||||||
|
"serial_number": "S4EWNX0N123456",
|
||||||
|
"firmware_version": "2B2QEXM7",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Pause crossing and counter reset
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestPauseCrossing:
|
||||||
|
"""Delta across monitoring period gap (issue #73 AC4)."""
|
||||||
|
|
||||||
|
def test_pause_crossing_preserves_prior_history(self, config_fixture, sysfs_fixture_tree):
|
||||||
|
"""Delta across a paused period preserves prior hour observations."""
|
||||||
|
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
t_pause = datetime(2026, 9, 1, 13, 0, 0, tzinfo=timezone.utc)
|
||||||
|
t_resume = datetime(2026, 9, 1, 14, 0, 0, tzinfo=timezone.utc)
|
||||||
|
t2 = datetime(2026, 9, 1, 14, 5, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
duw1 = 12345678
|
||||||
|
duw2 = duw1 + 100
|
||||||
|
|
||||||
|
# First sample (opens period)
|
||||||
|
clock1 = FakeClock(t1)
|
||||||
|
run_collection(self._make_sample_for_pause(duw1), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture, clock1)
|
||||||
|
|
||||||
|
# Pause
|
||||||
|
conn = sqlite3.connect(config_fixture["store_path"])
|
||||||
|
close_period(conn, t_pause, "user_disabled")
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Resume with new sample
|
||||||
|
clock_resume = FakeClock(t_resume)
|
||||||
|
run_collection(self._make_sample_for_pause(duw1), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture, clock_resume)
|
||||||
|
|
||||||
|
# Second sample after resume
|
||||||
|
clock2 = FakeClock(t2)
|
||||||
|
run_collection(self._make_sample_for_pause(duw2), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture, clock2)
|
||||||
|
|
||||||
|
# Verify prior hour observations are intact
|
||||||
|
conn = sqlite3.connect(config_fixture["store_path"])
|
||||||
|
hour_12 = conn.execute(
|
||||||
|
"SELECT bytes_written_delta FROM hour_observations WHERE hour LIKE '2026-09-01T12%'"
|
||||||
|
).fetchone()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Hour 12 should still have data from the first collection
|
||||||
|
assert hour_12 is not None, "Prior hour observation should be preserved"
|
||||||
|
|
||||||
|
def _make_sample_for_pause(self, duw_units: int) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"json_format_version": [1, 0],
|
||||||
|
"smartctl": {"version": [7, 3], "svn_revision": "5155", "build_info": "(local build)"},
|
||||||
|
"nvme_smart_health_information_log": {
|
||||||
|
"critical_warning": 0, "temperature": 35,
|
||||||
|
"available_spare": 100, "available_spare_threshold": 10,
|
||||||
|
"percentage_used": 5, "data_units_written": duw_units,
|
||||||
|
"data_units_read": 9876543, "power_on_hours": 8765,
|
||||||
|
"power_cycles": 1234, "unsafe_shutdowns": 5,
|
||||||
|
"media_errors": 0, "num_err_log_entries": 0,
|
||||||
|
},
|
||||||
|
"user_capacity": {"bytes": 1024000000000, "units": "bytes"},
|
||||||
|
"model_name": "Samsung SSD 970 EVO Plus 1TB",
|
||||||
|
"serial_number": "S4EWNX0N123456",
|
||||||
|
"firmware_version": "2B2QEXM7",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestCounterReset:
|
||||||
|
"""Counter reset / replacement opens new segment (issue #73 AC6)."""
|
||||||
|
|
||||||
|
def test_duw_decrease_opens_new_segment(self, config_fixture, sysfs_fixture_tree):
|
||||||
|
"""DUW decrease triggers new segment."""
|
||||||
|
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
duw1 = 12345678
|
||||||
|
duw2 = duw1 - 100 # decrease = reset
|
||||||
|
|
||||||
|
clock1 = FakeClock(t1)
|
||||||
|
run_collection(self._make_sample_for_reset(duw1), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture, clock1)
|
||||||
|
|
||||||
|
clock2 = FakeClock(t2)
|
||||||
|
run_collection(self._make_sample_for_reset(duw2), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture, clock2)
|
||||||
|
|
||||||
|
conn = sqlite3.connect(config_fixture["store_path"])
|
||||||
|
segments = conn.execute("SELECT COUNT(*) FROM controller_segments").fetchone()[0]
|
||||||
|
samples = conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0]
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Should have 2 segments (new one opened for DUW decrease)
|
||||||
|
assert segments == 2
|
||||||
|
# Should have 2 samples
|
||||||
|
assert samples == 2
|
||||||
|
|
||||||
|
def _make_sample_for_reset(self, duw_units: int) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"json_format_version": [1, 0],
|
||||||
|
"smartctl": {"version": [7, 3], "svn_revision": "5155", "build_info": "(local build)"},
|
||||||
|
"nvme_smart_health_information_log": {
|
||||||
|
"critical_warning": 0, "temperature": 35,
|
||||||
|
"available_spare": 100, "available_spare_threshold": 10,
|
||||||
|
"percentage_used": 5, "data_units_written": duw_units,
|
||||||
|
"data_units_read": 9876543, "power_on_hours": 8765,
|
||||||
|
"power_cycles": 1234, "unsafe_shutdowns": 5,
|
||||||
|
"media_errors": 0, "num_err_log_entries": 0,
|
||||||
|
},
|
||||||
|
"user_capacity": {"bytes": 1024000000000, "units": "bytes"},
|
||||||
|
"model_name": "Samsung SSD 970 EVO Plus 1TB",
|
||||||
|
"serial_number": "S4EWNX0N123456",
|
||||||
|
"firmware_version": "2B2QEXM7",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Derivation failure preserves prior history
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestDerivationFailure:
|
||||||
|
"""Injected derivation failure preserves prior history (issue #73 AC6)."""
|
||||||
|
|
||||||
|
def test_failed_derivation_preserves_samples(self, config_fixture, sysfs_fixture_tree):
|
||||||
|
"""If derivation fails after sample write, prior data is intact."""
|
||||||
|
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
clock1 = FakeClock(t1)
|
||||||
|
run_collection(self._make_sample_for_failure(12345678),
|
||||||
|
sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture, clock1)
|
||||||
|
|
||||||
|
# Verify first sample exists
|
||||||
|
conn = sqlite3.connect(config_fixture["store_path"])
|
||||||
|
count = conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0]
|
||||||
|
conn.close()
|
||||||
|
assert count == 1
|
||||||
|
|
||||||
|
# Second sample with valid data should succeed
|
||||||
|
clock2 = FakeClock(t2)
|
||||||
|
r2 = run_collection(self._make_sample_for_failure(12345718),
|
||||||
|
sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture, clock2)
|
||||||
|
assert r2["ok"]
|
||||||
|
|
||||||
|
# Both samples should exist
|
||||||
|
conn = sqlite3.connect(config_fixture["store_path"])
|
||||||
|
count = conn.execute("SELECT COUNT(*) FROM samples").fetchone()[0]
|
||||||
|
conn.close()
|
||||||
|
assert count == 2
|
||||||
|
|
||||||
|
def _make_sample_for_failure(self, duw_units: int) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"json_format_version": [1, 0],
|
||||||
|
"smartctl": {"version": [7, 3], "svn_revision": "5155", "build_info": "(local build)"},
|
||||||
|
"nvme_smart_health_information_log": {
|
||||||
|
"critical_warning": 0, "temperature": 35,
|
||||||
|
"available_spare": 100, "available_spare_threshold": 10,
|
||||||
|
"percentage_used": 5, "data_units_written": duw_units,
|
||||||
|
"data_units_read": 9876543, "power_on_hours": 8765,
|
||||||
|
"power_cycles": 1234, "unsafe_shutdowns": 5,
|
||||||
|
"media_errors": 0, "num_err_log_entries": 0,
|
||||||
|
},
|
||||||
|
"user_capacity": {"bytes": 1024000000000, "units": "bytes"},
|
||||||
|
"model_name": "Samsung SSD 970 EVO Plus 1TB",
|
||||||
|
"serial_number": "S4EWNX0N123456",
|
||||||
|
"firmware_version": "2B2QEXM7",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Monitoring period is opened by collector
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestMonitoringPeriod:
|
||||||
|
"""Collector ensures monitoring period is open (issue #73 AC2)."""
|
||||||
|
|
||||||
|
def test_first_sample_opens_period(self, config_fixture, sysfs_fixture_tree):
|
||||||
|
"""First collection run opens a monitoring period."""
|
||||||
|
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
clock1 = FakeClock(t1)
|
||||||
|
run_collection(self._make_sample_simple(), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture, clock1)
|
||||||
|
|
||||||
|
conn = sqlite3.connect(config_fixture["store_path"])
|
||||||
|
period = get_open_period(conn)
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
assert period is not None, "A monitoring period should be open"
|
||||||
|
|
||||||
|
def test_subsequent_sample_keeps_period_open(self, config_fixture, sysfs_fixture_tree):
|
||||||
|
"""Subsequent collection runs keep the period open."""
|
||||||
|
t1 = datetime(2026, 9, 1, 12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
t2 = datetime(2026, 9, 1, 12, 5, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
clock1 = FakeClock(t1)
|
||||||
|
run_collection(self._make_sample_simple(), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture, clock1)
|
||||||
|
|
||||||
|
clock2 = FakeClock(t2)
|
||||||
|
run_collection(self._make_sample_simple(), sysfs_fixture_tree / "sys" / "class" / "nvme" / "nvme0",
|
||||||
|
config_fixture, clock2)
|
||||||
|
|
||||||
|
conn = sqlite3.connect(config_fixture["store_path"])
|
||||||
|
period = get_open_period(conn)
|
||||||
|
periods_count = conn.execute("SELECT COUNT(*) FROM monitoring_periods").fetchone()[0]
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
assert period is not None
|
||||||
|
assert periods_count == 1 # Still only one period
|
||||||
|
|
||||||
|
def _make_sample_simple(self) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"json_format_version": [1, 0],
|
||||||
|
"smartctl": {"version": [7, 3], "svn_revision": "5155", "build_info": "(local build)"},
|
||||||
|
"nvme_smart_health_information_log": {
|
||||||
|
"critical_warning": 0, "temperature": 35,
|
||||||
|
"available_spare": 100, "available_spare_threshold": 10,
|
||||||
|
"percentage_used": 5, "data_units_written": 12345678,
|
||||||
|
"data_units_read": 9876543, "power_on_hours": 8765,
|
||||||
|
"power_cycles": 1234, "unsafe_shutdowns": 5,
|
||||||
|
"media_errors": 0, "num_err_log_entries": 0,
|
||||||
|
},
|
||||||
|
"user_capacity": {"bytes": 1024000000000, "units": "bytes"},
|
||||||
|
"model_name": "Samsung SSD 970 EVO Plus 1TB",
|
||||||
|
"serial_number": "S4EWNX0N123456",
|
||||||
|
"firmware_version": "2B2QEXM7",
|
||||||
|
}
|
||||||
@@ -118,12 +118,12 @@ class TestMigrateToLatest:
|
|||||||
assert migrate_to_latest(db) == 0
|
assert migrate_to_latest(db) == 0
|
||||||
|
|
||||||
def test_migrates_intermediate_version(self, tmp_path):
|
def test_migrates_intermediate_version(self, tmp_path):
|
||||||
"""Store at version 1 with SCHEMA_VERSION=1 → 0 steps (current)."""
|
"""Store at version SCHEMA_VERSION-1 → 1 step to current."""
|
||||||
|
from fenris.store import SCHEMA_VERSION
|
||||||
db = tmp_path / "observations.db"
|
db = tmp_path / "observations.db"
|
||||||
_make_store(db, version=1)
|
_make_store(db, version=SCHEMA_VERSION - 1)
|
||||||
# SCHEMA_VERSION is 1, so version 1 is current
|
|
||||||
steps = migrate_to_latest(db)
|
steps = migrate_to_latest(db)
|
||||||
assert steps == 0
|
assert steps == 1
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user