Plot live drive activity every three minutes (#91)
- Change default collection cadence from 5 minutes to 3 minutes (CADENCE_DEFAULT_S=180, systemd OnUnitInactiveSec=3min, runit CADENCE=180) - Update freshness threshold to 450s (2×180 + AccuracySec + 60) - Add _query_live_graph_data(): queries raw samples from the last 3 hours and computes interval byte deltas with actual timestamps - Add LiveActivityGraph widget: vertical bar chart of interval volumes with read/write toggle (w key), arrow key inspection, and click support - Wire live graph into TUI layout (full-width row between daily graph and drive health), refresh cycle, and CSS grid - Replace t theme binding with t today/live binding; theme selection via preferences file - Add w binding for read/write toggle on live graph - Update action legend, help screen, and grid layout for new live-activity row - Add 20 tests covering cadence constants, live query, widget rendering, toggle, and TUI integration - Update all cadence documentation (README, ADR 0003, acceptance criteria LC-2, fenris-redesign constants table, CHANGELOG)
This commit is contained in:
+1
-1
@@ -13,7 +13,7 @@ backfill releases from before this changelog.
|
|||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- Make usage-history axes, units, UTC boundaries, active 7/14/30/90-day window, gaps, partial periods, stacked write attribution, and hourly drill-down explicit; keep live graph data refreshed on the existing five-minute cadence.
|
- Make usage-history axes, units, UTC boundaries, active 7/14/30/90-day window, gaps, partial periods, stacked write attribution, and hourly drill-down explicit; keep live graph data refreshed on the current three-minute cadence.
|
||||||
|
|
||||||
## [0.3.6] - 2026-09-16
|
## [0.3.6] - 2026-09-16
|
||||||
|
|
||||||
|
|||||||
@@ -211,7 +211,7 @@ Uninstall performs the sanctioned disable first (`fenris-monitor disable --now`)
|
|||||||
|
|
||||||
## Cadence drop-ins
|
## Cadence drop-ins
|
||||||
|
|
||||||
The default collection cadence is **5 minutes** (`OnUnitInactiveSec=5min` in the timer unit). To change it, place a systemd drop-in:
|
The default collection cadence is **3 minutes** (`OnUnitInactiveSec=3min` in the timer unit). To change it, place a systemd drop-in:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sudo systemctl edit fenris-collect.timer
|
sudo systemctl edit fenris-collect.timer
|
||||||
@@ -223,7 +223,7 @@ sudo systemctl edit fenris-collect.timer
|
|||||||
No interval key exists in `/etc/fenris/fenris.conf`. Cadence is a systemd concern, not a Fenris configuration key.
|
No interval key exists in `/etc/fenris/fenris.conf`. Cadence is a systemd concern, not a Fenris configuration key.
|
||||||
|
|
||||||
On Void, Fenris uses its native runit service instead: its initial collection
|
On Void, Fenris uses its native runit service instead: its initial collection
|
||||||
is delayed by two minutes and later collections run five minutes after the
|
is delayed by two minutes and later collections run three minutes after the
|
||||||
previous run finishes. Inspect its state and diagnostics with:
|
previous run finishes. Inspect its state and diagnostics with:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ Fenris's current single process combines daemonization, a PID file, an HTTP dash
|
|||||||
## Decision
|
## Decision
|
||||||
|
|
||||||
1. **Units.** Two system units only: `fenris-collect.timer` (`WantedBy=timers.target`) and `fenris-collect.service` (`Type=oneshot`, root, `ExecStart=/usr/libexec/fenris/fenris-collect`; no listener, no UI code). The TUI and CLI are ordinary unprivileged processes and never units. There is no `/run/fenris` coordination surface: systemd serializes runs, the observation store holds state, and failures go to the journal per [ADR 0001](0001-observation-store-sqlite.md).
|
1. **Units.** Two system units only: `fenris-collect.timer` (`WantedBy=timers.target`) and `fenris-collect.service` (`Type=oneshot`, root, `ExecStart=/usr/libexec/fenris/fenris-collect`; no listener, no UI code). The TUI and CLI are ordinary unprivileged processes and never units. There is no `/run/fenris` coordination surface: systemd serializes runs, the observation store holds state, and failures go to the journal per [ADR 0001](0001-observation-store-sqlite.md).
|
||||||
2. **Cadence.** Default five minutes: `OnBootSec=2min`, `OnUnitInactiveSec=5min` (measured from run completion; drift accepted because hours are the evidence grain), `AccuracySec=30s`, `Persistent=no`, no suspend catch-up (absent hours classify through power-on-hours evidence), `TimeoutStartSec=90s` so a hung interrogation fails visibly. Cadence changes are documented drop-ins on the timer unit (`systemctl edit` + daemon-reload); no interval key exists in configuration.
|
2. **Cadence.** Default three minutes: `OnBootSec=2min`, `OnUnitInactiveSec=3min` (measured from run completion; drift accepted because hours are the evidence grain), `AccuracySec=30s`, `Persistent=no`, no suspend catch-up (absent hours classify through power-on-hours evidence), `TimeoutStartSec=90s` so a hung interrogation fails visibly. Cadence changes are documented drop-ins on the timer unit (`systemctl edit` + daemon-reload); no interval key exists in configuration.
|
||||||
3. **Configuration.** `/etc/fenris/fenris.conf` holds exactly one key: the device selector, a stable `/dev/disk/by-id/…` path (raw nodes accepted with an instability warning), validated at collection time. The oneshot re-reads it every run, so there is no reload path to design. An invalid selector is a bounded failed run — journal plus failed unit result, retried next interval; `status` and the TUI also read the world-readable file directly and surface a `configuration error: <reason>` fact.
|
3. **Configuration.** `/etc/fenris/fenris.conf` holds exactly one key: the device selector, a stable `/dev/disk/by-id/…` path (raw nodes accepted with an instability warning), validated at collection time. The oneshot re-reads it every run, so there is no reload path to design. An invalid selector is a bounded failed run — journal plus failed unit result, retried next interval; `status` and the TUI also read the world-readable file directly and surface a `configuration error: <reason>` fact.
|
||||||
4. **Entry points.** Two privileged binaries: `/usr/libexec/fenris/fenris-collect` (device interrogation and store writes; the unit's `ExecStart`) and `/usr/libexec/fenris/fenris-monitor` (fixed operations `enable` and `disable` with optional `--now`, plus the collect trigger, monitoring-period bookkeeping, and `baseline set`/`baseline clear` persistence for the CLI-validated endurance baseline; the only binary the polkit policy authorizes). One unprivileged `fenris` for humans: no arguments opens the TUI; subcommands (`status`, `sample`, `monitor pause`, `monitor resume`) are the CLI.
|
4. **Entry points.** Two privileged binaries: `/usr/libexec/fenris/fenris-collect` (device interrogation and store writes; the unit's `ExecStart`) and `/usr/libexec/fenris/fenris-monitor` (fixed operations `enable` and `disable` with optional `--now`, plus the collect trigger, monitoring-period bookkeeping, and `baseline set`/`baseline clear` persistence for the CLI-validated endurance baseline; the only binary the polkit policy authorizes). One unprivileged `fenris` for humans: no arguments opens the TUI; subcommands (`status`, `sample`, `monitor pause`, `monitor resume`) are the CLI.
|
||||||
5. **Sanctioned toggle.** Pause = `disable --now`; Resume = `enable --now`; both executed by `fenris-monitor`, which performs the systemctl operation and the monitoring-period bookkeeping in one step, under polkit action `com.bongbetic.fenris.monitor` (`auth_admin`, covering the collect trigger too). Root invokes the helpers directly; where no polkit agent exists the operation fails cleanly and prints the root equivalent. This amends the research's direct-systemctl toggle: a period boundary cannot be recorded by systemctl, so the toggle must be Fenris's own fixed operation.
|
5. **Sanctioned toggle.** Pause = `disable --now`; Resume = `enable --now`; both executed by `fenris-monitor`, which performs the systemctl operation and the monitoring-period bookkeeping in one step, under polkit action `com.bongbetic.fenris.monitor` (`auth_admin`, covering the collect trigger too). Root invokes the helpers directly; where no polkit agent exists the operation fails cleanly and prints the root equivalent. This amends the research's direct-systemctl toggle: a period boundary cannot be recorded by systemctl, so the toggle must be Fenris's own fixed operation.
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ Status: Accepted — resolves [Define cross-cutting acceptance criteria](https:/
|
|||||||
## Collector lifecycle and privilege boundaries (ADR 0003)
|
## Collector lifecycle and privilege boundaries (ADR 0003)
|
||||||
|
|
||||||
- **LC-1** (P) Exactly two system units exist — `fenris-collect.timer` (`timers.target`) and `fenris-collect.service` (`Type=oneshot`, root, `ExecStart=/usr/libexec/fenris/fenris-collect`); the TUI and CLI are ordinary unprivileged processes and never units.
|
- **LC-1** (P) Exactly two system units exist — `fenris-collect.timer` (`timers.target`) and `fenris-collect.service` (`Type=oneshot`, root, `ExecStart=/usr/libexec/fenris/fenris-collect`); the TUI and CLI are ordinary unprivileged processes and never units.
|
||||||
- **LC-2** (P) Timer defaults ship as `OnBootSec=2min`, `OnUnitInactiveSec=5min`, `AccuracySec=30s`, `Persistent=no`, `TimeoutStartSec=90s`; cadence changes are documented drop-ins and no interval key exists in configuration.
|
- **LC-2** (P) Timer defaults ship as `OnBootSec=2min`, `OnUnitInactiveSec=3min`, `AccuracySec=30s`, `Persistent=no`, `TimeoutStartSec=90s`; cadence changes are documented drop-ins and no interval key exists in configuration.
|
||||||
- **LC-3** (P) A hung device interrogation fails visibly within `TimeoutStartSec=90s` as a bounded failed run retried next interval.
|
- **LC-3** (P) A hung device interrogation fails visibly within `TimeoutStartSec=90s` as a bounded failed run retried next interval.
|
||||||
- **LC-4** (A/P) `/etc/fenris/fenris.conf` holds exactly the device selector (stable `/dev/disk/by-id/…` path; raw nodes warned), re-read every run; an invalid selector is a bounded failed run surfaced as `configuration error: <reason>` in `status` and the TUI.
|
- **LC-4** (A/P) `/etc/fenris/fenris.conf` holds exactly the device selector (stable `/dev/disk/by-id/…` path; raw nodes warned), re-read every run; an invalid selector is a bounded failed run surfaced as `configuration error: <reason>` in `status` and the TUI.
|
||||||
- **LC-5** (P) Two privileged binaries ship at `/usr/libexec/fenris/fenris-collect` and `/usr/libexec/fenris/fenris-monitor`; the unprivileged `fenris` wrapper opens the TUI with no arguments.
|
- **LC-5** (P) Two privileged binaries ship at `/usr/libexec/fenris/fenris-collect` and `/usr/libexec/fenris/fenris-monitor`; the unprivileged `fenris` wrapper opens the TUI with no arguments.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ Every constant is defined once, in the section named below; other sections cite,
|
|||||||
|
|
||||||
| Constant | Value | Defined in |
|
| Constant | Value | Defined in |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Collection cadence (default) | 5 min (`OnUnitInactiveSec`) | §8.2 |
|
| Collection cadence (default) | 3 min (`OnUnitInactiveSec`) | §8.2 |
|
||||||
| First-boot delay | 2 min (`OnBootSec`) | §8.2 |
|
| First-boot delay | 2 min (`OnBootSec`) | §8.2 |
|
||||||
| Timer accuracy window | 30 s (`AccuracySec`) | §8.2 |
|
| Timer accuracy window | 30 s (`AccuracySec`) | §8.2 |
|
||||||
| Collection-run timeout | 90 s (`TimeoutStartSec`) | §8.2 |
|
| Collection-run timeout | 90 s (`TimeoutStartSec`) | §8.2 |
|
||||||
@@ -479,7 +479,7 @@ Two privileged binaries — `/usr/libexec/fenris/fenris-collect` (device interro
|
|||||||
|
|
||||||
Constants defined once, consumed by TUI and CLI alike; the grade derives from the **newest sample timestamp**, never a stored flag:
|
Constants defined once, consumed by TUI and CLI alike; the grade derives from the **newest sample timestamp**, never a stored flag:
|
||||||
|
|
||||||
- **fresh** — newest sample within 2 × cadence + `AccuracySec` + 60 s (11.5 min at default cadence);
|
- **fresh** — newest sample within 2 × cadence + `AccuracySec` + 60 s (7.5 min at default cadence);
|
||||||
- **missed** — between that and 48 h (a contributing fact);
|
- **missed** — between that and 48 h (a contributing fact);
|
||||||
- **stale** — ≥ 48 h, matching the §6.7 evidence gate;
|
- **stale** — ≥ 48 h, matching the §6.7 evidence gate;
|
||||||
- **empty store** — *"no observations yet"* with an enable hint.
|
- **empty store** — *"no observations yet"* with an enable hint.
|
||||||
|
|||||||
@@ -10,13 +10,13 @@ responsibility shared by the privileged control path and read-only status
|
|||||||
composition.
|
composition.
|
||||||
|
|
||||||
Runit service layout:
|
Runit service layout:
|
||||||
/etc/sv/fenris-collect/run — scheduler (sleep 120; loop { collect; sleep 300 })
|
/etc/sv/fenris-collect/run — scheduler (sleep 120; loop { collect; sleep 180 })
|
||||||
/etc/sv/fenris-collect/log/run — logger to /var/log/fenris-collect/
|
/etc/sv/fenris-collect/log/run — logger to /var/log/fenris-collect/
|
||||||
/var/service/fenris-collect — symlink to enable
|
/var/service/fenris-collect — symlink to enable
|
||||||
/etc/sv/fenris-collect/down — marker for dormant install
|
/etc/sv/fenris-collect/down — marker for dormant install
|
||||||
|
|
||||||
Runit guarantees:
|
Runit guarantees:
|
||||||
- Completion-relative 5-minute cadence (sleep 300 after each collect)
|
- Completion-relative 3-minute cadence (sleep 180 after each collect)
|
||||||
- Initial 2-minute boot delay (sleep 120 before first collect)
|
- Initial 2-minute boot delay (sleep 120 before first collect)
|
||||||
- Bounded execution (90s timeout via timeout(1))
|
- Bounded execution (90s timeout via timeout(1))
|
||||||
- No catch-up (service sleeps fixed interval, no Persistent= flag)
|
- No catch-up (service sleeps fixed interval, no Persistent= flag)
|
||||||
|
|||||||
@@ -35,9 +35,9 @@ from .init_system import (
|
|||||||
# Freshness constants (§8.9, §8.2)
|
# Freshness constants (§8.9, §8.2)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
CADENCE_DEFAULT_S = 300 # 5 min
|
CADENCE_DEFAULT_S = 180 # 3 min
|
||||||
ACCURACY_SEC = 30
|
ACCURACY_SEC = 30
|
||||||
FRESH_THRESHOLD_S = 2 * CADENCE_DEFAULT_S + ACCURACY_SEC + 60 # 690 s
|
FRESH_THRESHOLD_S = 2 * CADENCE_DEFAULT_S + ACCURACY_SEC + 60 # 450 s
|
||||||
STALENESS_THRESHOLD_S = 48 * 3600 # 48 h
|
STALENESS_THRESHOLD_S = 48 * 3600 # 48 h
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+370
-36
@@ -51,6 +51,7 @@ from .status_composition import (
|
|||||||
STATUS_POLL_INTERVAL_S,
|
STATUS_POLL_INTERVAL_S,
|
||||||
)
|
)
|
||||||
from .control import MONITOR_HELPER, MonitorError, run_monitor
|
from .control import MONITOR_HELPER, MonitorError, run_monitor
|
||||||
|
from .derive import _parse_ts
|
||||||
from .preferences import load_preferences, save_preferences
|
from .preferences import load_preferences, save_preferences
|
||||||
from .themes import THEMES, THEME_NAMES, get_theme, get_graph_colors
|
from .themes import THEMES, THEME_NAMES, get_theme, get_graph_colors
|
||||||
|
|
||||||
@@ -62,7 +63,7 @@ from .themes import THEMES, THEME_NAMES, get_theme, get_graph_colors
|
|||||||
_RESUME_HINT = "r Resume — enable monitoring and future boots"
|
_RESUME_HINT = "r Resume — enable monitoring and future boots"
|
||||||
_ACTION_LEGEND = (
|
_ACTION_LEGEND = (
|
||||||
"p Pause · " + _RESUME_HINT + "\n"
|
"p Pause · " + _RESUME_HINT + "\n"
|
||||||
"c Collect now · t Theme · m Motion · d Disclosures · ? Help"
|
"c Collect now · t Today · m Motion · d Disclosures · ? Help"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _format_remaining(seconds: float) -> str:
|
def _format_remaining(seconds: float) -> str:
|
||||||
@@ -396,7 +397,7 @@ class DailyBarGraph(Widget):
|
|||||||
bar_h = max(1, min(_BAR_HEIGHT, render_height - 2))
|
bar_h = max(1, min(_BAR_HEIGHT, render_height - 2))
|
||||||
n = len(visible)
|
n = len(visible)
|
||||||
|
|
||||||
scale, unit = self._unit_scale(max_bytes)
|
scale, unit = _unit_scale(max_bytes)
|
||||||
lines: List[str] = [
|
lines: List[str] = [
|
||||||
"Writes (%s) · %s → %s UTC" % (
|
"Writes (%s) · %s → %s UTC" % (
|
||||||
unit, visible[0].get("day", ""), visible[-1].get("day", ""),
|
unit, visible[0].get("day", ""), visible[-1].get("day", ""),
|
||||||
@@ -532,7 +533,7 @@ class DailyBarGraph(Widget):
|
|||||||
render_height = self.query_one("#bar-render").region.height
|
render_height = self.query_one("#bar-render").region.height
|
||||||
bar_h = max(1, min(_BAR_HEIGHT, render_height - 2))
|
bar_h = max(1, min(_BAR_HEIGHT, render_height - 2))
|
||||||
n = len(visible)
|
n = len(visible)
|
||||||
scale, unit = self._unit_scale(max_bytes)
|
scale, unit = _unit_scale(max_bytes)
|
||||||
|
|
||||||
lines: List[str] = [
|
lines: List[str] = [
|
||||||
"Writes (%s) · %s:00 → %s:00 UTC" % (
|
"Writes (%s) · %s:00 → %s:00 UTC" % (
|
||||||
@@ -899,6 +900,320 @@ def _query_hourly_graph_data(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _unit_scale(max_bytes: int) -> tuple[float, str]:
|
||||||
|
if max_bytes >= 1_000_000_000_000:
|
||||||
|
return 1e12, "TB"
|
||||||
|
if max_bytes >= 1_000_000_000:
|
||||||
|
return 1e9, "GB"
|
||||||
|
return 1e6, "MB"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Live activity graph data (issue #91)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
LIVE_WINDOW_H = 3 # Rolling window for the live graph
|
||||||
|
|
||||||
|
|
||||||
|
def _query_live_graph_data(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
now: datetime,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Query the last three hours of measured interval volumes from raw samples.
|
||||||
|
|
||||||
|
Each point represents the byte delta between two compatible consecutive
|
||||||
|
readings in the same controller segment. Points use actual elapsed
|
||||||
|
timestamps rather than pretending delayed runs are exact samples; gaps
|
||||||
|
and first-reading anchors are visually distinct.
|
||||||
|
|
||||||
|
Returns one dict per interval, ordered oldest-first.
|
||||||
|
"""
|
||||||
|
window_start = (now - timedelta(hours=LIVE_WINDOW_H)).isoformat()
|
||||||
|
cursor = conn.execute(
|
||||||
|
"SELECT ts, bytes_written, bytes_read, segment_id "
|
||||||
|
"FROM samples WHERE ts >= ? ORDER BY ts",
|
||||||
|
(window_start,),
|
||||||
|
)
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
|
||||||
|
if len(rows) < 2:
|
||||||
|
return []
|
||||||
|
|
||||||
|
results: List[Dict[str, Any]] = []
|
||||||
|
for i in range(1, len(rows)):
|
||||||
|
prev_ts = _parse_ts(rows[i - 1][0])
|
||||||
|
curr_ts = _parse_ts(rows[i][0])
|
||||||
|
prev_bw = rows[i - 1][1] or 0
|
||||||
|
prev_br = rows[i - 1][2] or 0
|
||||||
|
curr_bw = rows[i][1] or 0
|
||||||
|
curr_br = rows[i][2] or 0
|
||||||
|
prev_seg = rows[i - 1][3]
|
||||||
|
curr_seg = rows[i][3]
|
||||||
|
|
||||||
|
elapsed_s = int((curr_ts - prev_ts).total_seconds())
|
||||||
|
is_gap = elapsed_s > (CADENCE_DEFAULT_S * 3) # more than 3× expected cadence
|
||||||
|
is_segment_boundary = prev_seg != curr_seg
|
||||||
|
|
||||||
|
bw_delta = max(0, curr_bw - prev_bw) if not is_segment_boundary else 0
|
||||||
|
br_delta = max(0, curr_br - prev_br) if not is_segment_boundary else 0
|
||||||
|
is_zero = bw_delta == 0 and br_delta == 0
|
||||||
|
|
||||||
|
# Format timestamps in local-ish readable form (HH:MM)
|
||||||
|
start_label = prev_ts.strftime("%H:%M")
|
||||||
|
end_label = curr_ts.strftime("%H:%M")
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
"start_ts": rows[i - 1][0],
|
||||||
|
"end_ts": rows[i][0],
|
||||||
|
"start_label": start_label,
|
||||||
|
"end_label": end_label,
|
||||||
|
"bytes_written": bw_delta,
|
||||||
|
"bytes_read": br_delta,
|
||||||
|
"elapsed_s": elapsed_s,
|
||||||
|
"is_gap": is_gap,
|
||||||
|
"is_zero": is_zero,
|
||||||
|
"is_segment_boundary": is_segment_boundary,
|
||||||
|
})
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Live activity graph widget (issue #91)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class LiveActivityGraph(Widget):
|
||||||
|
"""Rolling last-three-hours interval volume graph with read/write toggle.
|
||||||
|
|
||||||
|
Renders interval written (or read) volumes as vertical bars using block
|
||||||
|
glyphs. Supports keyboard and mouse navigation, a read/write toggle
|
||||||
|
(w key), and selected-point inspection showing timestamps, timezone,
|
||||||
|
measured volume, and evidence state. Reads only committed
|
||||||
|
observation-store evidence.
|
||||||
|
"""
|
||||||
|
|
||||||
|
can_focus = True
|
||||||
|
can_focus_children = False
|
||||||
|
|
||||||
|
CSS = """
|
||||||
|
LiveActivityGraph {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
layout: vertical;
|
||||||
|
}
|
||||||
|
#live-header {
|
||||||
|
height: 1;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
#live-render {
|
||||||
|
height: 1fr;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
#live-legend {
|
||||||
|
height: 1;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
#live-readout {
|
||||||
|
height: 2;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, **kwargs: Any) -> None:
|
||||||
|
super().__init__(**kwargs)
|
||||||
|
self._data: List[Dict[str, Any]] = []
|
||||||
|
self._selected_index: int = -1
|
||||||
|
self._measure: str = "written" # "written" or "read"
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
yield Static("", id="live-header")
|
||||||
|
yield Static("", id="live-render")
|
||||||
|
yield Static("", id="live-legend")
|
||||||
|
yield Static("", id="live-readout")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def measure(self) -> str:
|
||||||
|
return self._measure
|
||||||
|
|
||||||
|
def toggle_measure(self) -> None:
|
||||||
|
"""Toggle between written and read volumes."""
|
||||||
|
self._measure = "read" if self._measure == "written" else "written"
|
||||||
|
self._refresh()
|
||||||
|
|
||||||
|
def set_data(self, data: List[Dict[str, Any]]) -> None:
|
||||||
|
"""Update graph with live interval data."""
|
||||||
|
prev_selected_end = None
|
||||||
|
if 0 <= self._selected_index < len(self._data):
|
||||||
|
prev_selected_end = self._data[self._selected_index].get("end_ts")
|
||||||
|
|
||||||
|
self._data = data
|
||||||
|
if prev_selected_end is not None:
|
||||||
|
self._selected_index = next(
|
||||||
|
(i for i, d in enumerate(data) if d.get("end_ts") == prev_selected_end),
|
||||||
|
len(data) - 1 if data else -1,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._selected_index = len(data) - 1 if data else -1
|
||||||
|
self._refresh()
|
||||||
|
|
||||||
|
def _current_bytes(self, item: Dict[str, Any]) -> int:
|
||||||
|
if self._measure == "written":
|
||||||
|
return item.get("bytes_written", 0)
|
||||||
|
return item.get("bytes_read", 0)
|
||||||
|
|
||||||
|
def _refresh(self) -> None:
|
||||||
|
self._render_header()
|
||||||
|
if not self._data:
|
||||||
|
self.query_one("#live-render").update("[dim]Awaiting intervals[/dim]")
|
||||||
|
self.query_one("#live-legend").update("")
|
||||||
|
self.query_one("#live-readout").update("")
|
||||||
|
return
|
||||||
|
self._render_bars()
|
||||||
|
self._render_legend()
|
||||||
|
self._render_readout()
|
||||||
|
|
||||||
|
def _render_header(self) -> None:
|
||||||
|
label = "Writes" if self._measure == "written" else "Reads"
|
||||||
|
toggle_hint = "w Toggle W/R"
|
||||||
|
n = len(self._data)
|
||||||
|
self.query_one("#live-header").update(
|
||||||
|
"Live %s · last %dh · %d intervals · %s"
|
||||||
|
% (label.lower(), LIVE_WINDOW_H, n, toggle_hint)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _render_bars(self) -> None:
|
||||||
|
max_bytes = max(
|
||||||
|
(self._current_bytes(d) for d in self._data), default=0
|
||||||
|
) or 1
|
||||||
|
|
||||||
|
render_height = self.query_one("#live-render").region.height
|
||||||
|
bar_h = max(1, min(_BAR_HEIGHT, render_height - 2))
|
||||||
|
n = len(self._data)
|
||||||
|
scale, unit = _unit_scale(max_bytes)
|
||||||
|
|
||||||
|
measure_label = "Writes" if self._measure == "written" else "Reads"
|
||||||
|
lines: List[str] = [
|
||||||
|
"%s (%s) · %s → %s" % (
|
||||||
|
measure_label, unit,
|
||||||
|
self._data[0].get("start_label", ""),
|
||||||
|
self._data[-1].get("end_label", ""),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
for row in range(bar_h, 0, -1):
|
||||||
|
if row == bar_h:
|
||||||
|
axis = "%7.2f " % (max_bytes / scale)
|
||||||
|
elif row == (bar_h + 1) // 2 and row != 1:
|
||||||
|
axis = "%7.2f " % (max_bytes / scale / 2)
|
||||||
|
elif row == 1:
|
||||||
|
axis = "%7s " % "0"
|
||||||
|
else:
|
||||||
|
axis = " "
|
||||||
|
line = axis
|
||||||
|
threshold = (row / bar_h) * max_bytes
|
||||||
|
for i, item in enumerate(self._data):
|
||||||
|
bw = self._current_bytes(item)
|
||||||
|
if item.get("is_gap") or item.get("is_segment_boundary"):
|
||||||
|
glyph = _GLYPH_GAP if row <= 2 else " "
|
||||||
|
elif bw == 0 and item.get("is_zero"):
|
||||||
|
glyph = _GLYPH_ZERO if row == 1 else " "
|
||||||
|
elif bw == 0:
|
||||||
|
glyph = " "
|
||||||
|
elif threshold <= bw:
|
||||||
|
glyph = _GLYPH_ALLOCATED
|
||||||
|
else:
|
||||||
|
glyph = " "
|
||||||
|
line += glyph * _BAR_WIDTH
|
||||||
|
if i < n - 1:
|
||||||
|
line += " " * _BAR_SPACING
|
||||||
|
lines.append(line)
|
||||||
|
|
||||||
|
# Time labels
|
||||||
|
label_line = "Time "
|
||||||
|
for i, item in enumerate(self._data):
|
||||||
|
label = item.get("start_label", "")[-2:]
|
||||||
|
if i == self._selected_index:
|
||||||
|
label = _GLYPH_SELECTED + label[-1:]
|
||||||
|
label_line += label
|
||||||
|
if i < n - 1:
|
||||||
|
label_line += " " * _BAR_SPACING
|
||||||
|
lines.append(label_line)
|
||||||
|
|
||||||
|
self.query_one("#live-render").update("\n".join(lines))
|
||||||
|
|
||||||
|
def _render_legend(self) -> None:
|
||||||
|
self.query_one("#live-legend").update(
|
||||||
|
"%s Volume %s Gap %s Zero" % (
|
||||||
|
_GLYPH_ALLOCATED, _GLYPH_GAP, _GLYPH_ZERO,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _render_readout(self) -> None:
|
||||||
|
if self._selected_index < 0 or self._selected_index >= len(self._data):
|
||||||
|
self.query_one("#live-readout").update("[dim]← → Select interval[/dim]")
|
||||||
|
return
|
||||||
|
|
||||||
|
item = self._data[self._selected_index]
|
||||||
|
bw = item.get("bytes_written", 0)
|
||||||
|
br = item.get("bytes_read", 0)
|
||||||
|
measure_val = bw if self._measure == "written" else br
|
||||||
|
scale, unit = _unit_scale(measure_val) if measure_val > 0 else (1e6, "MB")
|
||||||
|
|
||||||
|
state_parts = []
|
||||||
|
if item.get("is_gap"):
|
||||||
|
state_parts.append("gap")
|
||||||
|
if item.get("is_segment_boundary"):
|
||||||
|
state_parts.append("segment boundary")
|
||||||
|
if item.get("is_zero"):
|
||||||
|
state_parts.append("measured zero")
|
||||||
|
state = " · " + ", ".join(state_parts) if state_parts else ""
|
||||||
|
|
||||||
|
self.query_one("#live-readout").update(
|
||||||
|
"[bold]%s → %s UTC[/bold] · W %.3f GB · R %.3f GB · %ds%s"
|
||||||
|
% (
|
||||||
|
item.get("start_label", ""),
|
||||||
|
item.get("end_label", ""),
|
||||||
|
bw / 1e9,
|
||||||
|
br / 1e9,
|
||||||
|
item.get("elapsed_s", 0),
|
||||||
|
state,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def on_key(self, event: Any) -> None:
|
||||||
|
if event.key == "left":
|
||||||
|
if self._selected_index < 0:
|
||||||
|
self._selected_index = len(self._data) - 1
|
||||||
|
else:
|
||||||
|
self._selected_index = max(0, self._selected_index - 1)
|
||||||
|
self._refresh()
|
||||||
|
event.stop()
|
||||||
|
elif event.key == "right":
|
||||||
|
if self._selected_index < 0:
|
||||||
|
self._selected_index = 0
|
||||||
|
else:
|
||||||
|
self._selected_index = min(
|
||||||
|
len(self._data) - 1, self._selected_index + 1
|
||||||
|
)
|
||||||
|
self._refresh()
|
||||||
|
event.stop()
|
||||||
|
elif event.key == "w":
|
||||||
|
self.toggle_measure()
|
||||||
|
event.stop()
|
||||||
|
|
||||||
|
def on_click(self, event: Any) -> None:
|
||||||
|
render = self.query_one("#live-render")
|
||||||
|
offset_x = event.x - render.region.x
|
||||||
|
bar_total = _BAR_WIDTH + _BAR_SPACING
|
||||||
|
idx = max(0, offset_x - 8) // bar_total
|
||||||
|
if 0 <= idx < len(self._data):
|
||||||
|
self._selected_index = idx
|
||||||
|
self._refresh()
|
||||||
|
|
||||||
|
def on_resize(self) -> None:
|
||||||
|
self._refresh()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Data queries for TUI regions
|
# Data queries for TUI regions
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -1018,7 +1333,9 @@ class HelpScreen(ModalScreen[None]):
|
|||||||
+ _ACTION_LEGEND + "\n"
|
+ _ACTION_LEGEND + "\n"
|
||||||
"q Quit the dashboard — monitoring continues\n"
|
"q Quit the dashboard — monitoring continues\n"
|
||||||
"Tab Focus the graph · ← → Select · 1-4 Change range\n"
|
"Tab Focus the graph · ← → Select · 1-4 Change range\n"
|
||||||
"Enter Show hours · Esc Return to days\n\n"
|
"Enter Show hours · Esc Return to days\n"
|
||||||
|
"w Toggle W/R on live graph\n"
|
||||||
|
"Theme changed via preferences file\n\n"
|
||||||
"[dim]↑ ↓ Scroll · Esc Close[/dim]",
|
"[dim]↑ ↓ Scroll · Esc Close[/dim]",
|
||||||
id="help-text",
|
id="help-text",
|
||||||
)
|
)
|
||||||
@@ -1040,46 +1357,49 @@ class FenrisTuiApp(App):
|
|||||||
CSS = """
|
CSS = """
|
||||||
#main-grid {
|
#main-grid {
|
||||||
layout: grid;
|
layout: grid;
|
||||||
grid-size: 2 4;
|
grid-size: 2 5;
|
||||||
grid-columns: 3fr 2fr;
|
grid-columns: 3fr 2fr;
|
||||||
grid-rows: auto 10 auto 3;
|
grid-rows: auto 10 8 auto 3;
|
||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
#main-grid.local-day {
|
#main-grid.local-day {
|
||||||
grid-size: 2 5;
|
grid-size: 2 6;
|
||||||
grid-rows: auto 10 auto auto 3;
|
grid-rows: auto 10 8 auto auto 3;
|
||||||
}
|
}
|
||||||
#main-grid.paused {
|
#main-grid.paused {
|
||||||
grid-size: 2 5;
|
grid-size: 2 6;
|
||||||
grid-rows: auto auto 10 auto 3;
|
grid-rows: auto auto 10 8 auto 3;
|
||||||
}
|
}
|
||||||
#main-grid.paused.local-day {
|
#main-grid.paused.local-day {
|
||||||
grid-size: 2 6;
|
grid-size: 2 7;
|
||||||
grid-rows: auto auto 10 auto auto 3;
|
grid-rows: auto auto 10 8 auto auto 3;
|
||||||
}
|
}
|
||||||
#main-grid.constrained {
|
#main-grid.constrained {
|
||||||
grid-size: 1 4;
|
grid-size: 1 5;
|
||||||
grid-columns: 1fr;
|
grid-columns: 1fr;
|
||||||
grid-rows: auto auto auto auto;
|
grid-rows: auto auto auto auto auto;
|
||||||
}
|
}
|
||||||
#main-grid.constrained.local-day {
|
#main-grid.constrained.local-day {
|
||||||
grid-size: 1 5;
|
|
||||||
grid-rows: auto auto auto auto auto;
|
|
||||||
}
|
|
||||||
#main-grid.constrained.paused {
|
|
||||||
grid-size: 1 5;
|
|
||||||
grid-rows: auto auto auto auto auto;
|
|
||||||
}
|
|
||||||
#main-grid.constrained.paused.local-day {
|
|
||||||
grid-size: 1 6;
|
grid-size: 1 6;
|
||||||
grid-rows: auto auto auto auto auto auto;
|
grid-rows: auto auto auto auto auto auto;
|
||||||
}
|
}
|
||||||
|
#main-grid.constrained.paused {
|
||||||
|
grid-size: 1 6;
|
||||||
|
grid-rows: auto auto auto auto auto auto;
|
||||||
|
}
|
||||||
|
#main-grid.constrained.paused.local-day {
|
||||||
|
grid-size: 1 7;
|
||||||
|
grid-rows: auto auto auto auto auto auto auto;
|
||||||
|
}
|
||||||
#main-grid.constrained #usage-history {
|
#main-grid.constrained #usage-history {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
#main-grid.constrained #constrained-summary {
|
#main-grid.constrained #constrained-summary {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
#main-grid.constrained #live-activity {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
#constrained-summary {
|
#constrained-summary {
|
||||||
display: none;
|
display: none;
|
||||||
border: round #555555;
|
border: round #555555;
|
||||||
@@ -1087,6 +1407,13 @@ class FenrisTuiApp(App):
|
|||||||
height: auto;
|
height: auto;
|
||||||
min-height: 3;
|
min-height: 3;
|
||||||
}
|
}
|
||||||
|
#live-activity {
|
||||||
|
column-span: 2;
|
||||||
|
border: round #555555;
|
||||||
|
padding: 0 1;
|
||||||
|
height: auto;
|
||||||
|
min-height: 5;
|
||||||
|
}
|
||||||
#local-day {
|
#local-day {
|
||||||
column-span: 2;
|
column-span: 2;
|
||||||
display: none;
|
display: none;
|
||||||
@@ -1123,8 +1450,9 @@ class FenrisTuiApp(App):
|
|||||||
Binding("r", "resume", "Resume", show=False),
|
Binding("r", "resume", "Resume", show=False),
|
||||||
Binding("c", "collect", "Collect now", show=False),
|
Binding("c", "collect", "Collect now", show=False),
|
||||||
Binding("d", "disclose", "Disclosures", show=False),
|
Binding("d", "disclose", "Disclosures", show=False),
|
||||||
Binding("t", "toggle_theme", "Theme", show=False),
|
Binding("t", "today", "Today", show=False),
|
||||||
Binding("m", "toggle_motion", "Motion", show=False),
|
Binding("m", "toggle_motion", "Motion", show=False),
|
||||||
|
Binding("w", "toggle_measure", "W/R", show=False),
|
||||||
Binding("?", "help", "Help", show=False),
|
Binding("?", "help", "Help", show=False),
|
||||||
Binding("q", "quit", "Quit", show=False),
|
Binding("q", "quit", "Quit", show=False),
|
||||||
]
|
]
|
||||||
@@ -1164,6 +1492,7 @@ class FenrisTuiApp(App):
|
|||||||
yield Static("", id="paused-banner")
|
yield Static("", id="paused-banner")
|
||||||
yield DailyBarGraph(id="usage-history", classes="pane")
|
yield DailyBarGraph(id="usage-history", classes="pane")
|
||||||
yield Static("", id="constrained-summary", classes="pane")
|
yield Static("", id="constrained-summary", classes="pane")
|
||||||
|
yield LiveActivityGraph(id="live-activity")
|
||||||
yield Static("", id="drive-health", classes="pane")
|
yield Static("", id="drive-health", classes="pane")
|
||||||
yield Static("", id="local-day", classes="pane")
|
yield Static("", id="local-day", classes="pane")
|
||||||
yield Static("", id="service-strip", classes="pane")
|
yield Static("", id="service-strip", classes="pane")
|
||||||
@@ -1179,6 +1508,7 @@ class FenrisTuiApp(App):
|
|||||||
self.query_one("#usage-history").border_title = "Usage history"
|
self.query_one("#usage-history").border_title = "Usage history"
|
||||||
self.query_one("#constrained-summary").border_title = "Usage history"
|
self.query_one("#constrained-summary").border_title = "Usage history"
|
||||||
self.query_one("#drive-health").border_title = "Drive"
|
self.query_one("#drive-health").border_title = "Drive"
|
||||||
|
self.query_one("#live-activity").border_title = "Live activity"
|
||||||
self.query_one("#local-day").border_title = "Local day"
|
self.query_one("#local-day").border_title = "Local day"
|
||||||
self.query_one("#service-strip").border_title = "Monitoring and actions"
|
self.query_one("#service-strip").border_title = "Monitoring and actions"
|
||||||
self._refresh_timer = self.set_interval(
|
self._refresh_timer = self.set_interval(
|
||||||
@@ -1386,6 +1716,11 @@ class FenrisTuiApp(App):
|
|||||||
)
|
)
|
||||||
self.query_one("#drive-health").update(health_text)
|
self.query_one("#drive-health").update(health_text)
|
||||||
|
|
||||||
|
# --- Live activity graph (issue #91) ---
|
||||||
|
live_data = _query_live_graph_data(conn, self._clock_now)
|
||||||
|
live_graph = self.query_one("#live-activity")
|
||||||
|
live_graph.set_data(live_data)
|
||||||
|
|
||||||
# --- Local-day totals (issue #90) ---
|
# --- Local-day totals (issue #90) ---
|
||||||
self._render_local_day(conn)
|
self._render_local_day(conn)
|
||||||
|
|
||||||
@@ -1527,19 +1862,18 @@ class FenrisTuiApp(App):
|
|||||||
|
|
||||||
# --- Actions ---
|
# --- Actions ---
|
||||||
|
|
||||||
def action_toggle_theme(self) -> None:
|
def action_toggle_measure(self) -> None:
|
||||||
"""Cycle through Amber → Nord → High Contrast presets (issue #80)."""
|
"""Toggle between written and read volumes on the live graph."""
|
||||||
names = sorted(THEME_NAMES)
|
live = self.query_one("#live-activity")
|
||||||
try:
|
live.toggle_measure()
|
||||||
idx = names.index(self._current_theme_name)
|
self._refresh()
|
||||||
except ValueError:
|
|
||||||
idx = 0
|
def action_today(self) -> None:
|
||||||
self._current_theme_name = names[(idx + 1) % len(names)]
|
"""Return to today's live view from any historical browsing."""
|
||||||
self.theme = "fenris-%s" % self._current_theme_name.replace("_", "-")
|
# Reset the daily graph to today's data
|
||||||
save_preferences(
|
graph = self.query_one("#usage-history")
|
||||||
theme=self._current_theme_name,
|
if graph.view_mode == "hourly":
|
||||||
reduced_motion=self._reduced_motion,
|
graph._exit_drill()
|
||||||
)
|
|
||||||
self._refresh()
|
self._refresh()
|
||||||
|
|
||||||
def action_toggle_motion(self) -> None:
|
def action_toggle_motion(self) -> None:
|
||||||
|
|||||||
+18
-11
@@ -171,8 +171,8 @@ class TestKeyBindings:
|
|||||||
assert "m" in binding_keys
|
assert "m" in binding_keys
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_t_cycles_through_themes(self, tmp_path):
|
async def test_t_goes_to_today(self, tmp_path):
|
||||||
"""Pressing t cycles through the three presets."""
|
"""Pressing t returns to today's live view (issue #91)."""
|
||||||
from fenris.tui import FenrisTuiApp
|
from fenris.tui import FenrisTuiApp
|
||||||
|
|
||||||
config_home = _make_prefs_dir(tmp_path)
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
@@ -182,12 +182,17 @@ class TestKeyBindings:
|
|||||||
# Start at amber
|
# Start at amber
|
||||||
assert app.theme == "fenris-amber"
|
assert app.theme == "fenris-amber"
|
||||||
|
|
||||||
# Press t to cycle
|
# Enter drill mode on the graph
|
||||||
|
graph = app.query_one("#usage-history")
|
||||||
|
graph.view_mode = "hourly"
|
||||||
|
graph.drill_day = "2026-09-30"
|
||||||
|
|
||||||
|
# Press t to go back to today
|
||||||
await pilot.press("t")
|
await pilot.press("t")
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
|
|
||||||
# Should be nord or high_contrast now
|
# Should be back in daily mode
|
||||||
assert app.theme in ("fenris-nord", "fenris-high-contrast")
|
assert graph.view_mode == "daily"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_m_toggles_reduced_motion(self, tmp_path):
|
async def test_m_toggles_reduced_motion(self, tmp_path):
|
||||||
@@ -226,21 +231,23 @@ class TestPersistence:
|
|||||||
async def test_theme_survives_restart(self, tmp_path):
|
async def test_theme_survives_restart(self, tmp_path):
|
||||||
"""Theme preference persists across TUI restart."""
|
"""Theme preference persists across TUI restart."""
|
||||||
from fenris.tui import FenrisTuiApp
|
from fenris.tui import FenrisTuiApp
|
||||||
|
from fenris.preferences import save_preferences
|
||||||
|
|
||||||
config_home = _make_prefs_dir(tmp_path)
|
config_home = _make_prefs_dir(tmp_path)
|
||||||
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config_home)}):
|
||||||
# First run: change theme
|
# First run: change theme via preferences API
|
||||||
app1 = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
app1 = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||||
async with app1.run_test() as pilot:
|
async with app1.run_test() as pilot:
|
||||||
await pilot.press("t")
|
save_preferences(theme="nord", reduced_motion=False)
|
||||||
await pilot.pause()
|
app1._current_theme_name = "nord"
|
||||||
theme_after_t = app1.theme
|
app1.theme = "fenris-nord"
|
||||||
assert theme_after_t != "fenris-amber"
|
theme_after = app1.theme
|
||||||
|
assert theme_after == "fenris-nord"
|
||||||
|
|
||||||
# Second run: theme should persist
|
# Second run: theme should persist
|
||||||
app2 = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
app2 = FenrisTuiApp(store_path=tmp_path / "nonexistent.db")
|
||||||
async with app2.run_test() as pilot:
|
async with app2.run_test() as pilot:
|
||||||
assert app2.theme == theme_after_t
|
assert app2.theme == theme_after
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_reduced_motion_survives_restart(self, tmp_path):
|
async def test_reduced_motion_survives_restart(self, tmp_path):
|
||||||
|
|||||||
@@ -0,0 +1,324 @@
|
|||||||
|
"""Tests for live activity graph (issue #91).
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- Live interval volume query from raw samples
|
||||||
|
- LiveActivityGraph widget rendering, toggle, and inspection
|
||||||
|
- Three-minute cadence constants
|
||||||
|
- TUI integration with live graph
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||||
|
|
||||||
|
from fenris.store import init_store
|
||||||
|
from fenris.monitoring_periods import ensure_period_open
|
||||||
|
from fenris.tui import (
|
||||||
|
FenrisTuiApp,
|
||||||
|
LiveActivityGraph,
|
||||||
|
_query_live_graph_data,
|
||||||
|
LIVE_WINDOW_H,
|
||||||
|
_query_daily_graph_data,
|
||||||
|
_RANGE_OPTIONS,
|
||||||
|
)
|
||||||
|
from fenris.status import CADENCE_DEFAULT_S, FRESH_THRESHOLD_S, ACCURACY_SEC
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _clock(year=2026, month=9, day=30, hour=12):
|
||||||
|
return datetime(year, month, day, hour, 0, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_segment(conn, opened_at="2026-09-01T00:00:00+00:00",
|
||||||
|
identity_key="nqn.test", degraded=False,
|
||||||
|
mn="Samsung SSD 970 EVO Plus 1TB"):
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO controller_segments "
|
||||||
|
"(opened_at, identity_key, identity_degraded, subnqn, sn, mn, fr, vid, ssvid, transport) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(opened_at, identity_key, degraded, "nqn.test", "SN123", mn, "FW1",
|
||||||
|
"0x144d", "0x144d", "pcie"),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_sample(conn, ts, bw=512000000000, br=256000000000,
|
||||||
|
segment_id=None, pu=5, device="/dev/nvme0n1"):
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO samples (ts, device, data_units_written, data_units_read, "
|
||||||
|
"percentage_used, bytes_written, bytes_read, power_on_hours, segment_id) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(ts, device, 1000000, 500000, pu, bw, br, 8765, segment_id),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _open_period(conn, start="2026-09-01T00:00:00+00:00"):
|
||||||
|
ensure_period_open(conn, datetime.fromisoformat(start))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Cadence constants (issue #91 AC1)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestCadenceConstants:
|
||||||
|
def test_cadence_default_is_3_minutes(self):
|
||||||
|
"""Default collection cadence is 3 minutes (180 s)."""
|
||||||
|
assert CADENCE_DEFAULT_S == 180
|
||||||
|
|
||||||
|
def test_fresh_threshold_uses_3min_cadence(self):
|
||||||
|
"""Fresh threshold = 2 × 3min + AccuracySec + 60s = 450s."""
|
||||||
|
expected = 2 * 180 + ACCURACY_SEC + 60
|
||||||
|
assert FRESH_THRESHOLD_S == expected
|
||||||
|
|
||||||
|
def test_live_window_is_3_hours(self):
|
||||||
|
"""Live graph window is 3 hours."""
|
||||||
|
assert LIVE_WINDOW_H == 3
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Live graph query (issue #91 AC2-5)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestQueryLiveData:
|
||||||
|
def test_empty_store_returns_empty(self, tmp_path):
|
||||||
|
"""No samples → empty list."""
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
now = _clock()
|
||||||
|
result = _query_live_graph_data(conn, now)
|
||||||
|
assert result == []
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_single_sample_returns_empty(self, tmp_path):
|
||||||
|
"""One sample (no pair) → empty list."""
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
now = _clock()
|
||||||
|
_insert_sample(conn, now.isoformat())
|
||||||
|
result = _query_live_graph_data(conn, now)
|
||||||
|
assert result == []
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_two_samples_in_window(self, tmp_path):
|
||||||
|
"""Two samples within 3h window produce one interval."""
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
now = _clock()
|
||||||
|
t1 = (now - timedelta(minutes=3)).isoformat()
|
||||||
|
t2 = (now - timedelta(minutes=0)).isoformat()
|
||||||
|
_insert_sample(conn, t1, bw=1000, br=500)
|
||||||
|
_insert_sample(conn, t2, bw=1500, br=700)
|
||||||
|
result = _query_live_graph_data(conn, now)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["bytes_written"] == 500
|
||||||
|
assert result[0]["bytes_read"] == 200
|
||||||
|
assert result[0]["elapsed_s"] == 180
|
||||||
|
assert result[0]["is_gap"] is False
|
||||||
|
assert result[0]["is_zero"] is False
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_samples_outside_window_ignored(self, tmp_path):
|
||||||
|
"""Samples older than 3h are excluded."""
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
now = _clock()
|
||||||
|
old = (now - timedelta(hours=4)).isoformat()
|
||||||
|
recent = (now - timedelta(minutes=1)).isoformat()
|
||||||
|
_insert_sample(conn, old, bw=1000, br=500)
|
||||||
|
_insert_sample(conn, recent, bw=1500, br=700)
|
||||||
|
result = _query_live_graph_data(conn, now)
|
||||||
|
assert len(result) == 0
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_gap_detection(self, tmp_path):
|
||||||
|
"""Interval > 3× cadence is flagged as a gap."""
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
now = _clock()
|
||||||
|
t1 = (now - timedelta(minutes=13)).isoformat()
|
||||||
|
t2 = (now - timedelta(minutes=3)).isoformat()
|
||||||
|
_insert_sample(conn, t1, bw=1000, br=500)
|
||||||
|
_insert_sample(conn, t2, bw=1500, br=700)
|
||||||
|
result = _query_live_graph_data(conn, now)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["is_gap"] is True
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_zero_interval(self, tmp_path):
|
||||||
|
"""No byte delta → is_zero flag."""
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
now = _clock()
|
||||||
|
t1 = (now - timedelta(minutes=3)).isoformat()
|
||||||
|
t2 = (now - timedelta(minutes=0)).isoformat()
|
||||||
|
_insert_sample(conn, t1, bw=1000, br=500)
|
||||||
|
_insert_sample(conn, t2, bw=1000, br=500)
|
||||||
|
result = _query_live_graph_data(conn, now)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["bytes_written"] == 0
|
||||||
|
assert result[0]["bytes_read"] == 0
|
||||||
|
assert result[0]["is_zero"] is True
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_segment_boundary_resets_counters(self, tmp_path):
|
||||||
|
"""Counter discontinuity across segments → zero delta."""
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
_insert_segment(conn, identity_key="key_a")
|
||||||
|
_insert_segment(conn, opened_at="2026-09-30T11:00:00+00:00",
|
||||||
|
identity_key="key_b")
|
||||||
|
now = _clock()
|
||||||
|
t1 = (now - timedelta(minutes=6)).isoformat()
|
||||||
|
t2 = (now - timedelta(minutes=3)).isoformat()
|
||||||
|
_insert_sample(conn, t1, bw=1000, br=500, segment_id=1)
|
||||||
|
_insert_sample(conn, t2, bw=500, br=200, segment_id=2)
|
||||||
|
result = _query_live_graph_data(conn, now)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["bytes_written"] == 0
|
||||||
|
assert result[0]["bytes_read"] == 0
|
||||||
|
assert result[0]["is_segment_boundary"] is True
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_actual_timestamps_used(self, tmp_path):
|
||||||
|
"""Points use actual sample timestamps, not 3-min spacing."""
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
now = _clock()
|
||||||
|
t1 = (now - timedelta(minutes=5)).isoformat()
|
||||||
|
t2 = (now - timedelta(minutes=1)).isoformat()
|
||||||
|
_insert_sample(conn, t1, bw=1000, br=500)
|
||||||
|
_insert_sample(conn, t2, bw=2000, br=800)
|
||||||
|
result = _query_live_graph_data(conn, now)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["start_ts"] == t1
|
||||||
|
assert result[0]["end_ts"] == t2
|
||||||
|
assert result[0]["elapsed_s"] == 240
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_multiple_intervals_ordered_oldest_first(self, tmp_path):
|
||||||
|
"""Multiple intervals are returned in chronological order."""
|
||||||
|
conn = init_store(tmp_path / "test.db")
|
||||||
|
now = _clock()
|
||||||
|
for i in range(5):
|
||||||
|
ts = (now - timedelta(minutes=15 - i * 3)).isoformat()
|
||||||
|
_insert_sample(conn, ts, bw=1000 * (i + 1), br=500 * (i + 1))
|
||||||
|
result = _query_live_graph_data(conn, now)
|
||||||
|
assert len(result) == 4
|
||||||
|
for i in range(len(result) - 1):
|
||||||
|
assert result[i]["end_ts"] <= result[i + 1]["start_ts"]
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# LiveActivityGraph widget (issue #91 AC3)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestLiveActivityGraph:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_toggle_measure(self, tmp_path):
|
||||||
|
"""Toggle switches between written and read."""
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
graph = app.query_one("#live-activity")
|
||||||
|
assert graph.measure == "written"
|
||||||
|
graph.toggle_measure()
|
||||||
|
assert graph.measure == "read"
|
||||||
|
graph.toggle_measure()
|
||||||
|
assert graph.measure == "written"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_empty_data_renders_awaiting(self, tmp_path):
|
||||||
|
"""Empty data shows awaiting message."""
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
graph = app.query_one("#live-activity")
|
||||||
|
graph.set_data([])
|
||||||
|
await pilot.pause()
|
||||||
|
render = str(graph.query_one("#live-render").render())
|
||||||
|
assert "Awaiting intervals" in render
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_data_renders_header(self, tmp_path):
|
||||||
|
"""Data updates the header with interval count."""
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
graph = app.query_one("#live-activity")
|
||||||
|
data = [
|
||||||
|
{
|
||||||
|
"start_ts": "2026-09-30T11:57:00+00:00",
|
||||||
|
"end_ts": "2026-09-30T12:00:00+00:00",
|
||||||
|
"start_label": "11:57",
|
||||||
|
"end_label": "12:00",
|
||||||
|
"bytes_written": 500000000,
|
||||||
|
"bytes_read": 200000000,
|
||||||
|
"elapsed_s": 180,
|
||||||
|
"is_gap": False,
|
||||||
|
"is_zero": False,
|
||||||
|
"is_segment_boundary": False,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
graph.set_data(data)
|
||||||
|
await pilot.pause()
|
||||||
|
header = str(graph.query_one("#live-header").render())
|
||||||
|
assert "1 intervals" in header
|
||||||
|
assert "w Toggle W/R" in header
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TUI integration (issue #91 AC2, AC6-9)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestTUILiveIntegration:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_live_graph_exists_in_compose(self, tmp_path):
|
||||||
|
"""Live activity graph is composed in the TUI."""
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
assert app.query_one("#live-activity") is not None
|
||||||
|
assert isinstance(app.query_one("#live-activity"), LiveActivityGraph)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_live_graph_has_border_title(self, tmp_path):
|
||||||
|
"""Live activity graph has a border title."""
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
live = app.query_one("#live-activity")
|
||||||
|
assert live.border_title == "Live activity"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_today_action_resets_drill(self, tmp_path):
|
||||||
|
"""Action today returns from hourly drill to daily view."""
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
graph = app.query_one("#usage-history")
|
||||||
|
graph.view_mode = "hourly"
|
||||||
|
graph.drill_day = "2026-09-30"
|
||||||
|
app.action_today()
|
||||||
|
await pilot.pause()
|
||||||
|
assert graph.view_mode == "daily"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_toggle_measure_binding(self, tmp_path):
|
||||||
|
"""The w key toggles the live graph measure."""
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
graph = app.query_one("#live-activity")
|
||||||
|
assert graph.measure == "written"
|
||||||
|
await pilot.press("w")
|
||||||
|
await pilot.pause()
|
||||||
|
assert graph.measure == "read"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_constrained_mode_hides_live_graph(self, tmp_path):
|
||||||
|
"""Constrained terminal hides the live graph."""
|
||||||
|
app = FenrisTuiApp(store_path=tmp_path / "test.db")
|
||||||
|
async with app.run_test(size=(70, 20)) as pilot:
|
||||||
|
await pilot.pause()
|
||||||
|
main_grid = app.query_one("#main-grid")
|
||||||
|
assert main_grid.has_class("constrained")
|
||||||
@@ -870,7 +870,7 @@ class TestRefreshTiming:
|
|||||||
_insert_segment(conn)
|
_insert_segment(conn)
|
||||||
_open_period(conn)
|
_open_period(conn)
|
||||||
now = _clock()
|
now = _clock()
|
||||||
ts = (now - timedelta(minutes=10)).isoformat()
|
ts = (now - timedelta(minutes=4)).isoformat()
|
||||||
_insert_sample(conn, ts)
|
_insert_sample(conn, ts)
|
||||||
for i in range(20):
|
for i in range(20):
|
||||||
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
|
d = (datetime(2026, 9, 10) + timedelta(days=i)).strftime("%Y-%m-%d")
|
||||||
@@ -878,27 +878,27 @@ class TestRefreshTiming:
|
|||||||
|
|
||||||
svc = {
|
svc = {
|
||||||
"boot_enabled": True, "timer_active": True,
|
"boot_enabled": True, "timer_active": True,
|
||||||
"last_collect_ok": True, "last_collect_age_s": 600,
|
"last_collect_ok": True, "last_collect_age_s": 240,
|
||||||
"last_collect_reason": None,
|
"last_collect_reason": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Sample is 10 min old (600s) → fresh (within 690s threshold)
|
# Sample is 4 min old (240s) → fresh (within 450s threshold)
|
||||||
comp1 = compose_status(conn, svc, now, store_fault=None, newer_schema=None)
|
comp1 = compose_status(conn, svc, now, store_fault=None, newer_schema=None)
|
||||||
assert comp1.freshness == "fresh"
|
assert comp1.freshness == "fresh"
|
||||||
assert comp1.state == StatusState.MONITORING
|
assert comp1.state == StatusState.MONITORING
|
||||||
|
|
||||||
# At now + 1 min → sample is 11 min old (660s) → still fresh
|
# At now + 3 min → sample is 7 min old (420s) → still fresh
|
||||||
now2 = now + timedelta(minutes=1)
|
now2 = now + timedelta(minutes=3)
|
||||||
comp2 = compose_status(conn, svc, now2, store_fault=None, newer_schema=None)
|
comp2 = compose_status(conn, svc, now2, store_fault=None, newer_schema=None)
|
||||||
assert comp2.freshness == "fresh"
|
assert comp2.freshness == "fresh"
|
||||||
|
|
||||||
# At now + 2 min → sample is 12 min old (720s) → missed (past 690s)
|
# At now + 5 min → sample is 9 min old (540s) → missed (past 450s)
|
||||||
now3 = now + timedelta(minutes=2)
|
now3 = now + timedelta(minutes=5)
|
||||||
comp3 = compose_status(conn, svc, now3, store_fault=None, newer_schema=None)
|
comp3 = compose_status(conn, svc, now3, store_fault=None, newer_schema=None)
|
||||||
assert comp3.freshness == "missed"
|
assert comp3.freshness == "missed"
|
||||||
assert comp3.state == StatusState.MONITORING
|
assert comp3.state == StatusState.MONITORING
|
||||||
|
|
||||||
# At now + 49 hours → sample is 49h 10min old → stale
|
# At now + 49 hours → sample is 49h 4min old → stale
|
||||||
now4 = now + timedelta(hours=49)
|
now4 = now + timedelta(hours=49)
|
||||||
comp4 = compose_status(conn, svc, now4, store_fault=None, newer_schema=None)
|
comp4 = compose_status(conn, svc, now4, store_fault=None, newer_schema=None)
|
||||||
assert comp4.freshness == "stale"
|
assert comp4.freshness == "stale"
|
||||||
|
|||||||
+1
-1
@@ -376,7 +376,7 @@ class TestDenseScreen:
|
|||||||
main_grid = app.query_one("#main-grid")
|
main_grid = app.query_one("#main-grid")
|
||||||
assert str(main_grid.styles.layout) == "<grid>"
|
assert str(main_grid.styles.layout) == "<grid>"
|
||||||
assert main_grid.has_class("paused")
|
assert main_grid.has_class("paused")
|
||||||
assert len(main_grid.styles.grid_rows) == 5
|
assert len(main_grid.styles.grid_rows) == 6
|
||||||
banner = str(paused_banner.render()).lower()
|
banner = str(paused_banner.render()).lower()
|
||||||
assert "monitoring: paused — deliberate disable" in banner
|
assert "monitoring: paused — deliberate disable" in banner
|
||||||
assert "paused time is excluded from your usage habit · resume: fenris monitor resume" in banner
|
assert "paused time is excluded from your usage habit · resume: fenris monitor resume" in banner
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Documentation=https://git.bongbetic.com/xavierk/Fenris
|
|||||||
|
|
||||||
[Timer]
|
[Timer]
|
||||||
OnBootSec=2min
|
OnBootSec=2min
|
||||||
OnUnitInactiveSec=5min
|
OnUnitInactiveSec=3min
|
||||||
AccuracySec=30s
|
AccuracySec=30s
|
||||||
Persistent=no
|
Persistent=no
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
#
|
#
|
||||||
# Guarantees (must match systemd timer semantics):
|
# Guarantees (must match systemd timer semantics):
|
||||||
# - Initial 2-minute boot delay (sleep 120 before first collect)
|
# - Initial 2-minute boot delay (sleep 120 before first collect)
|
||||||
# - Completion-relative 5-minute cadence (sleep 300 after collect)
|
# - Completion-relative 3-minute cadence (sleep 180 after collect)
|
||||||
# - Bounded execution (timeout 90s on each collect)
|
# - Bounded execution (timeout 90s on each collect)
|
||||||
# - No catch-up (fixed sleep interval, no Persistent=)
|
# - No catch-up (fixed sleep interval, no Persistent=)
|
||||||
# - Serialized runs (runsv does not restart until exit)
|
# - Serialized runs (runsv does not restart until exit)
|
||||||
@@ -20,7 +20,7 @@ set -eu
|
|||||||
|
|
||||||
COLLECT_TIMEOUT=90
|
COLLECT_TIMEOUT=90
|
||||||
BOOT_DELAY=120
|
BOOT_DELAY=120
|
||||||
CADENCE=300
|
CADENCE=180
|
||||||
LOCK_FILE=/var/lib/fenris/fenris-collect.lock
|
LOCK_FILE=/var/lib/fenris/fenris-collect.lock
|
||||||
|
|
||||||
# Ensure lock directory exists
|
# Ensure lock directory exists
|
||||||
|
|||||||
Reference in New Issue
Block a user