Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ jobs:
pip install .[test]

- name: Run tests with coverage
run: pytest --cov=pulse --cov-report=term-missing --cov-fail-under=35
run: pytest --cov=pulse --cov-report=term-missing --cov-fail-under=65

- name: Enforce coverage on safety-critical modules
# These decide whether a destructive action happens at all, so they are
Expand All @@ -43,6 +43,14 @@ jobs:
--cov=pulse.actions --cov=pulse.config
--cov-report=term-missing --cov-fail-under=85

- name: Enforce coverage on the core metrics layer
# The core runs entirely against a mock source, so there is no excuse
# for gaps here.
run: >-
pytest tests/test_core_models.py tests/test_core_sampler.py
tests/test_core_store.py tests/test_core_sources.py
--cov=pulse.core --cov-report=term-missing --cov-fail-under=90

smoke:
name: Installed package launches
runs-on: ${{ matrix.os }}
Expand Down
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,39 @@ All notable changes to this project are documented here.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Changed

- **Metrics moved out of the widgets.** New `pulse.core` package: immutable
models, pluggable sources, a fault-isolating sampler, and a store that owns
history and derived rates. Nothing in it imports Textual or Rich.
- **One tick now produces one `Snapshot` that every panel shares.** Previously
the CPU panel, Insight, the main view, and the process table each called
`cpu_percent()` separately in the same refresh, so four different CPU numbers
could be on screen at once.
- **Sampling runs on a worker thread.** Reading `/proc`, walking the socket
table, and stat-ing mount points no longer block the render path. An
overrunning tick is skipped rather than queued behind itself.
- **Rates are derived from real elapsed time** between two snapshots instead of
each panel tracking its own counters and assuming one second had passed.
- **Tables update in place.** Rows are diffed rather than cleared and rebuilt
every tick, so a selected or scrolled row stays where the user put it.
- A subsystem that fails to sample now degrades its own panel and is reported
in the Insight view, instead of being silently swallowed.
- The Aether visualisation takes metrics from the shared snapshot rather than
sampling psutil at 20 FPS on its own.

### Added

- `MockSource`, a deterministic fake machine. The entire UI can be driven from
it with no real system access, which is what makes the new integration tests
possible.
- Architectural tests that parse imports and fail if a panel reaches for psutil
or subprocess, or if `pulse.core` imports a UI library.
- Test count 94 -> 199; total coverage 39% -> 73%; CI floor raised to 65% with
a dedicated 90% gate on `pulse.core`.

## [0.4.0] - 2026-08-17

A correctness and safety release. No new features; several things that looked
Expand Down
29 changes: 23 additions & 6 deletions docs/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ Hatchling.
```
src/pulse/
├── app.py # PulseApp, the grid layout, global bindings, main()
├── core.py # re-exports: metrics from direct_os, actions from actions
├── core/ # metrics layer - NO Textual or Rich imports
│ ├── models.py # frozen dataclasses; one tick is one Snapshot
│ ├── sources/ # system.py (real), mock.py (deterministic)
│ ├── sampler.py # one source poll -> one Snapshot, failures isolated
│ └── store.py # latest Snapshot + history, rates, peaks
├── direct_os.py # per-platform metrics; /proc on Linux, psutil elsewhere
├── actions.py # the ONLY module that changes system state
├── config.py # load/save + validation of config.toml
Expand Down Expand Up @@ -48,13 +52,26 @@ here means the UI shows stale or wrong numbers with no way to find out why.
**Config values are untrusted input.** Anything read from `config.toml` goes
through `config.validate_*` before use.

**Panels never sample.** Read from `self.snapshot`, `self.history`,
`self.rates`, or `self.store` on `Panel`. One tick produces one `Snapshot` that
every widget shares, which is why the numbers agree across the UI. Sampling
runs on a worker thread; a panel that calls psutil puts a blocking syscall back
on the render path. `tests/test_ui_isolation.py` enforces this — it parses the
imports of everything under `panels/`, `screens/`, and `aether/` and fails if
psutil or subprocess appears.

**Guard against `None`.** `self.snapshot` is `None` until the first sample
lands. Return `self.waiting_text()` rather than assuming data exists.

**The dependency arrow points one way.** `pulse.core` must not import Textual
or Rich; that is also enforced by a test.

## Known debt

The panels call psutil directly from their render path — 80+ call sites — so
one refresh samples the same metric several times and everything runs on the
event loop. There are no workers. This is the main thing v2 fixes; see
[pulse-v2-plan.md](pulse-v2-plan.md) for the target architecture. Prefer not to
add new direct psutil calls in `panels/`.
The full-screen views still probe panels with `hasattr` for eight optional
methods instead of declaring an interface, and the keymap has collisions —
`f` means three different things depending on focus, `r` means four. That is
M3 in [pulse-v2-plan.md](pulse-v2-plan.md).

## Development

Expand Down
72 changes: 28 additions & 44 deletions src/pulse/aether/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@
"""
import time
import math
import psutil
from typing import Optional
from typing import Iterable, Optional
from collections import deque

from pulse.aether.shapes import (
Expand Down Expand Up @@ -79,49 +78,34 @@ def resize(self, width: int, height: int):
self.terrain = TerrainRenderer(width, height)
self.flux = FluxRenderer(width, height)

def _get_metrics(self) -> dict:
"""Fetch current system metrics."""
try:
cpu = psutil.cpu_percent()
mem = psutil.virtual_memory().percent

# Cache for HUD
self._current_cpu = cpu
self._current_mem = mem

# Store history
def set_metrics(self, cpu: float, memory: float, io_intensity: float,
cpu_history: Optional[Iterable[float]] = None) -> None:
"""Feed the engine the current tick.

The engine is pure animation - it does not sample anything. Metrics are
pushed in from the shared snapshot so the visualisation stays in step
with the rest of the UI and adds no syscalls of its own.
"""
self._current_cpu = cpu
self._current_mem = memory
self.io_intensity = max(0.0, min(1.0, io_intensity))
self._current_io = self.io_intensity

if cpu_history is not None:
# Terrain is driven by the store's history rather than a private copy.
self.cpu_history = deque(cpu_history, maxlen=60)
else:
self.cpu_history.append(cpu)
self.mem_history.append(mem)

# Calculate I/O intensity for Flux
try:
net = psutil.net_io_counters()
disk = psutil.disk_io_counters()

if self._last_net_io and self._last_disk_io:
net_delta = (net.bytes_sent - self._last_net_io.bytes_sent +
net.bytes_recv - self._last_net_io.bytes_recv)
disk_delta = (disk.read_bytes - self._last_disk_io.read_bytes +
disk.write_bytes - self._last_disk_io.write_bytes)

io_total = net_delta + disk_delta
self.io_intensity = min(1.0, io_total / (50 * 1024 * 1024))
self._current_io = self.io_intensity

self._last_net_io = net
self._last_disk_io = disk
except Exception:
self.io_intensity = 0.0

return {
'cpu': cpu,
'mem': mem,
'cpu_intensity': cpu / 100.0,
'mem_intensity': mem / 100.0,
'io_intensity': self.io_intensity,
}
except Exception:
return {'cpu': 0, 'mem': 0, 'cpu_intensity': 0, 'mem_intensity': 0, 'io_intensity': 0}

def _get_metrics(self) -> dict:
"""The most recently pushed metrics, in the form the renderer wants."""
return {
'cpu': self._current_cpu,
'mem': self._current_mem,
'cpu_intensity': self._current_cpu / 100.0,
'mem_intensity': self._current_mem / 100.0,
'io_intensity': self.io_intensity,
}

def _draw_hud(self, metrics: dict):
"""Draw telemetry HUD overlays on the buffer."""
Expand Down
79 changes: 63 additions & 16 deletions src/pulse/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@
from textual.containers import Container, Vertical
from textual.screen import Screen
from textual.widgets import Header, Footer
from textual.worker import Worker

from pulse.container_api import ContainerController
from pulse.core import MetricStore, Sampler, SystemSource



Expand Down Expand Up @@ -256,10 +260,19 @@ def get_system_commands(self, screen: Screen) -> Iterable[SystemCommand]:
continue
yield cmd

def __init__(self):
def __init__(self, source=None):
super().__init__()
self.frozen = False

# One store, one sampler. Panels read the store; nothing else samples.
# `source` is injectable so tests can drive the UI from a MockSource.
self.container_controller = ContainerController()
self.store = MetricStore()
self.sampler = Sampler(
source if source is not None
else SystemSource(self.container_controller)
)

# Load Config (already validated - see pulse.config)
self.config = load_config()

Expand Down Expand Up @@ -288,18 +301,23 @@ def compose(self):
# Right Sidebar (3 widgets)
with Vertical(id="sidebar-right"):
yield NetworkPanel()
yield DockerPanel(id="docker-panel")
yield DockerPanel(controller=self.container_controller,
id="docker-panel")
yield InsightPanel()
yield Footer()

def on_mount(self):
"""Start the update timer."""
# Push boot screen immediately
self.push_screen(BootScreen())

# Apply initial theme
self.apply_theme()


# Priming warms the delta-based counters, so the first visible reading
# is a real measurement rather than a zero.
self.sampler.prime()

refresh_rate = validate_refresh_rate(
self.config.get("core", {}).get("refresh_rate")
)
Expand Down Expand Up @@ -340,19 +358,47 @@ def on_descendant_blur(self, event):
pass

def refresh_data(self):
"""Refresh all panels."""
"""Kick off a sampling tick.

Sampling happens on a worker thread: reading /proc, walking the socket
table and stat-ing mount points are all slow enough to stutter the UI
if done on the event loop. `exclusive` means a tick that overruns its
interval is skipped rather than queued behind itself.
"""
if self.frozen:
return
self.query_one("#cpu-panel", CPUPanel).update_data()
self.query_one("#memory-panel", MemoryPanel).update_data()
self.query_one("#net-panel", NetworkPanel).update_data()
self.query_one("#disk-panel", DiskIOPanel).update_data()
self.query_one("#storage-panel", StoragePanel).update_data()
self.query_one("#docker-panel", DockerPanel).update_data()
self.query_one("#process-panel", ProcessPanel).update_data()
self.query_one("#main-panel", MainViewPanel).update_data()
self.query_one("#insight-panel", InsightPanel).update_data()

self._sample_metrics()

def _sample_metrics(self) -> Worker:
"""Sample off the event loop, then hand the result back to the UI."""
return self.run_worker(
self._sample_and_apply,
name="sampler",
group="sampler",
thread=True,
exclusive=True,
)

def _sample_and_apply(self) -> None:
"""Worker body. Runs on a thread - must not touch widgets directly."""
snapshot = self.sampler.sample()
self.call_from_thread(self.apply_snapshot, snapshot)

def apply_snapshot(self, snapshot) -> None:
"""Publish a snapshot and repaint. Runs on the event loop."""
self.store.push(snapshot)
self.refresh_panels()

def refresh_panels(self) -> None:
"""Repaint every panel from the current store contents."""
for panel in self.query(Panel):
try:
panel.update_data()
except Exception as exc: # noqa: BLE001
# One panel failing to render must not stop the others.
self.log.warning(f"{type(panel).__name__} failed to render: {exc}")



def action_cycle_theme(self):
"""Cycle through available themes."""
Expand All @@ -365,7 +411,8 @@ def action_cycle_theme(self):
self.notify("Theme applied, but the preference could not be saved.",
severity="warning")

self.refresh_data() # Refresh to show visual updates immediately
# Repaint from the store; a theme change needs no new measurements.
self.refresh_panels()

def action_freeze(self):
"""Toggle freeze state."""
Expand Down
43 changes: 0 additions & 43 deletions src/pulse/core.py

This file was deleted.

Loading
Loading