diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5748b74..c151656 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 @@ -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 }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 00e6925..50b79e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 7442d3c..9093fe4 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -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 @@ -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 diff --git a/src/pulse/aether/engine.py b/src/pulse/aether/engine.py index e738625..e8491c2 100644 --- a/src/pulse/aether/engine.py +++ b/src/pulse/aether/engine.py @@ -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 ( @@ -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.""" diff --git a/src/pulse/app.py b/src/pulse/app.py index ad46da2..f6e39e8 100644 --- a/src/pulse/app.py +++ b/src/pulse/app.py @@ -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 @@ -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() @@ -288,7 +301,8 @@ 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() @@ -296,10 +310,14 @@ 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") ) @@ -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.""" @@ -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.""" diff --git a/src/pulse/core.py b/src/pulse/core.py deleted file mode 100644 index d294bc1..0000000 --- a/src/pulse/core.py +++ /dev/null @@ -1,43 +0,0 @@ -""" -Pulse Core - Hardware Interface Layer - -Read-only metrics come from ``pulse.direct_os``. State-changing operations are -re-exported from ``pulse.actions``, which is the guarded layer: it refuses -protected targets and reports failures instead of swallowing them. -""" -from pulse import direct_os -from pulse.actions import ( - ActionResult, - ProcessInfo, - adjust_nice, - describe_process, - get_nice, - kill_process, - protection_reason, - renice_process, -) - -# Metrics (read-only) -init = direct_os.init -get_memory_info = direct_os.get_memory_info -get_cpu_percents = direct_os.get_cpu_percents -get_process_list = direct_os.get_process_list -get_network_stats = direct_os.get_network_stats -get_disk_info = direct_os.get_disk_info - -__all__ = [ - "ActionResult", - "ProcessInfo", - "adjust_nice", - "describe_process", - "get_cpu_percents", - "get_disk_info", - "get_memory_info", - "get_network_stats", - "get_nice", - "get_process_list", - "init", - "kill_process", - "protection_reason", - "renice_process", -] diff --git a/src/pulse/core/__init__.py b/src/pulse/core/__init__.py new file mode 100644 index 0000000..4153f03 --- /dev/null +++ b/src/pulse/core/__init__.py @@ -0,0 +1,93 @@ +""" +Pulse Core - metrics, sampling, and state. + +Nothing in this package imports Textual or Rich. The UI depends on core; core +does not know the UI exists. That is what makes it testable headless, and what +would let a future exporter or web frontend reuse it unchanged. + +The pieces: + +- ``models`` - frozen dataclasses; one tick is one ``Snapshot`` +- ``sources`` - where numbers come from (the live system, or a mock) +- ``sampler`` - one source poll -> one Snapshot, with failures isolated +- ``store`` - the latest Snapshot plus history, rates, and peaks + +Guarded state-changing operations live in ``pulse.actions`` and are re-exported +here for convenience. +""" +from pulse import direct_os +from pulse.actions import ( + ActionResult, + ProcessInfo, + adjust_nice, + describe_process, + get_nice, + kill_process, + protection_reason, + renice_process, +) +from pulse.core.models import ( + BatterySample, + ContainerSample, + CpuSample, + DiskIOSample, + InterfaceInfo, + MemorySample, + NetworkSample, + ProcessSample, + Snapshot, + SystemSample, + VolumeSample, +) +from pulse.core.sampler import Sampler +from pulse.core.sources import MetricSource, MockSource, SystemSource +from pulse.core.store import History, MetricStore, Peaks, Rates + +# Low-level metric functions. Panels should read from a MetricStore instead; +# these remain for direct_os-level access and the existing test suite. +init = direct_os.init +get_memory_info = direct_os.get_memory_info +get_cpu_percents = direct_os.get_cpu_percents +get_process_list = direct_os.get_process_list +get_network_stats = direct_os.get_network_stats +get_disk_info = direct_os.get_disk_info + +__all__ = [ + # Actions + "ActionResult", + "ProcessInfo", + "adjust_nice", + "describe_process", + "get_nice", + "kill_process", + "protection_reason", + "renice_process", + # Models + "BatterySample", + "ContainerSample", + "CpuSample", + "DiskIOSample", + "InterfaceInfo", + "MemorySample", + "NetworkSample", + "ProcessSample", + "Snapshot", + "SystemSample", + "VolumeSample", + # Machinery + "History", + "MetricSource", + "MetricStore", + "MockSource", + "Peaks", + "Rates", + "Sampler", + "SystemSource", + # Low-level metrics + "get_cpu_percents", + "get_disk_info", + "get_memory_info", + "get_network_stats", + "get_process_list", + "init", +] diff --git a/src/pulse/core/models.py b/src/pulse/core/models.py new file mode 100644 index 0000000..b4d592a --- /dev/null +++ b/src/pulse/core/models.py @@ -0,0 +1,217 @@ +""" +Immutable sample types. + +Everything here is a frozen dataclass with no behaviour beyond simple derived +properties. One poll of a source produces one ``Snapshot``, and every widget +renders from that same object - so the CPU number in the sidebar is the same +number the process table was sorted by. + +Nothing in this module imports Textual, Rich, or psutil. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Mapping, Optional, Tuple + + +@dataclass(frozen=True) +class CpuSample: + """Processor load for a single tick.""" + + per_core: Tuple[float, ...] = () + frequency_mhz: Optional[float] = None + load_average: Optional[Tuple[float, float, float]] = None + context_switches: Optional[int] = None + interrupts: Optional[int] = None + syscalls: Optional[int] = None + + @property + def percent(self) -> float: + """Mean load across all cores.""" + if not self.per_core: + return 0.0 + return sum(self.per_core) / len(self.per_core) + + @property + def core_count(self) -> int: + return len(self.per_core) + + +@dataclass(frozen=True) +class MemorySample: + """Physical and swap memory for a single tick.""" + + total: int = 0 + available: int = 0 + used: int = 0 + free: int = 0 + percent: float = 0.0 + cached: Optional[int] = None + buffers: Optional[int] = None + swap_total: int = 0 + swap_used: int = 0 + swap_free: int = 0 + + @property + def swap_percent(self) -> float: + if not self.swap_total: + return 0.0 + return self.swap_used / self.swap_total * 100.0 + + +@dataclass(frozen=True) +class DiskIOSample: + """Cumulative disk I/O counters. + + These are totals since boot, not rates. Rates are derived by the store from + two consecutive snapshots, so nothing else has to track its own deltas. + """ + + read_bytes: int = 0 + write_bytes: int = 0 + read_count: int = 0 + write_count: int = 0 + read_time_ms: int = 0 + write_time_ms: int = 0 + per_disk: Mapping[str, "DiskIOSample"] = field(default_factory=dict) + + +@dataclass(frozen=True) +class InterfaceInfo: + """One network interface.""" + + name: str + is_up: bool = False + speed_mbps: int = 0 + mtu: int = 0 + ipv4: Optional[str] = None + + +@dataclass(frozen=True) +class NetworkSample: + """Cumulative network counters plus interface state.""" + + bytes_sent: int = 0 + bytes_recv: int = 0 + packets_sent: int = 0 + packets_recv: int = 0 + interfaces: Tuple[InterfaceInfo, ...] = () + connection_count: int = 0 + established_count: int = 0 + listen_count: int = 0 + + @property + def primary_ipv4(self) -> Optional[str]: + """First non-loopback IPv4 address, for the summary line.""" + for interface in self.interfaces: + if interface.ipv4 and not interface.ipv4.startswith("127."): + return interface.ipv4 + return None + + @property + def active_interfaces(self) -> int: + return sum(1 for interface in self.interfaces if interface.is_up) + + +@dataclass(frozen=True) +class ProcessSample: + """One process at one instant.""" + + pid: int + name: str = "?" + cpu_percent: float = 0.0 + memory_bytes: int = 0 + username: str = "?" + status: str = "?" + num_threads: int = 0 + + def memory_percent(self, total_memory: int) -> float: + if not total_memory: + return 0.0 + return self.memory_bytes / total_memory * 100.0 + + +@dataclass(frozen=True) +class VolumeSample: + """A mounted filesystem.""" + + device: str = "" + mountpoint: str = "" + fstype: str = "" + total: int = 0 + used: int = 0 + free: int = 0 + percent: float = 0.0 + + +@dataclass(frozen=True) +class BatterySample: + percent: float = 0.0 + power_plugged: bool = False + seconds_left: Optional[int] = None + + +@dataclass(frozen=True) +class SystemSample: + """Host facts that change rarely or never.""" + + hostname: str = "" + platform_name: str = "" + platform_release: str = "" + platform_version: str = "" + architecture: str = "" + processor: str = "" + boot_time: float = 0.0 + battery: Optional[BatterySample] = None + + +@dataclass(frozen=True) +class ContainerSample: + """One Docker container.""" + + id: str = "" + name: str = "" + image: str = "" + status: str = "unknown" + state: str = "unknown" + + +@dataclass(frozen=True) +class Snapshot: + """Everything measured in one tick. + + A snapshot is a single consistent view: the sampler builds it once and all + panels read the same instance, so numbers cannot disagree across the UI. + """ + + timestamp: float = 0.0 + cpu: CpuSample = field(default_factory=CpuSample) + memory: MemorySample = field(default_factory=MemorySample) + disk_io: DiskIOSample = field(default_factory=DiskIOSample) + network: NetworkSample = field(default_factory=NetworkSample) + processes: Tuple[ProcessSample, ...] = () + volumes: Tuple[VolumeSample, ...] = () + system: SystemSample = field(default_factory=SystemSample) + containers: Tuple[ContainerSample, ...] = () + + # Subsystems that raised while being sampled, mapped to the error text. + # A failing metric degrades its own panel instead of blanking the tick. + errors: Mapping[str, str] = field(default_factory=dict) + + def top_processes(self, key: str = "cpu", limit: int = 10) -> Tuple[ProcessSample, ...]: + """Processes sorted by ``cpu`` or ``mem``, highest first.""" + if key == "mem": + ordered = sorted(self.processes, key=lambda p: p.memory_bytes, reverse=True) + else: + ordered = sorted(self.processes, key=lambda p: p.cpu_percent, reverse=True) + return tuple(ordered[:limit]) + + def find_process(self, pid: int) -> Optional[ProcessSample]: + for process in self.processes: + if process.pid == pid: + return process + return None + + @property + def healthy(self) -> bool: + return not self.errors diff --git a/src/pulse/core/sampler.py b/src/pulse/core/sampler.py new file mode 100644 index 0000000..c7cb6f1 --- /dev/null +++ b/src/pulse/core/sampler.py @@ -0,0 +1,72 @@ +""" +Turns one poll of a source into one Snapshot. + +The sampler's job is fault isolation. Each subsystem is read independently, and +a failure is recorded in ``Snapshot.errors`` and replaced with an empty sample +rather than propagating - so a machine where ``net_connections`` needs elevation +still shows CPU, memory, and disk. +""" +from __future__ import annotations + +import time +from typing import Any, Callable, Dict, Optional + +from pulse.core.models import ( + CpuSample, + DiskIOSample, + MemorySample, + NetworkSample, + Snapshot, + SystemSample, +) +from pulse.core.sources.base import MetricSource + + +class Sampler: + """Builds Snapshots from a MetricSource.""" + + def __init__(self, source: MetricSource, clock: Callable[[], float] = time.time) -> None: + self.source = source + self._clock = clock + self._primed = False + + def prime(self) -> None: + """Warm up the source's delta-based counters.""" + try: + self.source.prime() + except Exception: + # Priming is an optimisation; a failure here must not stop startup. + pass + self._primed = True + + def sample(self) -> Snapshot: + """Read every subsystem once and assemble a Snapshot. + + Never raises: a broken subsystem is reported through ``errors``. + """ + if not self._primed: + self.prime() + + errors: Dict[str, str] = {} + + def read(name: str, call: Callable[[], Any], fallback: Any) -> Any: + try: + return call() + except Exception as exc: # noqa: BLE001 - deliberately broad + # One subsystem failing must not blank the whole tick. + errors[name] = f"{type(exc).__name__}: {exc}" + return fallback + + snapshot = Snapshot( + timestamp=self._clock(), + cpu=read("cpu", self.source.cpu, CpuSample()), + memory=read("memory", self.source.memory, MemorySample()), + disk_io=read("disk_io", self.source.disk_io, DiskIOSample()), + network=read("network", self.source.network, NetworkSample()), + processes=read("processes", self.source.processes, ()), + volumes=read("volumes", self.source.volumes, ()), + system=read("system", self.source.system, SystemSample()), + containers=read("containers", self.source.containers, ()), + errors=errors, + ) + return snapshot diff --git a/src/pulse/core/sources/__init__.py b/src/pulse/core/sources/__init__.py new file mode 100644 index 0000000..4371a4a --- /dev/null +++ b/src/pulse/core/sources/__init__.py @@ -0,0 +1,6 @@ +"""Metric sources - where a Snapshot's numbers come from.""" +from pulse.core.sources.base import MetricSource +from pulse.core.sources.mock import MockSource +from pulse.core.sources.system import SystemSource + +__all__ = ["MetricSource", "MockSource", "SystemSource"] diff --git a/src/pulse/core/sources/base.py b/src/pulse/core/sources/base.py new file mode 100644 index 0000000..3faf8f7 --- /dev/null +++ b/src/pulse/core/sources/base.py @@ -0,0 +1,56 @@ +""" +The contract a metric source has to satisfy. + +Sources answer one question each and return model objects. They are allowed to +be slow and to raise - the sampler runs them off the event loop and isolates +their failures. They are not allowed to import anything from the UI. +""" +from __future__ import annotations + +from typing import Protocol, Tuple, runtime_checkable + +from pulse.core.models import ( + ContainerSample, + CpuSample, + DiskIOSample, + MemorySample, + NetworkSample, + ProcessSample, + SystemSample, + VolumeSample, +) + + +@runtime_checkable +class MetricSource(Protocol): + """Where a Snapshot's numbers come from. + + Implemented by the live system, and by fakes for tests and replay. + """ + + def cpu(self) -> CpuSample: + ... + + def memory(self) -> MemorySample: + ... + + def disk_io(self) -> DiskIOSample: + ... + + def network(self) -> NetworkSample: + ... + + def processes(self) -> Tuple[ProcessSample, ...]: + ... + + def volumes(self) -> Tuple[VolumeSample, ...]: + ... + + def system(self) -> SystemSample: + ... + + def containers(self) -> Tuple[ContainerSample, ...]: + ... + + def prime(self) -> None: + """Warm up delta-based counters so the first real reading is meaningful.""" diff --git a/src/pulse/core/sources/mock.py b/src/pulse/core/sources/mock.py new file mode 100644 index 0000000..59f7a93 --- /dev/null +++ b/src/pulse/core/sources/mock.py @@ -0,0 +1,189 @@ +""" +A deterministic metric source. + +Lets the whole core - and eventually the whole UI - be tested without touching +the real machine. Values follow a fixed script, so a test can assert exact +numbers instead of ranges, and a subsystem can be made to fail on demand. +""" +from __future__ import annotations + +import math +from typing import Dict, Optional, Set, Tuple + +from pulse.core.models import ( + BatterySample, + ContainerSample, + CpuSample, + DiskIOSample, + InterfaceInfo, + MemorySample, + NetworkSample, + ProcessSample, + SystemSample, + VolumeSample, +) + +GIB = 1024 ** 3 + + +class MockSource: + """A fake machine that behaves the same way every run. + + CPU and memory follow a sine wave so history buffers fill with something + varied but predictable. Counters advance by a fixed amount per tick, which + makes derived rates exactly checkable. + """ + + def __init__( + self, + core_count: int = 4, + total_memory: int = 16 * GIB, + process_count: int = 12, + bytes_per_tick: int = 1024 * 1024, + fail: Optional[Set[str]] = None, + ) -> None: + self.core_count = core_count + self.total_memory = total_memory + self.process_count = process_count + self.bytes_per_tick = bytes_per_tick + # Subsystem names that should raise, so fault isolation can be tested. + self.fail = set(fail or ()) + + self.tick = 0 + self.primed = False + self.call_counts: Dict[str, int] = {} + + # ------------------------------------------------------------------ + def prime(self) -> None: + self.primed = True + + def advance(self, ticks: int = 1) -> None: + """Move the fake machine forward in time.""" + self.tick += ticks + + def _record(self, name: str) -> None: + self.call_counts[name] = self.call_counts.get(name, 0) + 1 + if name in self.fail: + raise RuntimeError(f"mock {name} failure") + + def _wave(self, offset: float = 0.0, amplitude: float = 40.0, + baseline: float = 50.0) -> float: + value = baseline + amplitude * math.sin((self.tick + offset) / 3.0) + return max(0.0, min(100.0, value)) + + # ------------------------------------------------------------------ + def cpu(self) -> CpuSample: + self._record("cpu") + return CpuSample( + per_core=tuple(self._wave(offset=i) for i in range(self.core_count)), + frequency_mhz=2400.0, + load_average=(1.0, 1.5, 2.0), + context_switches=1000 * (self.tick + 1), + interrupts=500 * (self.tick + 1), + syscalls=2000 * (self.tick + 1), + ) + + def memory(self) -> MemorySample: + self._record("memory") + percent = self._wave(offset=1.5, amplitude=20.0, baseline=60.0) + used = int(self.total_memory * percent / 100) + swap_total = 4 * GIB + return MemorySample( + total=self.total_memory, + available=self.total_memory - used, + used=used, + free=self.total_memory - used, + percent=percent, + cached=2 * GIB, + buffers=GIB, + swap_total=swap_total, + swap_used=swap_total // 4, + swap_free=swap_total - swap_total // 4, + ) + + def disk_io(self) -> DiskIOSample: + self._record("disk_io") + read = self.bytes_per_tick * self.tick + write = self.bytes_per_tick * self.tick // 2 + return DiskIOSample( + read_bytes=read, + write_bytes=write, + read_count=10 * self.tick, + write_count=5 * self.tick, + read_time_ms=20 * self.tick, + write_time_ms=10 * self.tick, + per_disk={ + "disk0": DiskIOSample(read_bytes=read, write_bytes=write, + read_count=10 * self.tick, + write_count=5 * self.tick), + }, + ) + + def network(self) -> NetworkSample: + self._record("network") + return NetworkSample( + bytes_sent=self.bytes_per_tick * self.tick, + bytes_recv=self.bytes_per_tick * self.tick * 2, + packets_sent=100 * self.tick, + packets_recv=200 * self.tick, + interfaces=( + InterfaceInfo("eth0", is_up=True, speed_mbps=1000, mtu=1500, + ipv4="192.168.1.10"), + InterfaceInfo("lo", is_up=True, speed_mbps=0, mtu=65536, + ipv4="127.0.0.1"), + InterfaceInfo("wlan0", is_up=False, speed_mbps=0, mtu=1500), + ), + connection_count=42, + established_count=12, + listen_count=8, + ) + + def processes(self) -> Tuple[ProcessSample, ...]: + self._record("processes") + return tuple( + ProcessSample( + pid=1000 + i, + name=f"proc{i}", + # Descending, so ordering assertions are unambiguous. + cpu_percent=float(self.process_count - i), + memory_bytes=(self.process_count - i) * 64 * 1024 * 1024, + username="tester", + status="running" if i % 2 == 0 else "sleeping", + num_threads=i + 1, + ) + for i in range(self.process_count) + ) + + def volumes(self) -> Tuple[VolumeSample, ...]: + self._record("volumes") + return ( + VolumeSample(device="/dev/sda1", mountpoint="/", fstype="ext4", + total=512 * GIB, used=256 * GIB, free=256 * GIB, + percent=50.0), + VolumeSample(device="/dev/sda2", mountpoint="/home", fstype="ext4", + total=1024 * GIB, used=900 * GIB, free=124 * GIB, + percent=87.9), + ) + + def system(self) -> SystemSample: + self._record("system") + return SystemSample( + hostname="mock-host", + platform_name="MockOS", + platform_release="1.0", + platform_version="1.0.0", + architecture="x86_64", + processor="Mock CPU", + boot_time=1_700_000_000.0, + battery=BatterySample(percent=75.0, power_plugged=False, + seconds_left=3600), + ) + + def containers(self) -> Tuple[ContainerSample, ...]: + self._record("containers") + return ( + ContainerSample(id="abc123", name="web", image="nginx:latest", + status="running", state="running"), + ContainerSample(id="def456", name="db", image="postgres:16", + status="exited", state="exited"), + ) diff --git a/src/pulse/core/sources/system.py b/src/pulse/core/sources/system.py new file mode 100644 index 0000000..94a5435 --- /dev/null +++ b/src/pulse/core/sources/system.py @@ -0,0 +1,351 @@ +""" +The live system metric source. + +This is the only place that talks to psutil and ``direct_os``. Each method is +independent, so a subsystem that raises (net_connections needs elevation on +macOS; disk_io_counters returns None in some containers) degrades on its own +rather than taking the tick with it. +""" +from __future__ import annotations + +import platform +from typing import Dict, Optional, Tuple + +import psutil + +from pulse import direct_os +from pulse.core.models import ( + BatterySample, + ContainerSample, + CpuSample, + DiskIOSample, + InterfaceInfo, + MemorySample, + NetworkSample, + ProcessSample, + SystemSample, + VolumeSample, +) + +# Reading every socket is expensive and needs privileges on some platforms, so +# the connection table is capped rather than walked in full. +MAX_CONNECTIONS = 500 + +# How many processes to keep per sort axis. The snapshot holds the union of the +# heaviest by CPU and the heaviest by memory, so whichever way a view sorts, +# the processes it wants are present. +# +# This must not be a plain prefix of the raw list: that truncates in PID order, +# which on a machine with more processes than the cap silently drops every high +# PID - including, on a busy host, the heaviest processes and Pulse itself. +PROCESSES_PER_AXIS = 150 + + +class SystemSource: + """Reads real metrics from the running machine.""" + + def __init__(self, container_controller=None) -> None: + # Optional and lazily connected - see pulse.container_api. + self._containers = container_controller + self._system_cache: Optional[SystemSample] = None + + def prime(self) -> None: + """Prime the delta-based counters.""" + direct_os.init() + + # ------------------------------------------------------------------ + # CPU + # ------------------------------------------------------------------ + def cpu(self) -> CpuSample: + per_core = tuple(direct_os.get_cpu_percents()) + + frequency = None + try: + freq = psutil.cpu_freq() + if freq is not None: + frequency = freq.current + except (OSError, AttributeError, NotImplementedError): + pass + + load_average = None + try: + load_average = tuple(psutil.getloadavg()) + except (OSError, AttributeError): + pass + + context_switches = interrupts = syscalls = None + try: + stats = psutil.cpu_stats() + context_switches = stats.ctx_switches + interrupts = stats.interrupts + syscalls = getattr(stats, "syscalls", None) + except (OSError, AttributeError, RuntimeError): + pass + + return CpuSample( + per_core=per_core, + frequency_mhz=frequency, + load_average=load_average, + context_switches=context_switches, + interrupts=interrupts, + syscalls=syscalls, + ) + + # ------------------------------------------------------------------ + # Memory + # ------------------------------------------------------------------ + def memory(self) -> MemorySample: + data = direct_os.get_memory_info() + return MemorySample( + total=data.get("total", 0), + available=data.get("available", 0), + used=data.get("used", 0), + free=data.get("free", data.get("available", 0)), + percent=float(data.get("percent", 0.0)), + cached=data.get("cached"), + buffers=data.get("buffers"), + swap_total=data.get("swap_total", 0), + swap_used=data.get("swap_used", 0), + swap_free=data.get("swap_free", 0), + ) + + # ------------------------------------------------------------------ + # Disk I/O + # ------------------------------------------------------------------ + def disk_io(self) -> DiskIOSample: + totals = psutil.disk_io_counters() + if totals is None: + return DiskIOSample() + + per_disk: Dict[str, DiskIOSample] = {} + try: + for device, counters in (psutil.disk_io_counters(perdisk=True) or {}).items(): + per_disk[device] = self._io_sample(counters) + except (OSError, RuntimeError): + pass + + return self._io_sample(totals, per_disk) + + @staticmethod + def _io_sample(counters, per_disk=None) -> DiskIOSample: + return DiskIOSample( + read_bytes=counters.read_bytes, + write_bytes=counters.write_bytes, + read_count=counters.read_count, + write_count=counters.write_count, + read_time_ms=getattr(counters, "read_time", 0), + write_time_ms=getattr(counters, "write_time", 0), + per_disk=per_disk or {}, + ) + + # ------------------------------------------------------------------ + # Network + # ------------------------------------------------------------------ + def network(self) -> NetworkSample: + counters = psutil.net_io_counters() + sent = counters.bytes_sent if counters else 0 + recv = counters.bytes_recv if counters else 0 + packets_sent = counters.packets_sent if counters else 0 + packets_recv = counters.packets_recv if counters else 0 + + interfaces = self._interfaces() + + total = established = listening = 0 + try: + connections = psutil.net_connections(kind="inet")[:MAX_CONNECTIONS] + total = len(connections) + established = sum(1 for c in connections if c.status == "ESTABLISHED") + listening = sum(1 for c in connections if c.status == "LISTEN") + except (psutil.AccessDenied, OSError, RuntimeError): + # Needs elevation on macOS; the rest of the sample is still good. + pass + + return NetworkSample( + bytes_sent=sent, + bytes_recv=recv, + packets_sent=packets_sent, + packets_recv=packets_recv, + interfaces=interfaces, + connection_count=total, + established_count=established, + listen_count=listening, + ) + + @staticmethod + def _interfaces() -> Tuple[InterfaceInfo, ...]: + try: + stats = psutil.net_if_stats() + addresses = psutil.net_if_addrs() + except (OSError, RuntimeError): + return () + + interfaces = [] + for name, stat in stats.items(): + ipv4 = None + for address in addresses.get(name, []): + # AF_INET is 2 on every platform psutil supports. + if int(address.family) == 2: + ipv4 = address.address + break + interfaces.append(InterfaceInfo( + name=name, + is_up=stat.isup, + speed_mbps=stat.speed, + mtu=stat.mtu, + ipv4=ipv4, + )) + return tuple(interfaces) + + # ------------------------------------------------------------------ + # Processes + # ------------------------------------------------------------------ + def processes(self) -> Tuple[ProcessSample, ...]: + # Fetch everything first - the pid/name/cpu/memory fields are cheap. + # The per-process psutil lookups below are not, so they only run for + # the processes a view could actually display. + raw = direct_os.get_process_list() + + samples = [] + for entry in self._select(raw): + pid = entry["pid"] + username, status, threads = self._process_details(pid) + samples.append(ProcessSample( + pid=pid, + name=entry.get("name", "?"), + cpu_percent=entry.get("cpu_percent", 0.0), + memory_bytes=entry.get("memory_info", 0), + username=username, + status=status, + num_threads=threads, + )) + return tuple(samples) + + @staticmethod + def _select(raw): + """The processes worth keeping: heaviest by CPU, plus heaviest by memory. + + Selecting by weight rather than taking a prefix is what keeps the busy + processes in the snapshot on a host with more processes than the cap. + """ + by_cpu = sorted(raw, key=lambda e: e.get("cpu_percent", 0.0), + reverse=True)[:PROCESSES_PER_AXIS] + by_memory = sorted(raw, key=lambda e: e.get("memory_info", 0), + reverse=True)[:PROCESSES_PER_AXIS] + + selected = {} + for entry in (*by_cpu, *by_memory): + selected[entry["pid"]] = entry + return list(selected.values()) + + @staticmethod + def _process_details(pid: int): + """Best-effort extras. Missing ones are not worth failing a tick over.""" + username, status, threads = "?", "?", 0 + try: + proc = psutil.Process(pid) + with proc.oneshot(): + try: + username = proc.username() + if "\\" in username: + username = username.split("\\")[-1] + except (psutil.AccessDenied, KeyError): + pass + try: + status = proc.status() + except psutil.AccessDenied: + pass + try: + threads = proc.num_threads() + except psutil.AccessDenied: + pass + except (psutil.NoSuchProcess, psutil.AccessDenied, OSError): + pass + return username, status, threads + + # ------------------------------------------------------------------ + # Volumes + # ------------------------------------------------------------------ + def volumes(self) -> Tuple[VolumeSample, ...]: + return tuple( + VolumeSample( + device=disk["device"], + mountpoint=disk["mountpoint"], + fstype=disk["fstype"], + total=disk["total"], + used=disk["used"], + free=disk["free"], + percent=disk["percent"], + ) + for disk in direct_os.get_disk_info() + ) + + # ------------------------------------------------------------------ + # System + # ------------------------------------------------------------------ + def system(self) -> SystemSample: + # Host identity does not change while the app runs; only the battery + # is re-read each tick. + if self._system_cache is None: + uname = platform.uname() + try: + boot_time = psutil.boot_time() + except (OSError, RuntimeError): + boot_time = 0.0 + + self._system_cache = SystemSample( + hostname=uname.node, + platform_name=uname.system, + platform_release=uname.release, + platform_version=uname.version, + architecture=uname.machine, + processor=uname.processor, + boot_time=boot_time, + ) + + base = self._system_cache + return SystemSample( + hostname=base.hostname, + platform_name=base.platform_name, + platform_release=base.platform_release, + platform_version=base.platform_version, + architecture=base.architecture, + processor=base.processor, + boot_time=base.boot_time, + battery=self._battery(), + ) + + @staticmethod + def _battery() -> Optional[BatterySample]: + try: + battery = psutil.sensors_battery() + except (AttributeError, OSError, NotImplementedError): + return None + if battery is None: + return None + + seconds_left = battery.secsleft + if seconds_left in (psutil.POWER_TIME_UNLIMITED, psutil.POWER_TIME_UNKNOWN): + seconds_left = None + + return BatterySample( + percent=battery.percent, + power_plugged=bool(battery.power_plugged), + seconds_left=seconds_left, + ) + + # ------------------------------------------------------------------ + # Containers + # ------------------------------------------------------------------ + def containers(self) -> Tuple[ContainerSample, ...]: + if self._containers is None: + return () + return tuple( + ContainerSample( + id=entry["id"], + name=entry["name"], + image=entry["image"], + status=entry["status"], + state=entry["state"], + ) + for entry in self._containers.get_containers() + ) diff --git a/src/pulse/core/store.py b/src/pulse/core/store.py new file mode 100644 index 0000000..e90972d --- /dev/null +++ b/src/pulse/core/store.py @@ -0,0 +1,209 @@ +""" +Holds the latest Snapshot, the history behind it, and everything derived. + +Rates are computed here, once per tick, from two consecutive snapshots. Before +this existed, each panel tracked its own ``last_net``/``last_io`` and divided by +an assumed one-second interval, so the numbers drifted apart whenever the +refresh rate changed or a tick ran late. +""" +from __future__ import annotations + +from collections import deque +from dataclasses import dataclass, field +from typing import Deque, Dict, List, Optional, Tuple + +from pulse.core.models import Snapshot + +# 80 samples is what the widest waveform in the UI draws. +HISTORY_LENGTH = 80 + + +@dataclass(frozen=True) +class Rates: + """Per-second values derived from two consecutive snapshots.""" + + interval: float = 0.0 + net_sent_kbps: float = 0.0 + net_recv_kbps: float = 0.0 + disk_read_mbps: float = 0.0 + disk_write_mbps: float = 0.0 + disk_read_latency_ms: float = 0.0 + disk_write_latency_ms: float = 0.0 + + +@dataclass +class Peaks: + """Session highs, for the Insight panel.""" + + cpu: float = 0.0 + memory: float = 0.0 + net_sent_kbps: float = 0.0 + net_recv_kbps: float = 0.0 + disk_read_mbps: float = 0.0 + disk_write_mbps: float = 0.0 + + +@dataclass +class History: + """Bounded ring buffers for the waveform views.""" + + maxlen: int = HISTORY_LENGTH + cpu: Deque[float] = field(default_factory=lambda: deque(maxlen=HISTORY_LENGTH)) + memory: Deque[float] = field(default_factory=lambda: deque(maxlen=HISTORY_LENGTH)) + net_sent: Deque[float] = field(default_factory=lambda: deque(maxlen=HISTORY_LENGTH)) + net_recv: Deque[float] = field(default_factory=lambda: deque(maxlen=HISTORY_LENGTH)) + disk_read: Deque[float] = field(default_factory=lambda: deque(maxlen=HISTORY_LENGTH)) + disk_write: Deque[float] = field(default_factory=lambda: deque(maxlen=HISTORY_LENGTH)) + tension: Deque[float] = field(default_factory=lambda: deque(maxlen=HISTORY_LENGTH)) + + +class MetricStore: + """The single place the UI reads from. + + Panels ask the store for the current snapshot and its history; they never + sample anything themselves. + """ + + def __init__(self, history_length: int = HISTORY_LENGTH) -> None: + self.history_length = history_length + self.history = History(maxlen=history_length) + self.peaks = Peaks() + self.rates = Rates() + + self._snapshot: Optional[Snapshot] = None + self._previous: Optional[Snapshot] = None + self._per_core: List[Deque[float]] = [] + self._started_at: Optional[float] = None + self.tick_count = 0 + + # Rebuild the deques at the requested length. + if history_length != HISTORY_LENGTH: + for name in ("cpu", "memory", "net_sent", "net_recv", + "disk_read", "disk_write", "tension"): + setattr(self.history, name, deque(maxlen=history_length)) + + # ------------------------------------------------------------------ + # Reading + # ------------------------------------------------------------------ + @property + def snapshot(self) -> Optional[Snapshot]: + """The most recent snapshot, or None before the first tick.""" + return self._snapshot + + @property + def ready(self) -> bool: + return self._snapshot is not None + + @property + def uptime_seconds(self) -> float: + """How long this Pulse session has been sampling.""" + if self._started_at is None or self._snapshot is None: + return 0.0 + return self._snapshot.timestamp - self._started_at + + def per_core_history(self, index: int) -> Tuple[float, ...]: + if 0 <= index < len(self._per_core): + return tuple(self._per_core[index]) + return () + + @property + def tension(self) -> float: + """A rough "how stressed is this machine" score, 0-100. + + Deliberately simple and documented as a heuristic: a weighted blend of + CPU, memory, and I/O saturation. It is not a model of anything. + """ + if self._snapshot is None: + return 0.0 + + cpu = self._snapshot.cpu.percent + memory = self._snapshot.memory.percent + # 50 MB/s of combined throughput counts as fully saturated. + io = min(100.0, (self.rates.disk_read_mbps + self.rates.disk_write_mbps) * 2.0) + return min(100.0, cpu * 0.4 + memory * 0.4 + io * 0.2) + + # ------------------------------------------------------------------ + # Writing + # ------------------------------------------------------------------ + def push(self, snapshot: Snapshot) -> None: + """Record a new tick and update everything derived from it.""" + self._previous = self._snapshot + self._snapshot = snapshot + self.tick_count += 1 + + if self._started_at is None: + self._started_at = snapshot.timestamp + + self.rates = self._derive_rates(self._previous, snapshot) + self._update_history(snapshot) + self._update_peaks(snapshot) + + @staticmethod + def _derive_rates(previous: Optional[Snapshot], current: Snapshot) -> Rates: + """Per-second rates between two snapshots. + + Uses the real elapsed time between them, so a late or reconfigured tick + reports the correct rate instead of assuming one second passed. + """ + if previous is None: + return Rates() + + interval = current.timestamp - previous.timestamp + if interval <= 0: + return Rates() + + def per_second(now: int, before: int, divisor: float) -> float: + delta = now - before + # Counters reset when an interface or device goes away. + if delta < 0: + return 0.0 + return delta / divisor / interval + + read_ops = current.disk_io.read_count - previous.disk_io.read_count + write_ops = current.disk_io.write_count - previous.disk_io.write_count + read_time = current.disk_io.read_time_ms - previous.disk_io.read_time_ms + write_time = current.disk_io.write_time_ms - previous.disk_io.write_time_ms + + return Rates( + interval=interval, + net_sent_kbps=per_second(current.network.bytes_sent, + previous.network.bytes_sent, 1024), + net_recv_kbps=per_second(current.network.bytes_recv, + previous.network.bytes_recv, 1024), + disk_read_mbps=per_second(current.disk_io.read_bytes, + previous.disk_io.read_bytes, 1024 ** 2), + disk_write_mbps=per_second(current.disk_io.write_bytes, + previous.disk_io.write_bytes, 1024 ** 2), + disk_read_latency_ms=(read_time / read_ops) if read_ops > 0 else 0.0, + disk_write_latency_ms=(write_time / write_ops) if write_ops > 0 else 0.0, + ) + + def _update_history(self, snapshot: Snapshot) -> None: + self.history.cpu.append(snapshot.cpu.percent) + self.history.memory.append(snapshot.memory.percent) + self.history.net_sent.append(self.rates.net_sent_kbps) + self.history.net_recv.append(self.rates.net_recv_kbps) + self.history.disk_read.append(self.rates.disk_read_mbps) + self.history.disk_write.append(self.rates.disk_write_mbps) + self.history.tension.append(self.tension) + + # Grow the per-core buffers if the core count changed (or on first tick). + while len(self._per_core) < snapshot.cpu.core_count: + self._per_core.append(deque(maxlen=self.history_length)) + for index, value in enumerate(snapshot.cpu.per_core): + self._per_core[index].append(value) + + def _update_peaks(self, snapshot: Snapshot) -> None: + self.peaks.cpu = max(self.peaks.cpu, snapshot.cpu.percent) + self.peaks.memory = max(self.peaks.memory, snapshot.memory.percent) + self.peaks.net_sent_kbps = max(self.peaks.net_sent_kbps, self.rates.net_sent_kbps) + self.peaks.net_recv_kbps = max(self.peaks.net_recv_kbps, self.rates.net_recv_kbps) + self.peaks.disk_read_mbps = max(self.peaks.disk_read_mbps, self.rates.disk_read_mbps) + self.peaks.disk_write_mbps = max(self.peaks.disk_write_mbps, self.rates.disk_write_mbps) + + def reset_network_baseline(self) -> None: + """Clear the network waveforms without disturbing other history.""" + self.history.net_sent.clear() + self.history.net_recv.clear() + self.peaks.net_sent_kbps = 0.0 + self.peaks.net_recv_kbps = 0.0 diff --git a/src/pulse/panels/base.py b/src/pulse/panels/base.py index ab75d38..1993532 100644 --- a/src/pulse/panels/base.py +++ b/src/pulse/panels/base.py @@ -6,6 +6,8 @@ from pulse import core from pulse.actions import ActionResult +from pulse.core.models import Snapshot +from pulse.core.store import MetricStore from pulse.screens.confirm import ( BlockedScreen, ConfirmScreen, @@ -36,6 +38,34 @@ def on_click(self) -> None: """Focus the panel when clicked.""" self.focus() + # ------------------------------------------------------------------ + # Metric access + # + # Panels read from the shared store; they never sample anything. That is + # what keeps every number on screen consistent within a tick, and keeps + # blocking system calls off the event loop. + # ------------------------------------------------------------------ + @property + def store(self) -> MetricStore: + return self.app.store + + @property + def snapshot(self) -> Optional[Snapshot]: + """The current tick, or None before the first sample lands.""" + return self.app.store.snapshot + + @property + def history(self): + return self.app.store.history + + @property + def rates(self): + return self.app.store.rates + + def waiting_text(self) -> Text: + """Placeholder shown until the first snapshot arrives.""" + return Text("Acquiring telemetry...", style="dim") + # ------------------------------------------------------------------ # Guarded actions # ------------------------------------------------------------------ diff --git a/src/pulse/panels/cpu.py b/src/pulse/panels/cpu.py index 5107741..4a6f9b5 100644 --- a/src/pulse/panels/cpu.py +++ b/src/pulse/panels/cpu.py @@ -1,14 +1,12 @@ -from collections import deque -import psutil from rich.text import Text -from pulse import core from pulse.panels.base import Panel from pulse.ui_utils import value_to_spark, value_to_heat_color, make_bar from textual.containers import Container, Vertical, Horizontal from textual.widgets import Static, Button + class CPUPanel(Panel): """Shows CPU core heat blocks with real data.""" @@ -22,22 +20,18 @@ class CPUPanel(Panel): def __init__(self): super().__init__("CPU CORES", "", id="cpu-panel") - self.core_count = psutil.cpu_count() - # Track history for every core - self.per_core_history = [deque(maxlen=30) for _ in range(self.core_count)] - self.aggregate_history = deque(maxlen=80) # Transcendence Control States self.sampling_rate = 1.0 - self.view_mode = "developer" # cinematic / developer - self.scaling_mode = "absolute" # absolute / relative + self.view_mode = "developer" # cinematic / developer + self.scaling_mode = "absolute" # absolute / relative # The heaviest process currently on display. Actions name it explicitly # and confirm before touching it - see Panel.request_kill. self.top_pid = None - # Initialize Core - core.init() - + # ------------------------------------------------------------------ + # Actions + # ------------------------------------------------------------------ def action_kill_process(self): """Terminate the displayed top process, after confirmation.""" self.request_kill(self.top_pid) @@ -50,29 +44,6 @@ def action_renice_down(self): """Decrease nice value (higher priority).""" self.request_renice(self.top_pid, -1) - def compose_transcendence(self): - """Compose the interactive Core Management Console.""" - with Container(id="cpu-transcendence-layout"): - # Top Section: Header & Stats - with Horizontal(classes="header-section"): - yield Static(id="cpu-hero-header") - - # Middle: Core Grid - with Container(classes="core-section"): - yield Static("CORE ARCHITECTURE", classes="section-title") - yield Static(id="cpu-core-grid") - - # Bottom: Process Inspector - with Container(classes="process-section"): - yield Static("TOP OFFENDER ANALYSIS", classes="section-title") - with Horizontal(id="process-control-box"): - yield Static(id="top-process-info", classes="process-info") - with Vertical(id="process-actions"): - yield Button("KILL PID [K]", id="btn-kill", variant="error") - with Horizontal(): - yield Button("NICE + [+] ", id="btn-renice-up", variant="warning") - yield Button("NICE - [-] ", id="btn-renice-down", variant="success") - def on_button_pressed(self, event: Button.Pressed): """Handle process control buttons.""" if event.button.id == "btn-kill": @@ -82,216 +53,224 @@ def on_button_pressed(self, event: Button.Pressed): elif event.button.id == "btn-renice-down": self.request_renice(self.top_pid, -1) - def update_transcendence(self, screen): - """Update the interactive transcendence view.""" - # 1. Update Header - try: - avg = self.aggregate_history[-1] if self.aggregate_history else 0 - freq = psutil.cpu_freq() - freq_str = f"{freq.current:.0f} MHz" if freq else "?" - - # Get Model Name (best effort) - import platform - model = platform.processor() or "Unknown CPU" - - header = Text() - header.append(f"CPU LOAD: {avg:3.0f}% ", style="bold " + value_to_heat_color(avg)) - header.append(make_bar(avg, 30, 20), style=value_to_heat_color(avg)) - header.append(f" FREQ: {freq_str} ", style="cyan") - header.append(f"MODEL: {model[:20]}...", style="dim") - screen.query_one("#cpu-hero-header", Static).update(header) - except: - pass # Widget might not be ready - - # 2. Update Core Grid (Visual) PLUS Kernel Stats - try: - # We'll use a dense block view - grid = Text() - percentages = core.get_cpu_percents() # This calls direct_os.get_cpu_percents which handles delta logic - - # Add Kernel Stats at top of grid - stats = psutil.cpu_stats() - grid.append(f"CTX SWITCH: {stats.ctx_switches:,} | INTERRUPTS: {stats.interrupts:,} | SYSCALLS: {stats.syscalls:,}\n\n", style="dim green") - - # Split into rows of 8 - row_len = 8 - for i, pct in enumerate(percentages): - color = value_to_heat_color(pct) - # Use a larger block for visual impact - block = "███" if pct > 80 else "███" if pct > 40 else "███" - val_str = f"{pct:3.0f}%" - - grid.append(f" CORE {i:02} ", style="dim white") - grid.append(f"{val_str} ", style=color) - grid.append(block, style=color) - grid.append(" ") - - if (i + 1) % 4 == 0: - grid.append("\n\n") - - screen.query_one("#cpu-core-grid", Static).update(grid) - except: - pass - - # 3. Update Top Process (Heuristic: Max CPU) - try: - procs = core.get_process_list(sort_by='cpu', limit=1) - if procs: - top = procs[0] - # Tracked for display; the action itself names this process in a - # confirmation before doing anything to it. - self.top_pid = top['pid'] - - info = Text() - info.append(f"PID: {top['pid']}\n", style="bold yellow") - info.append(f"NAME: {top['name']}\n", style="bold white") - info.append(f"CPU: {top['cpu_percent']:.1f}%\n", style="red") - info.append("Actions below apply to this process.", style="dim") - - screen.query_one("#top-process-info", Static).update(info) - else: - self.top_pid = None - screen.query_one("#top-process-info", Static).update("No active processes identified.") - except Exception: - pass - + # ------------------------------------------------------------------ + # Summary view + # ------------------------------------------------------------------ def update_data(self): - # Use Direct OS engine for CPU data - percentages = core.get_cpu_percents() - - # Store history - for i, pct in enumerate(percentages): - if i < len(self.per_core_history): - self.per_core_history[i].append(pct) - - # Summary View: Heat Map Blocks + snapshot = self.snapshot + if snapshot is None: + self.update(self.waiting_text()) + return + + cpu = snapshot.cpu + text = Text() text.append("CPU ", style="cyan") - for i, pct in enumerate(percentages): - color = value_to_heat_color(pct) - block = "█" if pct > 50 else "▓" if pct > 25 else "░" + for index, percent in enumerate(cpu.per_core): + color = value_to_heat_color(percent) + block = "█" if percent > 50 else "▓" if percent > 25 else "░" text.append(block, style=color) # Wrap every 8 cores for cleaner sidebar fit - if (i + 1) % 8 == 0: + if (index + 1) % 8 == 0: text.append("\n ") - elif (i + 1) % 4 == 0: + elif (index + 1) % 4 == 0: text.append(" ") - - avg = sum(percentages) / len(percentages) if percentages else 0 - self.aggregate_history.append(avg) - text.append(f"\n{avg:.0f}% avg", style=value_to_heat_color(avg)) + + average = cpu.percent + text.append(f"\n{average:.0f}% avg", style=value_to_heat_color(average)) self.update(text) - + # Critical Alert - if avg > 90: + if average > 90: self.add_class("alarm") self.border_title = "CPU CRITICAL" else: self.remove_class("alarm") - # Don't reset styles.border/color here, let apply_theme handle standard state self.border_title = "CPU CORES" - + + # ------------------------------------------------------------------ + # Transcendence + # ------------------------------------------------------------------ + def compose_transcendence(self): + """Compose the interactive Core Management Console.""" + with Container(id="cpu-transcendence-layout"): + with Horizontal(classes="header-section"): + yield Static(id="cpu-hero-header") + + with Container(classes="core-section"): + yield Static("CORE ARCHITECTURE", classes="section-title") + yield Static(id="cpu-core-grid") + + with Container(classes="process-section"): + yield Static("TOP OFFENDER ANALYSIS", classes="section-title") + with Horizontal(id="process-control-box"): + yield Static(id="top-process-info", classes="process-info") + with Vertical(id="process-actions"): + yield Button("KILL PID [K]", id="btn-kill", variant="error") + with Horizontal(): + yield Button("NICE + [+] ", id="btn-renice-up", variant="warning") + yield Button("NICE - [-] ", id="btn-renice-down", variant="success") + + def update_transcendence(self, screen): + """Update the interactive transcendence view.""" + snapshot = self.snapshot + if snapshot is None: + return + + cpu = snapshot.cpu + + # 1. Header + header = Text() + average = cpu.percent + header.append(f"CPU LOAD: {average:3.0f}% ", + style="bold " + value_to_heat_color(average)) + header.append(make_bar(average, 100, 20), style=value_to_heat_color(average)) + frequency = f"{cpu.frequency_mhz:.0f} MHz" if cpu.frequency_mhz else "?" + header.append(f" FREQ: {frequency} ", style="cyan") + header.append(f"CORES: {cpu.core_count}", style="dim") + screen.query_one("#cpu-hero-header", Static).update(header) + + # 2. Core grid, with kernel counters above it + grid = Text() + if cpu.context_switches is not None: + syscalls = f" | SYSCALLS: {cpu.syscalls:,}" if cpu.syscalls else "" + grid.append( + f"CTX SWITCH: {cpu.context_switches:,} | " + f"INTERRUPTS: {cpu.interrupts:,}{syscalls}\n\n", + style="dim green", + ) + + for index, percent in enumerate(cpu.per_core): + color = value_to_heat_color(percent) + grid.append(f" CORE {index:02} ", style="dim white") + grid.append(f"{percent:3.0f}% ", style=color) + grid.append("███", style=color) + grid.append(" ") + if (index + 1) % 4 == 0: + grid.append("\n\n") + + screen.query_one("#cpu-core-grid", Static).update(grid) + + # 3. Top offender + top = snapshot.top_processes("cpu", limit=1) + info_widget = screen.query_one("#top-process-info", Static) + if top: + process = top[0] + self.top_pid = process.pid + + info = Text() + info.append(f"PID: {process.pid}\n", style="bold yellow") + info.append(f"NAME: {process.name}\n", style="bold white") + info.append(f"CPU: {process.cpu_percent:.1f}%\n", style="red") + info.append("Actions below apply to this process.", style="dim") + info_widget.update(info) + else: + self.top_pid = None + info_widget.update(Text("No active processes identified.", style="dim")) + def get_transcendence_view(self) -> Text: - """Ultimate Telemetry Console with switchable modes.""" + """Text fallback for the full-screen view.""" + snapshot = self.snapshot + if snapshot is None: + return self.waiting_text() + + cpu = snapshot.cpu + average = cpu.percent + color = value_to_heat_color(average) + text = Text() - avg = self.aggregate_history[-1] if self.aggregate_history else 0 - color = value_to_heat_color(avg) - - # --- HERO HEADER --- - text.append(f"CPU CORE INFUSION ", style="bold") - text.append(f"[{self.view_mode.upper()} MODE] ", style="cyan") - text.append(f"{'⚡ HIGH-RES' if self.sampling_rate < 0.5 else ''}\n", style="yellow") - - text.append(f"LOAD: {avg:3.0f}% ", style=color) - text.append(make_bar(avg, 100, 20), style=color) + text.append("CPU CORE INFUSION ", style="bold") + text.append(f"[{self.view_mode.upper()} MODE]\n", style="cyan") + text.append(f"LOAD: {average:3.0f}% ", style=color) + text.append(make_bar(average, 100, 20), style=color) text.append("\n") if self.view_mode == "cinematic": - # Massive Waveform Focus - text.append("\nPERFORMANCE WAVEFORM (80s)\n", style="cyan") - for val in self.aggregate_history: - text.append(value_to_spark(val), style=value_to_heat_color(val)) + text.append("\nPERFORMANCE WAVEFORM\n", style="cyan") + for value in self.history.cpu: + text.append(value_to_spark(value), style=value_to_heat_color(value)) + text.append("\n\nCORE HEAT MAP\n", style="cyan") - cols = 4 - rows = (self.core_count + cols - 1) // cols - for r in range(rows): - for c in range(cols): - idx = r + c * rows - if idx < self.core_count: - pct = self.per_core_history[idx][-1] if self.per_core_history[idx] else 0 - c_color = value_to_heat_color(pct) - text.append(f"C{idx:02} ", style="dim") - text.append("█" if pct > 50 else "▓" if pct > 20 else "░", style=c_color) + columns = 4 + rows = (cpu.core_count + columns - 1) // columns + for row in range(rows): + for column in range(columns): + index = row + column * rows + if index < cpu.core_count: + percent = cpu.per_core[index] + text.append(f"C{index:02} ", style="dim") + text.append( + "█" if percent > 50 else "▓" if percent > 20 else "░", + style=value_to_heat_color(percent), + ) text.append(" ") text.append("\n") else: - # Developer Focus (The raw telemetry we added before) - text.append("\n80s PULSE: ", style="dim") - for val in list(self.aggregate_history)[-30:]: - text.append(value_to_spark(val), style=value_to_heat_color(val)) - - text.append("\n\nSTATE BREAKDOWN\n", style="cyan") - try: - core_times = psutil.cpu_times_percent(percpu=True) - core_pcts = psutil.cpu_percent(percpu=True) - half = (self.core_count + 1) // 2 - for i in range(half): - for col in [i, i + half]: - if col < self.core_count: - t, p = core_times[col], core_pcts[col] - c = value_to_heat_color(p) - text.append(f"C{col:02} {p:3.0f}% ", style=c) - u, s = int(t.user/10), int(t.system/10) - text.append("[" + "█"*u + "▒"*s + "░"*(10-u-s) + "] ", style=c) - text.append("\n") - except: - text.append("Developer mode restricted\n", style="red") + text.append("\nPULSE: ", style="dim") + for value in list(self.history.cpu)[-30:]: + text.append(value_to_spark(value), style=value_to_heat_color(value)) + + text.append("\n\nPER-CORE LOAD\n", style="cyan") + half = (cpu.core_count + 1) // 2 + for i in range(half): + for index in (i, i + half): + if index < cpu.core_count: + percent = cpu.per_core[index] + color = value_to_heat_color(percent) + text.append(f"C{index:02} {percent:3.0f}% ", style=color) + filled = int(percent / 10) + text.append("[" + "█" * filled + "░" * (10 - filled) + "] ", + style=color) + text.append("\n") text.append("\nKERNEL TELEMETRY\n", style="cyan") - try: - stats = psutil.cpu_stats() - text.append(f" Switches: {stats.ctx_switches:,} Interrupts: {stats.interrupts:,} Syscalls: {stats.syscalls:,}\n", style="dim") - load = psutil.getloadavg() - text.append(f" Load Avg: {load[0]:.2f} / {load[1]:.2f} / {load[2]:.2f}\n", style="cyan") - except: - pass + if cpu.context_switches is not None: + text.append(f" Switches: {cpu.context_switches:,} " + f"Interrupts: {cpu.interrupts:,}\n", style="dim") + if cpu.load_average: + one, five, fifteen = cpu.load_average + text.append(f" Load Avg: {one:.2f} / {five:.2f} / {fifteen:.2f}\n", + style="cyan") return text + # ------------------------------------------------------------------ + # Detail view (shown in the centre panel when focused) + # ------------------------------------------------------------------ def get_detailed_view(self) -> Text: """Detailed CPU view with 2-column performance matrix.""" + snapshot = self.snapshot + if snapshot is None: + return self.waiting_text() + + cpu = snapshot.cpu + text = Text() text.append("🔥 CORE PERFORMANCE MATRIX\n\n", style="bold") - - try: - freq = psutil.cpu_freq() - freq_str = f"{freq.current:.0f} MHz" if freq else "?" - load_avg = psutil.getloadavg() - except: - freq_str = "?" - load_avg = (0.0, 0.0, 0.0) - - text.append(f"THREADS: {self.core_count} ", style="dim") - text.append(f"CLOCK: {freq_str} ", style="cyan") - text.append(f"LOAD: {load_avg}\n\n", style="dim") - - # Render in 2 columns for high density - half = (self.core_count + 1) // 2 + + frequency = f"{cpu.frequency_mhz:.0f} MHz" if cpu.frequency_mhz else "?" + text.append(f"THREADS: {cpu.core_count} ", style="dim") + text.append(f"CLOCK: {frequency} ", style="cyan") + if cpu.load_average: + one, five, fifteen = cpu.load_average + text.append(f"LOAD: {one:.2f} {five:.2f} {fifteen:.2f}\n\n", style="dim") + else: + text.append("\n\n", style="dim") + + half = (cpu.core_count + 1) // 2 for i in range(half): - # Left Column - latest_l = self.per_core_history[i][-1] if self.per_core_history[i] else 0 - color_l = value_to_heat_color(latest_l) - text.append(f"C{i:02} {latest_l:3.0f}% ", style=color_l) - text.append(value_to_spark(latest_l), style=color_l) - - # Right Column - idx_r = i + half - if idx_r < self.core_count: - latest_r = self.per_core_history[idx_r][-1] if self.per_core_history[idx_r] else 0 - color_r = value_to_heat_color(latest_r) - text.append(f" C{idx_r:02} {latest_r:3.0f}% ", style=color_r) - text.append(value_to_spark(latest_r), style=color_r) - + left = cpu.per_core[i] + color = value_to_heat_color(left) + text.append(f"C{i:02} {left:3.0f}% ", style=color) + text.append(value_to_spark(left), style=color) + + right_index = i + half + if right_index < cpu.core_count: + right = cpu.per_core[right_index] + color = value_to_heat_color(right) + text.append(f" C{right_index:02} {right:3.0f}% ", style=color) + text.append(value_to_spark(right), style=color) + text.append("\n") return text diff --git a/src/pulse/panels/disk.py b/src/pulse/panels/disk.py index 692d324..b8ea612 100644 --- a/src/pulse/panels/disk.py +++ b/src/pulse/panels/disk.py @@ -1,5 +1,3 @@ -from collections import deque -import psutil from rich.text import Text from pulse.panels.base import Panel @@ -9,225 +7,174 @@ from textual.containers import Container, Horizontal from textual.binding import Binding +GIB = 1024 ** 3 +# Reference ceiling for the activity bars, in MB/s. +BAR_SCALE_MBPS = 50.0 + + class DiskIOPanel(Panel): """Disk I/O waveform showing read/write activity.""" - + PANEL_NAME = "DISK I/O" BINDINGS = [ Binding("r", "refresh_stats", "Refresh", priority=True) ] - + def __init__(self): super().__init__("DISK I/O", "", id="disk-panel") - self.read_history = deque(maxlen=80) - self.write_history = deque(maxlen=80) - try: - self.last_io = psutil.disk_io_counters() - except: - self.last_io = None - - # Per-disk state for transcendence rate calc - self.last_per_disk_io = {} - - self.current_read_lat = 0.0 - self.current_write_lat = 0.0 - - # Transcendence Control States self.sampling_rate = 1.0 - self.view_mode = "developer" # cinematic / developer - self.scaling_mode = "auto" # auto / absolute - + self.view_mode = "developer" # cinematic / developer + self.scaling_mode = "auto" # auto / absolute + def action_refresh_stats(self): - """Force refresh.""" - self.refresh_content(force=True) - self.notify("Refreshing Disk Stats...") - - def refresh_content(self, force=False): - if hasattr(self.app.screen, "query_one"): - try: - self.update_transcendence(self.app.screen) - except: pass - + self.app.refresh_data() + self.notify("Refreshing disk stats...") + + def on_button_pressed(self, event: Button.Pressed): + if event.button.id == "btn_refresh": + self.action_refresh_stats() + + # ------------------------------------------------------------------ + # Summary view + # ------------------------------------------------------------------ def update_data(self): - try: - current = psutil.disk_io_counters() - except: + if self.snapshot is None: + self.update(self.waiting_text()) return - if not self.last_io: - self.last_io = current - return - - # Throughput - read_rate = (current.read_bytes - self.last_io.read_bytes) / (1024**2) - write_rate = (current.write_bytes - self.last_io.write_bytes) / (1024**2) - - # Latency (read_time/write_time are in ms in psutil) - delta_read_count = current.read_count - self.last_io.read_count - delta_write_count = current.write_count - self.last_io.write_count - delta_read_time = current.read_time - self.last_io.read_time - delta_write_time = current.write_time - self.last_io.write_time - - self.current_read_lat = delta_read_time / delta_read_count if delta_read_count > 0 else 0 - self.current_write_lat = delta_write_time / delta_write_count if delta_write_count > 0 else 0 - - self.last_io = current - - self.read_history.append(min(read_rate * 2, 100)) - self.write_history.append(min(write_rate * 2, 100)) - + rates = self.rates + text = Text() - # Summary with throughput + sparks text.append("R ", style="cyan") - text.append(f"{read_rate:4.1f}MB/s ", style="dim") - for val in list(self.read_history)[-15:]: - text.append(value_to_spark(val), style="cyan") - - # Latencies in summary - lat_color_r = value_to_heat_color(self.current_read_lat * 2) - text.append(f"\n {self.current_read_lat:4.1f}ms ", style=lat_color_r) - + text.append(f"{rates.disk_read_mbps:4.1f}MB/s ", style="dim") + for value in list(self.history.disk_read)[-15:]: + text.append(value_to_spark(value, BAR_SCALE_MBPS), style="cyan") + + read_latency_color = value_to_heat_color(rates.disk_read_latency_ms * 2) + text.append(f"\n {rates.disk_read_latency_ms:4.1f}ms ", style=read_latency_color) + text.append("\nW ", style="yellow") - text.append(f"{write_rate:4.1f}MB/s ", style="dim") - for val in list(self.write_history)[-15:]: - text.append(value_to_spark(val), style="yellow") - - lat_color_w = value_to_heat_color(self.current_write_lat * 2) - text.append(f"\n {self.current_write_lat:4.1f}ms ", style=lat_color_w) - + text.append(f"{rates.disk_write_mbps:4.1f}MB/s ", style="dim") + for value in list(self.history.disk_write)[-15:]: + text.append(value_to_spark(value, BAR_SCALE_MBPS), style="yellow") + + write_latency_color = value_to_heat_color(rates.disk_write_latency_ms * 2) + text.append(f"\n {rates.disk_write_latency_ms:4.1f}ms ", style=write_latency_color) + self.update(text) + # ------------------------------------------------------------------ + # Transcendence + # ------------------------------------------------------------------ def compose_transcendence(self): """Interactive Disk I/O Matrix.""" with Container(id="disk-transcendence-layout"): with Horizontal(classes="header-section"): yield Static(id="disk-hero-header") - + yield DataTable(id="disk_table", cursor_type="row", zebra_stripes=True) - + with Horizontal(classes="footer-section", id="disk-actions"): yield Button("REFRESH [R]", id="btn_refresh", variant="primary") yield Static(" Monitoring Real-Time I/O Activity", classes="status-text") - - def on_button_pressed(self, event: Button.Pressed): - if event.button.id == "btn_refresh": - self.update_transcendence(self.app.screen) - self.notify("Refreshed I/O Stats") def update_transcendence(self, screen): - """Update Disk I/O Table.""" + """Update the per-device I/O table.""" + snapshot = self.snapshot + if snapshot is None: + return + table = screen.query_one("#disk_table", DataTable) header = screen.query_one("#disk-hero-header", Static) - - # Init Cols + if not table.columns: - table.add_columns("DEVICE", "MOUNT", "READ/s", "WRITE/s", "ACTIVITY", "TOTAL DATA") - - try: - io_counters = psutil.disk_io_counters(perdisk=True) - parts = {p.device: p.mountpoint for p in psutil.disk_partitions()} - - current_rows = set(table.rows.keys()) - - # Timestamp for rate calc - import time - now = time.time() - - for dev, io in io_counters.items(): - mount = parts.get(dev, parts.get(dev.replace('/dev/', ''), "?")) - - # Rate Calculation - r_rate_mb = 0.0 - w_rate_mb = 0.0 - - if dev in self.last_per_disk_io: - last_io, last_time = self.last_per_disk_io[dev] - dt = now - last_time - if dt > 0: - r_rate_mb = (io.read_bytes - last_io.read_bytes) / (1024**2) / dt - w_rate_mb = (io.write_bytes - last_io.write_bytes) / (1024**2) / dt - - # Update state - self.last_per_disk_io[dev] = (io, now) - - # Format Data - r_gb = io.read_bytes / (1024**3) - w_gb = io.write_bytes / (1024**3) - total_str = f"R:{r_gb:.1f}G W:{w_gb:.1f}G" - - # Activity Bar (Max 50 MB/s ref for visualization) - max_speed = 50.0 - activity_val = max(r_rate_mb, w_rate_mb) - bar_color = "green" if w_rate_mb < r_rate_mb else "yellow" - if activity_val > 100: bar_color = "red" - - bar = make_bar(min(activity_val, max_speed), max_speed, 20) - activity_cell = Text(f"{bar}", style=bar_color) - - # Styles - r_style = value_to_heat_color(min(r_rate_mb * 5, 100)) - w_style = value_to_heat_color(min(w_rate_mb * 5, 100)) - - row = [ - dev, - mount, - Text(f"{r_rate_mb:5.1f} MB/s", style=r_style), - Text(f"{w_rate_mb:5.1f} MB/s", style=w_style), - activity_cell, - Text(total_str, style="dim") - ] - - if dev in current_rows: - for i, val in enumerate(row): - key = list(table.columns.keys())[i] - table.update_cell(dev, key, val) - else: - table.add_row(*row, key=dev) - - # Update Header - header.update(f"DISK I/O MATRIX SCANNING {len(io_counters)} DEVICES") - - except Exception as e: - header.update(f"Disk Telemetry Error: {e}") + table.add_columns("DEVICE", "READ TOTAL", "WRITE TOTAL", "OPS (R/W)") + + per_disk = snapshot.disk_io.per_disk + column_keys = list(table.columns.keys()) + current_rows = set(table.rows.keys()) + + for device, counters in per_disk.items(): + row = [ + device, + Text(f"{counters.read_bytes / GIB:.2f} GB", style="cyan"), + Text(f"{counters.write_bytes / GIB:.2f} GB", style="yellow"), + Text(f"{counters.read_count:,} / {counters.write_count:,}", style="dim"), + ] + if device in current_rows: + # Updating in place keeps the cursor where the user put it. + for index, value in enumerate(row): + table.update_cell(device, column_keys[index], value) + else: + table.add_row(*row, key=device) + + for stale in current_rows - set(per_disk): + table.remove_row(stale) + + rates = self.rates + header.update( + f"DISK I/O MATRIX " + f"READ {rates.disk_read_mbps:5.1f} MB/s " + f"WRITE {rates.disk_write_mbps:5.1f} MB/s " + f"DEVICES {len(per_disk)}" + ) def get_transcendence_view(self) -> Text: - """Fallback text view (should not be reached if compose_transcendence works).""" - return Text("Interactive Mode Active") + """Text fallback for the full-screen view.""" + if self.snapshot is None: + return self.waiting_text() + return self.get_detailed_view() + # ------------------------------------------------------------------ + # Detail view + # ------------------------------------------------------------------ def get_detailed_view(self) -> Text: """High-res disk telemetry and response heatmap.""" + snapshot = self.snapshot + if snapshot is None: + return self.waiting_text() + + disk = snapshot.disk_io + rates = self.rates + text = Text() text.append("💿 DISK TELEMETRY\n\n", style="bold") - - try: - io = psutil.disk_io_counters() - except: - return Text("Disk telemetry unavailable") - - # Waveforms - text.append("Throughput Waves (Last 40s)\n", style="cyan") + + text.append("Throughput Waves\n", style="cyan") text.append(" READ ", style="cyan") - for val in list(self.read_history)[-40:]: - text.append(value_to_spark(val), style="cyan") + for value in list(self.history.disk_read)[-40:]: + text.append(value_to_spark(value, BAR_SCALE_MBPS), style="cyan") text.append("\n WRITE ", style="yellow") - for val in list(self.write_history)[-40:]: - text.append(value_to_spark(val), style="yellow") + for value in list(self.history.disk_write)[-40:]: + text.append(value_to_spark(value, BAR_SCALE_MBPS), style="yellow") text.append("\n\n") - # Response Latency Map - text.append("Response Latency Map\n", style="cyan") - lat_r_style = value_to_heat_color(self.current_read_lat * 2) - lat_w_style = value_to_heat_color(self.current_write_lat * 2) - - text.append(f" READ [", style="dim") - text.append(f"{self.current_read_lat:6.2f} ms", style=lat_r_style) - text.append("] " + make_bar(min(self.current_read_lat, 50), 50, 15) + "\n", style=lat_r_style) - - text.append(f" WRITE [", style="dim") - text.append(f"{self.current_write_lat:6.2f} ms", style=lat_w_style) - text.append("] " + make_bar(min(self.current_write_lat, 50), 50, 15) + "\n", style=lat_w_style) + text.append("Current Throughput\n", style="cyan") + text.append(f" READ {rates.disk_read_mbps:6.2f} MB/s ", style="cyan") + text.append(make_bar(min(rates.disk_read_mbps, BAR_SCALE_MBPS), + BAR_SCALE_MBPS, 15) + "\n", style="cyan") + text.append(f" WRITE {rates.disk_write_mbps:6.2f} MB/s ", style="yellow") + text.append(make_bar(min(rates.disk_write_mbps, BAR_SCALE_MBPS), + BAR_SCALE_MBPS, 15) + "\n", style="yellow") + + text.append("\nResponse Latency Map\n", style="cyan") + read_color = value_to_heat_color(rates.disk_read_latency_ms * 2) + write_color = value_to_heat_color(rates.disk_write_latency_ms * 2) + + text.append(" READ [", style="dim") + text.append(f"{rates.disk_read_latency_ms:6.2f} ms", style=read_color) + text.append("] " + make_bar(min(rates.disk_read_latency_ms, 50), 50, 15) + "\n", + style=read_color) + + text.append(" WRITE [", style="dim") + text.append(f"{rates.disk_write_latency_ms:6.2f} ms", style=write_color) + text.append("] " + make_bar(min(rates.disk_write_latency_ms, 50), 50, 15) + "\n", + style=write_color) text.append("\nSession Stats\n", style="cyan") - text.append(f" Total Data: Read {io.read_bytes/(1024**3):.2f}GB / Write {io.write_bytes/(1024**3):.2f}GB\n", style="dim") - + text.append(f" Total Data: Read {disk.read_bytes / GIB:.2f}GB / " + f"Write {disk.write_bytes / GIB:.2f}GB\n", style="dim") + text.append(f" Operations: {disk.read_count:,} reads / " + f"{disk.write_count:,} writes\n", style="dim") + return text diff --git a/src/pulse/panels/docker.py b/src/pulse/panels/docker.py index 88a1882..cc40055 100644 --- a/src/pulse/panels/docker.py +++ b/src/pulse/panels/docker.py @@ -1,94 +1,91 @@ from rich.text import Text from textual.app import ComposeResult -from textual.widgets import DataTable, Button, Label +from textual.widgets import DataTable, Label from textual.containers import Vertical from pulse.panels.base import Panel from pulse.container_api import ContainerController + class DockerPanel(Panel): + """Docker container monitor. + + Summary counts in the grid, full management in the full-screen view. + Container data arrives through the shared snapshot; the controller is only + used for the state-changing operations, which always confirm first. """ - Docker Container Monitor Panel. - Shows summary stats in grid, full interactive management in Transcendence. - """ - + PANEL_NAME = "Docker" - + BINDINGS = [ ("r", "restart_container", "Restart"), ("k", "stop_container", "Stop/Kill"), ("s", "start_container", "Start"), ] - def __init__(self, **kwargs): + def __init__(self, controller=None, **kwargs): super().__init__("DOCKER", **kwargs) - # The controller does not touch the Docker socket until something asks - # it to - access to that socket is root-equivalent on the host. - self.controller = ContainerController() - self.containers = [] - self.summary_stats = {"total": 0, "running": 0, "paused": 0, "stopped": 0} + # Does not touch the Docker socket until something asks it to - that + # socket is root-equivalent on the host. + self.controller = controller if controller is not None else ContainerController() self.table_widget = None + @property + def containers(self): + snapshot = self.snapshot + return snapshot.containers if snapshot else () + + # ------------------------------------------------------------------ + # Summary view + # ------------------------------------------------------------------ def update_data(self) -> None: - """Fetch latest container data.""" if not self.controller.available: self.update(self.controller.status_text()) self.border_title = "DOCKER [N/A]" return - if not self.controller.is_available(): + if not self.controller.connected: self.update(self.controller.status_text()) self.border_title = "DOCKER [OFFLINE]" return - self.containers = self.controller.get_containers() - - # Calculate summary - self.summary_stats = {"total": len(self.containers), "running": 0, "paused": 0, "stopped": 0} - for c in self.containers: - status = c["status"] - if status == "running": - self.summary_stats["running"] += 1 - elif status == "paused": - self.summary_stats["paused"] += 1 - else: - self.summary_stats["stopped"] += 1 - - # Update Grid View (Text) - status_text = ( - f"\n[bold green]● Running: {self.summary_stats['running']}[/]\n" - f"[bold yellow]● Paused: {self.summary_stats['paused']}[/]\n" - f"[bold red]● Stopped: {self.summary_stats['stopped']}[/]\n\n" - f"[bold blue]Total: {self.summary_stats['total']}[/]") - self.update(status_text) - self.border_title = f"DOCKER [{self.summary_stats['running']}/{self.summary_stats['total']}]" - - # Update Transcendence Table if active + containers = self.containers + running = sum(1 for c in containers if c.status == "running") + paused = sum(1 for c in containers if c.status == "paused") + stopped = len(containers) - running - paused + + self.update( + f"\n[bold green]● Running: {running}[/]\n" + f"[bold yellow]● Paused: {paused}[/]\n" + f"[bold red]● Stopped: {stopped}[/]\n\n" + f"[bold blue]Total: {len(containers)}[/]" + ) + self.border_title = f"DOCKER [{running}/{len(containers)}]" + if self.table_widget: self._refresh_table() def get_detailed_view(self) -> Text: - """Textual detail view for the main panel (sidebar preview).""" + """Sidebar preview.""" if not self.controller.connected: return Text(self.controller.status_text(), style="red") - + containers = self.containers text = Text() text.append("CONTAINERS\n", style="bold underline") - for c in self.containers[:10]: # Limit to 10 for preview - color = "green" if c["status"] == "running" else "red" - text.append(f"• {c['name'][:20]:<20} ", style=color) - text.append(f"[{c['status']}]\n", style="dim") - - if len(self.containers) > 10: - text.append(f"...and {len(self.containers)-10} more", style="italic") - + for container in containers[:10]: + color = "green" if container.status == "running" else "red" + text.append(f"• {container.name[:20]:<20} ", style=color) + text.append(f"[{container.status}]\n", style="dim") + + if len(containers) > 10: + text.append(f"...and {len(containers) - 10} more", style="italic") + return text - # ========================================================================= - # TRANSCENDENCE (IMMERSIVE MODE) - # ========================================================================= - + # ------------------------------------------------------------------ + # Transcendence + # ------------------------------------------------------------------ def compose_transcendence(self) -> ComposeResult: """Compose the interactive full-screen view.""" # First point where the user has actually asked to manage containers, @@ -99,42 +96,54 @@ def compose_transcendence(self) -> ComposeResult: return with Vertical(id="docker-transcendence"): - yield Label("[b]Container Operations:[/b] [green]Start (S)[/] | [red]Stop (K)[/] | [yellow]Restart (R)[/]", classes="header-section") - + yield Label( + "[b]Container Operations:[/b] [green]Start (S)[/] | " + "[red]Stop (K)[/] | [yellow]Restart (R)[/]", + classes="header-section", + ) + self.table_widget = DataTable(cursor_type="row") self.table_widget.add_columns("ID", "Name", "Image", "Status", "State") yield self.table_widget def _refresh_table(self): - """Update the data table without losing selection.""" - if not self.table_widget: return - - # Store selection - cursor_row = self.table_widget.cursor_row - - self.table_widget.clear() - - # Re-populate - for c in self.containers: - # Colorize status - status_style = "green" if c["status"] == "running" else "red" if c["status"] == "exited" else "yellow" - status_cell = Text(c["status"], style=status_style) - - self.table_widget.add_row( - c["id"], - c["name"], - c["image"], - status_cell, - c["state"], - key=c["id"] # Use ID as row key + """Update the table in place so the selected row does not jump.""" + table = self.table_widget + if not table: + return + + column_keys = list(table.columns.keys()) + current_rows = set(table.rows.keys()) + seen = set() + + for container in self.containers: + seen.add(container.id) + style = ( + "green" if container.status == "running" + else "red" if container.status == "exited" + else "yellow" ) - - # Restore selection if possible - if cursor_row < len(self.containers): - self.table_widget.move_cursor(row=cursor_row) + row = [ + container.id, + container.name, + container.image, + Text(container.status, style=style), + container.state, + ] + + if container.id in current_rows: + for index, value in enumerate(row): + table.update_cell(container.id, column_keys[index], value) + else: + table.add_row(*row, key=container.id) + + for stale in current_rows - seen: + table.remove_row(stale) + # ------------------------------------------------------------------ + # Actions + # ------------------------------------------------------------------ def _get_selected_id(self): - """Helper to get currently selected ID from table.""" if not self.table_widget: return None try: @@ -146,8 +155,8 @@ def _get_selected_id(self): def _container_name(self, container_id: str) -> str: for container in self.containers: - if container["id"] == container_id: - return f"{container['name']} ({container_id})" + if container.id == container_id: + return f"{container.name} ({container_id})" return container_id def _request(self, operation: str) -> None: @@ -156,7 +165,7 @@ def _request(self, operation: str) -> None: def run(): result = getattr(self.controller, f"{operation}_container")(container_id) - self.update_data() + self.app.refresh_data() return result self.request_container_action( diff --git a/src/pulse/panels/insight.py b/src/pulse/panels/insight.py index 524e315..5c12c58 100644 --- a/src/pulse/panels/insight.py +++ b/src/pulse/panels/insight.py @@ -1,183 +1,200 @@ -from datetime import datetime -from collections import deque -import psutil from rich.text import Text from pulse.panels.base import Panel from pulse.ui_utils import value_to_spark, value_to_heat_color, make_bar +def _format_duration(seconds: float) -> str: + total = int(max(0, seconds)) + hours, remainder = divmod(total, 3600) + minutes, secs = divmod(remainder, 60) + if hours: + return f"{hours}h {minutes}m" + if minutes: + return f"{minutes}m {secs}s" + return f"{secs}s" + + class InsightPanel(Panel): - """Proactive system intelligence and session analytics.""" - + """System pressure summary and session analytics. + + The "tension" score is a documented heuristic - a weighted blend of CPU, + memory and I/O saturation. It is not a model or a prediction. + """ + PANEL_NAME = "INTELLIGENCE" - + def __init__(self): super().__init__("INTELLIGENCE", "", id="insight-panel") - self.peaks = { - "cpu": 0.0, - "mem": 0.0, - "net_up": 0.0, - "net_down": 0.0, - "disk_lat": 0.0 - } - self.tension_score = 0 - self.history = deque(maxlen=80) - self.start_time = datetime.now() - + # Transcendence Control States - self.sampling_rate = 0.05 # Fast refresh for Aether (20 FPS) + self.sampling_rate = 0.05 # Fast refresh for the Aether visualisation self.view_mode = "aether" # aether / developer self.scaling_mode = "auto" - - # Aether Engine (lazy init) + + # Aether Engine (lazy init - it is pure animation, not telemetry) self._aether_engine = None self._aether_width = 80 self._aether_height = 24 - - # Animation state for "Neural" analysis - self.thought_idx = 0 - self.thoughts = [ - "Analyzing kernel scheduling patterns...", - "Correlating I/O wait times with memory paging...", - "Synthesizing network flow heuristics...", - "Detecting micro-stutters in frame timings...", - "Optimizing thermal dissipation models...", - "Predicting future resource contention..." - ] - + def _get_aether_engine(self): - """Lazy initialization of the Aether engine.""" if self._aether_engine is None: from pulse.aether.engine import AetherEngine self._aether_engine = AetherEngine(self._aether_width, self._aether_height) return self._aether_engine - + def set_aether_size(self, width: int, height: int): - """Set the Aether canvas size.""" self._aether_width = width self._aether_height = height if self._aether_engine: self._aether_engine.resize(width, height) + # ------------------------------------------------------------------ + # Summary view + # ------------------------------------------------------------------ + def update_data(self): + snapshot = self.snapshot + if snapshot is None: + self.update(self.waiting_text()) + return + + tension = self.store.tension + color = value_to_heat_color(tension) + + text = Text() + text.append("TENSION: ", style="dim") + text.append(f"{tension:.0f}%\n", style=color) + text.append(make_bar(tension, 100, 18) + "\n\n", style=color) + + text.append(f"» {self._advice(snapshot)}", style=self._advice_style(snapshot)) + self.update(text) + + def _advice(self, snapshot) -> str: + if snapshot.cpu.percent > 80: + return "Heavy Compute Load" + if snapshot.memory.percent > 90: + return "RAM Saturated" + if self.rates.disk_read_mbps + self.rates.disk_write_mbps > 40: + return "Heavy Disk Traffic" + return "System Stable" + + def _advice_style(self, snapshot) -> str: + if snapshot.memory.percent > 90: + return "red" + if snapshot.cpu.percent > 80: + return "orange1" + return "green" + + # ------------------------------------------------------------------ + # Transcendence + # ------------------------------------------------------------------ def get_transcendence_view(self) -> Text: - """Render the Aether visualization or developer stats.""" + """Render the Aether visualisation or the developer stats.""" text = Text() - + if self.view_mode == "aether": - # === AETHER MODE === engine = self._get_aether_engine() - - # Render a frame - frame = engine.render_frame() - status = engine.get_status_line() - atmosphere = engine.get_atmosphere_char() - - # Build the output - text.append(f" {atmosphere} ", style="bold cyan") - text.append(status, style="dim") - text.append(f" {atmosphere}\n\n", style="bold cyan") - text.append(frame, style="cyan") - - elif self.view_mode == "developer": - # Developer Mode: Session Statistics - text.append("NEURAL INSIGHT ENGINE ", style="bold") - text.append("[DEVELOPER MODE]\n", style="cyan") - - text.append("\nSESSION PEAK METRICS\n", style="cyan") - text.append(f" CPU Peak: {self.peaks['cpu']:>5.1f}%\n", style="cyan") - text.append(f" Memory Peak: {self.peaks['mem']:>5.1f}%\n", style="cyan") - - runtime = datetime.now() - self.start_time - text.append(f" Session Time: {str(runtime).split('.')[0]}\n", style="dim") - - text.append("\nNEURAL NETWORK TOPOLOGY\n", style="cyan") - text.append(" Tension Pulse Trace (80 samples)\n", style="dim") - for val in list(self.history): - text.append(value_to_spark(val), style=value_to_heat_color(val)) - text.append("\n") - - text.append("\nHEURISTIC WEIGHTS\n", style="cyan") - text.append(" Compute Importance: 40%\n", style="dim") - text.append(" Memory Weight: 40%\n", style="dim") - text.append(" I/O Bias: 20%\n", style="dim") + snapshot = self.snapshot + if snapshot is not None: + # The engine animates from the shared snapshot rather than + # sampling the machine itself. + engine.set_metrics( + cpu=snapshot.cpu.percent, + memory=snapshot.memory.percent, + io_intensity=min(1.0, (self.rates.disk_read_mbps + + self.rates.disk_write_mbps) / 50.0), + cpu_history=self.history.cpu, + ) + + text.append(f" {engine.get_atmosphere_char()} ", style="bold cyan") + text.append(engine.get_status_line(), style="dim") + text.append(f" {engine.get_atmosphere_char()}\n\n", style="bold cyan") + text.append(engine.render_frame(), style="cyan") + return text + + # Developer mode: session statistics + peaks = self.store.peaks + + text.append("SYSTEM INSIGHT ", style="bold") + text.append("[DEVELOPER MODE]\n", style="cyan") + + text.append("\nSESSION PEAK METRICS\n", style="cyan") + text.append(f" CPU Peak: {peaks.cpu:>5.1f}%\n", style="cyan") + text.append(f" Memory Peak: {peaks.memory:>5.1f}%\n", style="cyan") + text.append(f" Net Up Peak: {peaks.net_sent_kbps:>7.1f} KB/s\n", style="yellow") + text.append(f" Net Down Peak: {peaks.net_recv_kbps:>7.1f} KB/s\n", style="yellow") + text.append(f" Disk Read Peak: {peaks.disk_read_mbps:>7.2f} MB/s\n", style="green") + text.append(f" Disk Write Peak: {peaks.disk_write_mbps:>7.2f} MB/s\n", style="green") + + text.append(f"\n Session Time: {_format_duration(self.store.uptime_seconds)}\n", + style="dim") + text.append(f" Ticks Sampled: {self.store.tick_count}\n", style="dim") + + text.append("\nTENSION TRACE\n", style="cyan") + for value in self.history.tension: + text.append(value_to_spark(value), style=value_to_heat_color(value)) + text.append("\n") + + text.append("\nHEURISTIC WEIGHTS\n", style="cyan") + text.append(" Compute: 40%\n", style="dim") + text.append(" Memory: 40%\n", style="dim") + text.append(" Disk I/O: 20%\n", style="dim") + + snapshot = self.snapshot + if snapshot is not None and snapshot.errors: + text.append("\nDEGRADED SUBSYSTEMS\n", style="red") + for name, message in snapshot.errors.items(): + text.append(f" {name}: {message}\n", style="red dim") return text - - def update_data(self): - try: - cpu = psutil.cpu_percent() - mem = psutil.virtual_memory().percent - except: - return - - # Update Peaks - self.peaks["cpu"] = max(self.peaks["cpu"], cpu) - self.peaks["mem"] = max(self.peaks["mem"], mem) - - # Calculate Tension (0-100) - self.tension_score = (cpu * 0.4 + mem * 0.4 + 20) - self.tension_score = min(100, self.tension_score) - self.history.append(self.tension_score) - - text = Text() - color = value_to_heat_color(self.tension_score) - - # Summary View - text.append("TENSION: ", style="dim") - text.append(f"{self.tension_score:.0f}%\n", style=color) - text.append(make_bar(self.tension_score, 100, 18) + "\n\n", style=color) - - # Advice - advice = "System Stable" - advice_style = "green" - if cpu > 80: - advice = "Heavy Compute Load" - advice_style = "orange1" - elif mem > 90: - advice = "RAM Saturated" - advice_style = "red" - - text.append(f"» {advice}", style=advice_style) - self.update(text) - + + # ------------------------------------------------------------------ + # Detail view + # ------------------------------------------------------------------ def get_detailed_view(self) -> Text: - """Deep analytics with tension waveforms and strains.""" + """Deep analytics with tension waveform and current strains.""" + snapshot = self.snapshot + if snapshot is None: + return self.waiting_text() + + tension = self.store.tension + peaks = self.store.peaks + text = Text() text.append("🧠 SYSTEM INTELLIGENCE\n\n", style="bold") - - # Tension Waveform - text.append("Session Tension Pulse (Last 40s)\n", style="cyan") - for val in self.history: - text.append(value_to_spark(val), style=value_to_heat_color(val)) - text.append(f" {self.tension_score:.0f}%\n\n", style=value_to_heat_color(self.tension_score)) - - # System Strains + + text.append("Session Tension Pulse\n", style="cyan") + for value in self.history.tension: + text.append(value_to_spark(value), style=value_to_heat_color(value)) + text.append(f" {tension:.0f}%\n\n", style=value_to_heat_color(tension)) + text.append("Primary Resource Strains\n", style="cyan") - try: - cpu = psutil.cpu_percent() - mem = psutil.virtual_memory().percent - except: - cpu, mem = 0, 0 - strain_found = False - if cpu > 70: - text.append(f" [!] COMPUTE: High CPU Pressure ({cpu:.0f}%)\n", style="orange1") + if snapshot.cpu.percent > 70: + text.append(f" [!] COMPUTE: High CPU Pressure ({snapshot.cpu.percent:.0f}%)\n", + style="orange1") strain_found = True - if mem > 85: - text.append(f" [!] MEMORY: RAM Saturated ({mem:.0f}%)\n", style="red") + if snapshot.memory.percent > 85: + text.append(f" [!] MEMORY: RAM Saturated ({snapshot.memory.percent:.0f}%)\n", + style="red") strain_found = True - + if self.rates.disk_read_mbps + self.rates.disk_write_mbps > 40: + text.append(" [!] STORAGE: Heavy disk traffic\n", style="orange1") + strain_found = True + if snapshot.errors: + text.append(f" [!] TELEMETRY: {len(snapshot.errors)} subsystem(s) degraded\n", + style="red") + strain_found = True + if not strain_found: - text.append(" [✓] All subsystems operating within nominal range.\n", style="green") + text.append(" [✓] All subsystems operating within nominal range.\n", + style="green") - # Session Peaks text.append("\nSession Peaks\n", style="cyan") - text.append(f" CPU Peak: {self.peaks['cpu']:5.1f}%\n", style="dim") - text.append(f" Memory Peak: {self.peaks['mem']:5.1f}%\n", style="dim") - - runtime = datetime.now() - self.start_time - mins = int(runtime.total_seconds() // 60) - text.append(f"\nMonitoring Session: {mins} minutes active\n", style="dim") - + text.append(f" CPU Peak: {peaks.cpu:5.1f}%\n", style="dim") + text.append(f" Memory Peak: {peaks.memory:5.1f}%\n", style="dim") + + text.append(f"\nMonitoring Session: {_format_duration(self.store.uptime_seconds)}\n", + style="dim") + return text diff --git a/src/pulse/panels/main_view.py b/src/pulse/panels/main_view.py index b9c5986..e69d766 100644 --- a/src/pulse/panels/main_view.py +++ b/src/pulse/panels/main_view.py @@ -1,128 +1,148 @@ -from datetime import datetime -import psutil +from datetime import datetime, timedelta + from rich.text import Text from pulse.panels.base import Panel from pulse.ui_utils import value_to_heat_color, make_bar +GIB = 1024 ** 3 + + +def _format_duration(seconds: float) -> str: + """Human-readable uptime, e.g. '3d 4h 12m'.""" + delta = timedelta(seconds=max(0, int(seconds))) + days = delta.days + hours, remainder = divmod(delta.seconds, 3600) + minutes = remainder // 60 + if days: + return f"{days}d {hours}h {minutes}m" + return f"{hours}h {minutes}m" + + class MainViewPanel(Panel): """The large central panel showing system overview or focused panel details.""" - + PANEL_NAME = "SYSTEM" - + def __init__(self): super().__init__("SYSTEM", "", id="main-panel") - self.focused_panel = None # Will be set by app when another panel is focused - + self.focused_panel = None # Set by the app when another panel is focused + + def _root_volume(self): + """The volume the OS lives on, for the summary line.""" + snapshot = self.snapshot + if snapshot is None or not snapshot.volumes: + return None + for volume in snapshot.volumes: + if volume.mountpoint in ("/", "C:\\"): + return volume + return snapshot.volumes[0] + def update_data(self): - # If another panel is focused, show its detailed view - if self.focused_panel and hasattr(self.focused_panel, 'get_detailed_view'): + # If another panel is focused, show its detailed view instead. + if self.focused_panel is not None: self.update(self.focused_panel.get_detailed_view()) self.border_title = f"◆ {self.focused_panel.PANEL_NAME}" return - - # Default: system overview + self.border_title = "SYSTEM" - try: - cpu = psutil.cpu_percent() - mem = psutil.virtual_memory() - disk = psutil.disk_usage('/') - boot = datetime.fromtimestamp(psutil.boot_time()) - except: + + snapshot = self.snapshot + if snapshot is None: + self.update(self.waiting_text()) return - - uptime = datetime.now() - boot - hours = int(uptime.total_seconds() // 3600) - mins = int((uptime.total_seconds() % 3600) // 60) - + text = Text() + + cpu = snapshot.cpu.percent text.append("CPU ", style="bold") text.append(f"{cpu:5.1f}% ", style=value_to_heat_color(cpu)) text.append(make_bar(cpu, 100, 10) + "\n", style=value_to_heat_color(cpu)) - + + memory = snapshot.memory.percent text.append("RAM ", style="bold") - text.append(f"{mem.percent:5.1f}% ", style=value_to_heat_color(mem.percent)) - text.append(make_bar(mem.percent, 100, 10) + "\n", style=value_to_heat_color(mem.percent)) - - text.append("DISK ", style="bold") - text.append(f"{disk.percent:5.1f}% ", style=value_to_heat_color(disk.percent)) - text.append(make_bar(disk.percent, 100, 10) + "\n", style=value_to_heat_color(disk.percent)) - - text.append(f"\n⏱ Up {hours}h {mins}m\n", style="dim") - - # Add Platform Hint if space - import platform - sys_name = platform.system() - text.append(f"{sys_name}", style="dim") - + text.append(f"{memory:5.1f}% ", style=value_to_heat_color(memory)) + text.append(make_bar(memory, 100, 10) + "\n", style=value_to_heat_color(memory)) + + volume = self._root_volume() + if volume is not None: + text.append("DISK ", style="bold") + text.append(f"{volume.percent:5.1f}% ", style=value_to_heat_color(volume.percent)) + text.append(make_bar(volume.percent, 100, 10) + "\n", + style=value_to_heat_color(volume.percent)) + + if snapshot.system.boot_time: + uptime = datetime.now().timestamp() - snapshot.system.boot_time + text.append(f"\n⏱ Up {_format_duration(uptime)}\n", style="dim") + + text.append(snapshot.system.platform_name, style="dim") + self.update(text) - + def get_transcendence_view(self) -> Text: - """Ultimate System Dashboard.""" + """Full-screen system dashboard.""" + snapshot = self.snapshot + if snapshot is None: + return self.waiting_text() + + system = snapshot.system + text = Text() text.append("SYSTEM OVERVIEW\n", style="bold") text.append(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}]\n\n", style="dim") - try: - # OS Info - import platform - uname = platform.uname() - text.append("PLATFORM DETAILS\n", style="cyan") - text.append(f" OS: {uname.system} {uname.release} ({uname.version})\n", style="white") - text.append(f" Node: {uname.node}\n", style="dim") - text.append(f" Arch: {uname.machine}\n", style="dim") - text.append(f" Proc: {uname.processor}\n\n", style="dim") - - # Uptime - boot = datetime.fromtimestamp(psutil.boot_time()) - uptime = datetime.now() - boot + text.append("PLATFORM DETAILS\n", style="cyan") + text.append(f" OS: {system.platform_name} {system.platform_release} " + f"({system.platform_version})\n", style="white") + text.append(f" Node: {system.hostname}\n", style="dim") + text.append(f" Arch: {system.architecture}\n", style="dim") + text.append(f" Proc: {system.processor}\n\n", style="dim") + + if system.boot_time: + boot = datetime.fromtimestamp(system.boot_time) + uptime = datetime.now().timestamp() - system.boot_time text.append("SESSION STATUS\n", style="cyan") text.append(f" Booted: {boot.strftime('%Y-%m-%d %H:%M:%S')}\n", style="dim") - text.append(f" Uptime: {str(uptime).split('.')[0]}\n\n", style="green") - - # Hardware Summary - text.append("RESOURCE UTILIZATION\n", style="cyan") - - # CPU - cpu = psutil.cpu_percent() - text.append(f" CPU: {cpu:5.1f}% ", style=value_to_heat_color(cpu)) - text.append(make_bar(cpu, 100, 20) + "\n", style=value_to_heat_color(cpu)) - - # MEM - mem = psutil.virtual_memory() - text.append(f" RAM: {mem.percent:5.1f}% ", style=value_to_heat_color(mem.percent)) - text.append(make_bar(mem.percent, 100, 20), style=value_to_heat_color(mem.percent)) - text.append(f" ({mem.used/1024**3:.1f}/{mem.total/1024**3:.1f} GB)\n", style="dim") - - # SWAP - swap = psutil.swap_memory() - text.append(f" SWAP: {swap.percent:5.1f}% ", style=value_to_heat_color(swap.percent)) - text.append(make_bar(swap.percent, 100, 20) + "\n", style=value_to_heat_color(swap.percent)) - - # DISK (Root) - disk = psutil.disk_usage('/') - text.append(f" DISK (/):{disk.percent:5.1f}% ", style=value_to_heat_color(disk.percent)) - text.append(make_bar(disk.percent, 100, 20) + "\n", style=value_to_heat_color(disk.percent)) - - # Battery (Laptop only) - if hasattr(psutil, "sensors_battery"): - batt = psutil.sensors_battery() - if batt: - text.append("\nPOWER STATUS\n", style="cyan") - plugged = "⚡ Plugged In" if batt.power_plugged else "🔋 On Battery" - color = "green" if batt.percent > 20 else "red" - text.append(f" Level: {batt.percent}% ({plugged})\n", style=color) - if not batt.power_plugged: - secs = batt.secsleft - if secs != psutil.POWER_TIME_UNLIMITED: - mins = secs // 60 - text.append(f" Est. Time: {mins//60}h {mins%60}m remaining\n", style="dim") - - except Exception as e: - text.append(f"\nError fetching system details: {e}", style="red") + text.append(f" Uptime: {_format_duration(uptime)}\n\n", style="green") + + text.append("RESOURCE UTILIZATION\n", style="cyan") + + cpu = snapshot.cpu.percent + text.append(f" CPU: {cpu:5.1f}% ", style=value_to_heat_color(cpu)) + text.append(make_bar(cpu, 100, 20) + "\n", style=value_to_heat_color(cpu)) + + memory = snapshot.memory + text.append(f" RAM: {memory.percent:5.1f}% ", + style=value_to_heat_color(memory.percent)) + text.append(make_bar(memory.percent, 100, 20), + style=value_to_heat_color(memory.percent)) + text.append(f" ({memory.used / GIB:.1f}/{memory.total / GIB:.1f} GB)\n", style="dim") + + swap = memory.swap_percent + text.append(f" SWAP: {swap:5.1f}% ", style=value_to_heat_color(swap)) + text.append(make_bar(swap, 100, 20) + "\n", style=value_to_heat_color(swap)) + + volume = self._root_volume() + if volume is not None: + label = volume.mountpoint[:8] + text.append(f" DISK ({label}):{volume.percent:5.1f}% ", + style=value_to_heat_color(volume.percent)) + text.append(make_bar(volume.percent, 100, 20) + "\n", + style=value_to_heat_color(volume.percent)) + + battery = system.battery + if battery is not None: + text.append("\nPOWER STATUS\n", style="cyan") + plugged = "⚡ Plugged In" if battery.power_plugged else "🔋 On Battery" + color = "green" if battery.percent > 20 else "red" + text.append(f" Level: {battery.percent:.0f}% ({plugged})\n", style=color) + if battery.seconds_left is not None: + minutes = battery.seconds_left // 60 + text.append(f" Est. Time: {minutes // 60}h {minutes % 60}m remaining\n", + style="dim") return text - + def get_detailed_view(self) -> Text: """This panel shows other panels' details, not its own.""" return Text("System Overview - Tab to other panels for details") diff --git a/src/pulse/panels/memory.py b/src/pulse/panels/memory.py index dbb6a20..ade9c8a 100644 --- a/src/pulse/panels/memory.py +++ b/src/pulse/panels/memory.py @@ -1,81 +1,47 @@ -from collections import deque -import psutil from rich.text import Text -from pulse import core from pulse.panels.base import Panel -from pulse.ui_utils import value_to_spark, value_to_heat_color +from pulse.ui_utils import value_to_spark, value_to_heat_color, make_bar from textual.containers import Container, Vertical, Horizontal from textual.widgets import Static, Button -# Adapter for Rust dict to psutil-like object -class MemAdapter: - def __init__(self, d, prefix=""): - self.total = d.get(f"{prefix}total", 0) - self.used = d.get(f"{prefix}used", 0) - self.free = d.get(f"{prefix}free", 0) - self.available = d.get("available", 0) - self.percent = (self.used / self.total * 100) if self.total > 0 else 0 +GIB = 1024 ** 3 + class MemoryPanel(Panel): """Shows memory usage as a live pressure bar.""" - + PANEL_NAME = "MEMORY" BINDINGS = [ ("k", "kill_process", "Kill Top Process"), ("plus", "renice_up", "Lower Priority"), ("minus", "renice_down", "Higher Priority"), ] - + def __init__(self): super().__init__("MEMORY", "", id="memory-panel") - self.history = deque(maxlen=80) # Transcendence Control States self.sampling_rate = 1.0 - self.view_mode = "developer" # cinematic / developer + self.view_mode = "developer" # cinematic / developer # The heaviest process currently on display. Actions name it explicitly # and confirm before touching it - see Panel.request_kill. self.top_pid = None - core.init() - + # ------------------------------------------------------------------ + # Actions + # ------------------------------------------------------------------ def action_kill_process(self): """Terminate the displayed top process, after confirmation.""" self.request_kill(self.top_pid) def action_renice_up(self): - """Increase nice value (lower priority).""" self.request_renice(self.top_pid, 1) def action_renice_down(self): - """Decrease nice value (higher priority).""" self.request_renice(self.top_pid, -1) - def compose_transcendence(self): - """Compose the interactive Memory Management Console.""" - with Container(id="mem-transcendence-layout"): - # Top Section: Header & Stats - with Horizontal(classes="header-section"): - yield Static(id="mem-hero-header") - - # Middle: Memory Map - with Container(classes="core-section"): - yield Static("ALLOCATION MAP (PHYSICAL vs SWAP)", classes="section-title") - yield Static(id="mem-allocation-map") - - # Bottom: Process Inspector - with Container(classes="process-section"): - yield Static("TOP MEMORY OFFENDER", classes="section-title") - with Horizontal(id="process-control-box"): - yield Static(id="mem-top-process-info", classes="process-info") - with Vertical(id="process-actions"): - yield Button("KILL PID [K]", id="btn-kill", variant="error") - with Horizontal(): - yield Button("NICE + [+] ", id="btn-renice-up", variant="warning") - yield Button("NICE - [-] ", id="btn-renice-down", variant="success") - def on_button_pressed(self, event: Button.Pressed): """Handle process control buttons.""" if event.button.id == "btn-kill": @@ -85,216 +51,199 @@ def on_button_pressed(self, event: Button.Pressed): elif event.button.id == "btn-renice-down": self.request_renice(self.top_pid, -1) - def update_transcendence(self, screen): - """Update the interactive transcendence view.""" - self.update_data() # Ensure fresh data - - # 1. Update Header - try: - data = core.get_memory_info() - mem = MemAdapter(data) - swap = MemAdapter(data, prefix="swap_") - - header = Text() - header.append(f"MEM PRESSURE: {mem.percent:3.0f}% ", style="bold " + value_to_heat_color(mem.percent)) - from pulse.ui_utils import make_bar - header.append(make_bar(mem.percent, 30, 20), style=value_to_heat_color(mem.percent)) - header.append(f" SWAP: {swap.percent:.0f}% ", style="yellow") - header.append(f"TOTAL: {mem.total / (1024**3):.1f} GB", style="dim") - screen.query_one("#mem-hero-header", Static).update(header) - - # 2. Update Allocation Map - # Visual layout of physical vs swap - # 100 blocks total - total_vol = mem.total + swap.total - phys_blocks = int((mem.used / total_vol) * 100) if total_vol else 0 - swap_blocks = int((swap.used / total_vol) * 100) if total_vol else 0 - - amap = Text() - # Physical - amap.append("PHYSICAL RAM IN USE\n", style="cyan") - amap.append("█" * phys_blocks, style="cyan") - amap.append("░" * (50 - phys_blocks) if phys_blocks < 50 else "", style="dim cyan") - - amap.append("\n\nSWAP FILE COMMITTED\n", style="yellow") - amap.append("▓" * swap_blocks, style="yellow") - - amap.append("\n\nSTATS:\n", style="bold") - amap.append(f" Available: {mem.available / (1024**3):.2f} GB\n", style="green") - amap.append(f" Used: {mem.used / (1024**3):.2f} GB\n", style="cyan") - amap.append(f" Swap Used: {swap.used / (1024**3):.2f} GB", style="yellow") - - screen.query_one("#mem-allocation-map", Static).update(amap) - - except Exception: - pass # Guard against race conditions - - # 3. Update Top Memory Offender - try: - procs = core.get_process_list(sort_by='mem', limit=1) - if procs: - top = procs[0] - # Tracked for display; the action itself names this process in a - # confirmation before doing anything to it. - self.top_pid = top['pid'] - mem_mb = top['memory_info'] / (1024 * 1024) - - info = Text() - info.append(f"PID: {top['pid']}\n", style="bold yellow") - info.append(f"NAME: {top['name']}\n", style="bold white") - info.append(f"MEM: {mem_mb:.1f} MB\n", style="cyan") - info.append("Actions below apply to this process.", style="dim") - - screen.query_one("#mem-top-process-info", Static).update(info) - else: - self.top_pid = None - screen.query_one("#mem-top-process-info", Static).update("No active processes identified.") - except Exception: - pass - - + # ------------------------------------------------------------------ + # Summary view + # ------------------------------------------------------------------ def update_data(self): - try: - # Get memory info from Direct OS engine - data = core.get_memory_info() - mem = MemAdapter(data) - swap = MemAdapter(data, prefix="swap_") - used_gb = mem.used / (1024**3) - total_gb = mem.total / (1024**3) - pct = mem.percent - except Exception: + snapshot = self.snapshot + if snapshot is None: + self.update(self.waiting_text()) return - + + memory = snapshot.memory + percent = memory.percent + color = value_to_heat_color(percent) + text = Text() text.append("MEM ", style="cyan") bar_width = 12 - filled = int(pct / 100 * bar_width) - color = value_to_heat_color(pct) - - self.history.append(pct) - + filled = int(percent / 100 * bar_width) text.append("█" * filled, style=color) text.append("░" * (bar_width - filled), style="dim") - text.append(f"\n{used_gb:.1f}/{total_gb:.1f}GB", style=color) + text.append(f"\n{memory.used / GIB:.1f}/{memory.total / GIB:.1f}GB", style=color) self.update(text) - - # Critical Alert - if pct > 95: + + if percent > 95: self.add_class("alarm") - self.styles.border = ("heavy", "red") - self.styles.color = "red" self.border_title = "MEM CRITICAL" else: self.remove_class("alarm") self.border_title = "MEMORY" + # ------------------------------------------------------------------ + # Transcendence + # ------------------------------------------------------------------ + def compose_transcendence(self): + """Compose the interactive Memory Management Console.""" + with Container(id="mem-transcendence-layout"): + with Horizontal(classes="header-section"): + yield Static(id="mem-hero-header") + + with Container(classes="core-section"): + yield Static("ALLOCATION MAP (PHYSICAL vs SWAP)", classes="section-title") + yield Static(id="mem-allocation-map") + + with Container(classes="process-section"): + yield Static("TOP MEMORY OFFENDER", classes="section-title") + with Horizontal(id="process-control-box"): + yield Static(id="mem-top-process-info", classes="process-info") + with Vertical(id="process-actions"): + yield Button("KILL PID [K]", id="btn-kill", variant="error") + with Horizontal(): + yield Button("NICE + [+] ", id="btn-renice-up", variant="warning") + yield Button("NICE - [-] ", id="btn-renice-down", variant="success") + + def update_transcendence(self, screen): + """Update the interactive transcendence view.""" + snapshot = self.snapshot + if snapshot is None: + return + + memory = snapshot.memory + color = value_to_heat_color(memory.percent) + + header = Text() + header.append(f"MEM PRESSURE: {memory.percent:3.0f}% ", style="bold " + color) + header.append(make_bar(memory.percent, 100, 20), style=color) + header.append(f" SWAP: {memory.swap_percent:.0f}% ", style="yellow") + header.append(f"TOTAL: {memory.total / GIB:.1f} GB", style="dim") + screen.query_one("#mem-hero-header", Static).update(header) + + # Allocation map: physical and swap drawn against the same total. + total_volume = memory.total + memory.swap_total + physical_blocks = int(memory.used / total_volume * 100) if total_volume else 0 + swap_blocks = int(memory.swap_used / total_volume * 100) if total_volume else 0 + + allocation = Text() + allocation.append("PHYSICAL RAM IN USE\n", style="cyan") + allocation.append("█" * physical_blocks, style="cyan") + allocation.append("░" * max(0, 50 - physical_blocks), style="dim cyan") + + allocation.append("\n\nSWAP FILE COMMITTED\n", style="yellow") + allocation.append("▓" * swap_blocks, style="yellow") + + allocation.append("\n\nSTATS:\n", style="bold") + allocation.append(f" Available: {memory.available / GIB:.2f} GB\n", style="green") + allocation.append(f" Used: {memory.used / GIB:.2f} GB\n", style="cyan") + allocation.append(f" Swap Used: {memory.swap_used / GIB:.2f} GB", style="yellow") + screen.query_one("#mem-allocation-map", Static).update(allocation) + + top = snapshot.top_processes("mem", limit=1) + info_widget = screen.query_one("#mem-top-process-info", Static) + if top: + process = top[0] + self.top_pid = process.pid + + info = Text() + info.append(f"PID: {process.pid}\n", style="bold yellow") + info.append(f"NAME: {process.name}\n", style="bold white") + info.append(f"MEM: {process.memory_bytes / (1024 * 1024):.1f} MB\n", style="cyan") + info.append("Actions below apply to this process.", style="dim") + info_widget.update(info) + else: + self.top_pid = None + info_widget.update(Text("No active processes identified.", style="dim")) + def get_transcendence_view(self) -> Text: - """Immersive Memory console with pressure waves and allocation mapping.""" - text = Text() - try: - mem = psutil.virtual_memory() - swap = psutil.swap_memory() - except: - return Text("Memory Telemetry Offline") + """Text fallback for the full-screen view.""" + snapshot = self.snapshot + if snapshot is None: + return self.waiting_text() - color = value_to_heat_color(mem.percent) - from pulse.ui_utils import make_bar + memory = snapshot.memory + color = value_to_heat_color(memory.percent) - # --- HERO HEADER --- - text.append(f"MEMORY DEPTH ANALYTICS ", style="bold") + text = Text() + text.append("MEMORY DEPTH ANALYTICS ", style="bold") text.append(f"[{self.view_mode.upper()} MODE]\n", style="cyan") - - text.append(f"PRESSURE: {mem.percent:3.0f}% ", style=color) - text.append(make_bar(mem.percent, 100, 20), style=color) - text.append(f" SWAP: {swap.percent:3.0f}%\n", style="dim") - + text.append(f"PRESSURE: {memory.percent:3.0f}% ", style=color) + text.append(make_bar(memory.percent, 100, 20), style=color) + text.append(f" SWAP: {memory.swap_percent:3.0f}%\n", style="dim") + if self.view_mode == "cinematic": - # Massive Waveform Focus - text.append("\nPRESSURE WAVEFORM (80s)\n", style="cyan") - for val in self.history: - text.append(value_to_spark(val), style=value_to_heat_color(val)) - + text.append("\nPRESSURE WAVEFORM\n", style="cyan") + for value in self.history.memory: + text.append(value_to_spark(value), style=value_to_heat_color(value)) + text.append("\n\nALLOCATION LANDSCAPE\n", style="cyan") - # Visual layout of physical vs swap - total_vol = mem.total + swap.total - phys_ratio = int((mem.used / total_vol) * 40) - swap_ratio = int((swap.used / total_vol) * 40) - free_ratio = 40 - phys_ratio - swap_ratio - + total_volume = memory.total + memory.swap_total + physical = int(memory.used / total_volume * 40) if total_volume else 0 + swap = int(memory.swap_used / total_volume * 40) if total_volume else 0 + free = max(0, 40 - physical - swap) + text.append("PHYS [", style="dim") - text.append("█" * phys_ratio, style="cyan") + text.append("█" * physical, style="cyan") text.append("] SWAP [", style="dim") - text.append("▓" * swap_ratio, style="yellow") + text.append("▓" * swap, style="yellow") text.append("] FREE [", style="dim") - text.append("░" * max(0, free_ratio), style="dim") + text.append("░" * free, style="dim") text.append("]\n", style="dim") else: - # Developer Focus: Detailed Subsystems - text.append("\n80s PRESSURE: ", style="dim") - for val in list(self.history)[-30:]: - text.append(value_to_spark(val), style=value_to_heat_color(val)) - + text.append("\nPRESSURE: ", style="dim") + for value in list(self.history.memory)[-30:]: + text.append(value_to_spark(value), style=value_to_heat_color(value)) + text.append("\n\nSUBSYSTEM BREAKDOWN\n", style="cyan") - - # Cross-platform safe breakdown - data = [ - ("Physical Total", f"{mem.total/(1024**3):.2f} GB"), - ("Available", f"{mem.available/(1024**3):.2f} GB"), - ("Used (Kernel+Apps)", f"{mem.used/(1024**3):.2f} GB"), + rows = [ + ("Physical Total", memory.total), + ("Available", memory.available), + ("Used (Kernel+Apps)", memory.used), ] - - # Platform specifics - if hasattr(mem, 'cached'): - data.append(("Cached", f"{mem.cached/(1024**3):.2f} GB")) - if hasattr(mem, 'buffers'): - data.append(("Buffers", f"{mem.buffers/(1024**3):.2f} GB")) - - for label, val in data: + if memory.cached is not None: + rows.append(("Cached", memory.cached)) + if memory.buffers is not None: + rows.append(("Buffers", memory.buffers)) + + for label, value in rows: text.append(f" {label:<20} ", style="dim") - text.append(f"{val:>12}\n", style="cyan") - + text.append(f"{value / GIB:>9.2f} GB\n", style="cyan") + text.append("\nSWAP & PAGING\n", style="cyan") - text.append(f" Swap Used: {swap.used/(1024**3):6.2f} GB / {swap.total/(1024**3):.2f} GB\n", style="dim") - # sin/sout are cumulative pages swapped in/out. Windows reports 0. - if swap.sin or swap.sout: - text.append( - f" Swap In/Out: {swap.sin/(1024**2):.1f} MB / {swap.sout/(1024**2):.1f} MB\n", - style="dim", - ) + text.append(f" Swap Used: {memory.swap_used / GIB:6.2f} GB / " + f"{memory.swap_total / GIB:.2f} GB\n", style="dim") return text + # ------------------------------------------------------------------ + # Detail view + # ------------------------------------------------------------------ def get_detailed_view(self) -> Text: """Detailed memory breakdown with pressure waveform.""" + snapshot = self.snapshot + if snapshot is None: + return self.waiting_text() + + memory = snapshot.memory + text = Text() text.append("💾 MEMORY ANALYTICS\n\n", style="bold") - - try: - mem = psutil.virtual_memory() - swap = psutil.swap_memory() - except: - return Text("Memory telemetry unavailable") - - # Pressure Waveform - text.append("Pressure Waveform (Last 40s)\n", style="cyan") - for val in list(self.history)[-40:]: - text.append(value_to_spark(val), style=value_to_heat_color(val)) - text.append(f" {mem.percent:.0f}%\n\n", style=value_to_heat_color(mem.percent)) - - # Status Table + + text.append("Pressure Waveform\n", style="cyan") + for value in list(self.history.memory)[-40:]: + text.append(value_to_spark(value), style=value_to_heat_color(value)) + text.append(f" {memory.percent:.0f}%\n\n", style=value_to_heat_color(memory.percent)) + text.append(f"{'TYPE':<12} {'TOTAL':<10} {'USED':<10} {'FREE':<10}\n", style="dim") text.append("─" * 45 + "\n", style="dim") - text.append(f"{'Physical':<12} {mem.total/(1024**3):.1f}GB {mem.used/(1024**3):.1f}GB {mem.free/(1024**3):.1f}GB\n") - text.append(f"{'Swap':<12} {swap.total/(1024**3):.1f}GB {swap.used/(1024**3):.1f}GB {swap.free/(1024**3):.1f}GB\n\n") + text.append(f"{'Physical':<12} {memory.total / GIB:.1f}GB " + f"{memory.used / GIB:.1f}GB {memory.free / GIB:.1f}GB\n") + text.append(f"{'Swap':<12} {memory.swap_total / GIB:.1f}GB " + f"{memory.swap_used / GIB:.1f}GB {memory.swap_free / GIB:.1f}GB\n\n") - # Allocation Map text.append("Allocation Map\n", style="cyan") - total = mem.total - # Segments: Used, Available - used_p = int((mem.used / total) * 30) - avail_p = 30 - used_p - text.append("[" + "█"*used_p + "▒"*avail_p + "]\n", style="cyan") - text.append(f" {'Used':<8} {mem.used/(1024**3):.1f}GB\n", style="dim") - text.append(f" {'Avail':<8} {mem.available/(1024**3):.1f}GB\n", style="dim") + used_blocks = int(memory.used / memory.total * 30) if memory.total else 0 + text.append("[" + "█" * used_blocks + "▒" * (30 - used_blocks) + "]\n", style="cyan") + text.append(f" {'Used':<8} {memory.used / GIB:.1f}GB\n", style="dim") + text.append(f" {'Avail':<8} {memory.available / GIB:.1f}GB\n", style="dim") return text diff --git a/src/pulse/panels/network.py b/src/pulse/panels/network.py index 8d1a5c1..3b2c426 100644 --- a/src/pulse/panels/network.py +++ b/src/pulse/panels/network.py @@ -1,5 +1,3 @@ -from collections import deque -import psutil from rich.text import Text from pulse.panels.base import Panel @@ -9,52 +7,38 @@ from textual.widgets import Static, Button, DataTable from textual.binding import Binding +GIB = 1024 ** 3 +MIB = 1024 ** 2 +# Reference ceiling for the throughput bars, in KB/s. +BAR_SCALE_KBPS = 1000 + + class NetworkPanel(Panel): """Network upload/download activity.""" - + + PANEL_NAME = "NETWORK" + BINDINGS = [ Binding("f", "optimize", "Reset Counters"), Binding("k", "kill_connection", "Kill PID"), Binding("r", "refresh_stats", "Refresh"), ] - + def __init__(self): super().__init__("NETWORK", "", id="net-panel") - self.up_history = deque(maxlen=80) - self.down_history = deque(maxlen=80) - try: - self.last_net = psutil.net_io_counters() - except: - self.last_net = None - - # Transcendence Control States self.sampling_rate = 1.0 - self.view_mode = "developer" # cinematic / developer + self.view_mode = "developer" # cinematic / developer - def compose_transcendence(self): - """Interactive Network Matrix.""" - with Container(id="net-transcendence-layout"): - # Top: Hero Stats - with Horizontal(classes="header-section"): - yield Static(id="net-hero-header") - - # Middle: Interactive Table - yield DataTable(id="net_table", cursor_type="row", zebra_stripes=True) - - # Bottom: Actions - with Horizontal(classes="footer-section", id="net-actions"): - yield Button("KILL PID [K]", id="btn_kill", variant="error") - yield Button("REFRESH [R]", id="btn_refresh", variant="warning") - - def on_button_pressed(self, event: Button.Pressed) -> None: - if event.button.id == "btn_kill": - self.action_kill_connection() - elif event.button.id == "btn_refresh": - self.action_refresh_stats() + # ------------------------------------------------------------------ + # Actions + # ------------------------------------------------------------------ + def action_optimize(self): + """Clear the network waveforms without disturbing other history.""" + self.store.reset_network_baseline() + self.notify("Network waveforms reset.") def action_refresh_stats(self): - self.update_data() - self.refresh_content(force=True) + self.app.refresh_data() def action_kill_connection(self): """Terminate the process owning the selected connection, after confirming.""" @@ -70,7 +54,7 @@ def action_kill_connection(self): return try: - # Row key is PID_PORT (e.g. "1234_8080") or "Unknown_". + # Row key starts with the owning PID - see update_transcendence. raw_key = table.coordinate_to_cell_key(table.cursor_coordinate).row_key.value except (AttributeError, TypeError): self.notify("Could not identify the selected connection.", severity="error") @@ -82,272 +66,201 @@ def action_kill_connection(self): severity="warning") return - # request_kill confirms, refuses protected PIDs, and reports the result. self.request_kill(int(pid_str)) - def refresh_content(self, force=False): - if hasattr(self.app.screen, "query_one"): - try: - self.update_transcendence(self.app.screen) - except: pass - - def update_transcendence(self, screen): - """Update interactive network view.""" - self.update_data() # Update global counters - - # 1. Hero Header - try: - up = self.up_history[-1] if self.up_history else 0 - down = self.down_history[-1] if self.down_history else 0 - header_widget = screen.query_one("#net-hero-header", Static) - - head = Text() - head.append(f" ▲ {up:6.1f} KB/s ", style="bold yellow") - head.append(make_bar(min(up, 1000), 1000, 15), style="yellow") - head.append(f" ▼ {down:6.1f} KB/s ", style="bold cyan") - head.append(make_bar(min(down, 1000), 1000, 15), style="cyan") - - # Interface Summary - stats = psutil.net_if_stats() - active_nics = [n for n, s in stats.items() if s.isup] - head.append(f" INTERFACES: {len(active_nics)} UP", style="dim") - - header_widget.update(head) - - # 2. DataTable - table = screen.query_one("#net_table", DataTable) - - # Setup columns - cols = list(table.columns.values()) - if not cols: - table.add_columns("PROTO", "L-WT", "LOCAL IP", "REMOTE IP", "STATUS", "PID", "PROCESS") - - # Get Connections - conns = psutil.net_connections(kind='inet') - # Filter: Show established or listen (skip time_wait to reduce noise?) - # Letting user see everything for now, maybe sort by state - active_conns = sorted(conns, key=lambda c: (c.status != 'ESTABLISHED', c.laddr.port)) - - # Prune limit - active_conns = active_conns[:200] - - # Row keys start with the owning PID so the kill action can recover - # it, and include the socket's endpoints so they stay unique - one - # process can hold many sockets on the same local port. - seen_keys = set() - current_rows = set(table.rows.keys()) - - for conn in active_conns: - try: - pid = conn.pid if conn.pid else "Unknown" - remote = f"{conn.raddr.ip}:{conn.raddr.port}" if conn.raddr else "*" - proto = "TCP" if conn.type == 1 else "UDP" - key = f"{pid}_{conn.laddr.port}_{remote}_{proto}" - seen_keys.add(key) - - if key in current_rows: - # Sockets churn constantly; rows are added and removed - # rather than updated in place. - continue - - process_name = "System" - if pid != "Unknown": - try: - process_name = psutil.Process(pid).name() - except (psutil.NoSuchProcess, psutil.AccessDenied): - process_name = "?" - - table.add_row( - proto, - str(conn.laddr.port), - conn.laddr.ip, - remote, - Text(conn.status, - style="green" if conn.status == "ESTABLISHED" else "dim"), - str(pid), - process_name, - key=key, - ) - except (AttributeError, ValueError): - continue - - for stale_key in current_rows - seen_keys: - table.remove_row(stale_key) - - except (psutil.Error, OSError): - pass + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "btn_kill": + self.action_kill_connection() + elif event.button.id == "btn_refresh": + self.action_refresh_stats() - def action_optimize(self): - """Reset network session counters.""" - try: - self.last_net = psutil.net_io_counters() - self.up_history.clear() - self.down_history.clear() - self.notify("Network Session Counters Reset", severity="information") - except: - self.notify("Failed to reset network counters", severity="error") - self.refresh() - + # ------------------------------------------------------------------ + # Summary view + # ------------------------------------------------------------------ def update_data(self): - try: - current = psutil.net_io_counters() - except: + snapshot = self.snapshot + if snapshot is None: + self.update(self.waiting_text()) return - if not self.last_net: - self.last_net = current - return - - # Calculate rates (bytes per second if interval is 1s, but we'll use delta) - sent_rate = (current.bytes_sent - self.last_net.bytes_sent) / 1024 # KB - recv_rate = (current.bytes_recv - self.last_net.bytes_recv) / 1024 # KB - self.last_net = current - - self.up_history.append(min(sent_rate, 1000)) # Cap for sparkline scaling - self.down_history.append(min(recv_rate, 1000)) - + network = snapshot.network + rates = self.rates + text = Text() - # Traffic summary text.append("UP ", style="yellow") - text.append(f"{sent_rate:4.0f}KB/s ", style="dim") - for val in list(self.up_history)[-15:]: - text.append(value_to_spark(val, 100), style="yellow") - + text.append(f"{rates.net_sent_kbps:4.0f}KB/s ", style="dim") + for value in list(self.history.net_sent)[-15:]: + text.append(value_to_spark(value, 100), style="yellow") + text.append("\nDOWN ", style="cyan") - text.append(f"{recv_rate:4.0f}KB/s ", style="dim") - for val in list(self.down_history)[-15:]: - text.append(value_to_spark(val, 100), style="cyan") - - # Connectivity Consolidation - try: - conns = len(psutil.net_connections(kind='inet')) - text.append(f"\nCONNS: {conns} ", style="cyan") - - # Show first active IP as primary hint - addrs = psutil.net_if_addrs() - found_ip = False - for nic_name, nic_addrs in addrs.items(): - for addr in nic_addrs: - if addr.family == 2 and not addr.address.startswith("127."): - text.append(f" IP: {addr.address}", style="dim") - found_ip = True - break - if found_ip: break - except: - pass - + text.append(f"{rates.net_recv_kbps:4.0f}KB/s ", style="dim") + for value in list(self.history.net_recv)[-15:]: + text.append(value_to_spark(value, 100), style="cyan") + + text.append(f"\nCONNS: {network.connection_count} ", style="cyan") + primary = network.primary_ipv4 + if primary: + text.append(f" IP: {primary}", style="dim") + self.update(text) + # ------------------------------------------------------------------ + # Transcendence + # ------------------------------------------------------------------ + def compose_transcendence(self): + """Interactive Network Matrix.""" + with Container(id="net-transcendence-layout"): + with Horizontal(classes="header-section"): + yield Static(id="net-hero-header") + + yield DataTable(id="net_table", cursor_type="row", zebra_stripes=True) + + with Horizontal(classes="footer-section", id="net-actions"): + yield Button("KILL PID [K]", id="btn_kill", variant="error") + yield Button("REFRESH [R]", id="btn_refresh", variant="warning") + + def update_transcendence(self, screen): + """Update the interactive network view from the current snapshot.""" + snapshot = self.snapshot + if snapshot is None: + return + + network = snapshot.network + rates = self.rates + + header = Text() + header.append(f" ▲ {rates.net_sent_kbps:6.1f} KB/s ", style="bold yellow") + header.append(make_bar(min(rates.net_sent_kbps, BAR_SCALE_KBPS), + BAR_SCALE_KBPS, 15), style="yellow") + header.append(f" ▼ {rates.net_recv_kbps:6.1f} KB/s ", style="bold cyan") + header.append(make_bar(min(rates.net_recv_kbps, BAR_SCALE_KBPS), + BAR_SCALE_KBPS, 15), style="cyan") + header.append(f" INTERFACES: {network.active_interfaces} UP", style="dim") + screen.query_one("#net-hero-header", Static).update(header) + + table = screen.query_one("#net_table", DataTable) + if not table.columns: + table.add_columns("INTERFACE", "STATE", "IPv4", "SPEED", "MTU") + + # The interface list is short and stable, so a full repaint is cheap + # and avoids the row-churn the connection table used to suffer from. + current_rows = set(table.rows.keys()) + seen = set() + for interface in network.interfaces: + seen.add(interface.name) + if interface.name in current_rows: + continue + state = "UP" if interface.is_up else "DOWN" + table.add_row( + interface.name, + Text(state, style="green" if interface.is_up else "red"), + interface.ipv4 or "-", + f"{interface.speed_mbps} Mbps" if interface.speed_mbps else "-", + str(interface.mtu), + key=interface.name, + ) + + for stale in current_rows - seen: + table.remove_row(stale) + def get_transcendence_view(self) -> Text: - """Immersive Network console with throughput pulses and interface mapping.""" - text = Text() - try: - net = psutil.net_io_counters() - conns = psutil.net_connections(kind='inet') - except: - return Text("Network Telemetry Offline") + """Text fallback for the full-screen view.""" + snapshot = self.snapshot + if snapshot is None: + return self.waiting_text() - from pulse.ui_utils import make_bar + network = snapshot.network + rates = self.rates - # --- HERO HEADER --- - text.append(f"NETWORK FLOW ANALYTICS ", style="bold") + text = Text() + text.append("NETWORK FLOW ANALYTICS ", style="bold") text.append(f"[{self.view_mode.upper()} MODE]\n", style="cyan") - - recent_up = self.up_history[-1] if self.up_history else 0 - recent_down = self.down_history[-1] if self.down_history else 0 - - text.append(f"TX: {recent_up:6.1f} KB/s ", style="yellow") - text.append(make_bar(min(recent_up, 1000), 1000, 20), style="yellow") - text.append(f" TCP CONNS: {len([c for c in conns if c.type == 1])}\n", style="dim") - - text.append(f"RX: {recent_down:6.1f} KB/s ", style="cyan") - text.append(make_bar(min(recent_down, 1000), 1000, 20), style="cyan") - text.append(f" UDP CONNS: {len([c for c in conns if c.type == 2])}\n", style="dim") + + text.append(f"TX: {rates.net_sent_kbps:6.1f} KB/s ", style="yellow") + text.append(make_bar(min(rates.net_sent_kbps, BAR_SCALE_KBPS), + BAR_SCALE_KBPS, 20), style="yellow") + text.append(f" CONNS: {network.connection_count}\n", style="dim") + + text.append(f"RX: {rates.net_recv_kbps:6.1f} KB/s ", style="cyan") + text.append(make_bar(min(rates.net_recv_kbps, BAR_SCALE_KBPS), + BAR_SCALE_KBPS, 20), style="cyan") + text.append(f" LISTENING: {network.listen_count}\n", style="dim") if self.view_mode == "cinematic": - # Massive Waveform Focus - text.append("\nUPLOAD WAVEFORM (80s)\n", style="cyan") - for val in self.up_history: - text.append(value_to_spark(val, 100), style="yellow") - - text.append("\n\nDOWNLOAD WAVEFORM (80s)\n", style="cyan") - for val in self.down_history: - text.append(value_to_spark(val, 100), style="cyan") - + text.append("\nUPLOAD WAVEFORM\n", style="cyan") + for value in self.history.net_sent: + text.append(value_to_spark(value, 100), style="yellow") + + text.append("\n\nDOWNLOAD WAVEFORM\n", style="cyan") + for value in self.history.net_recv: + text.append(value_to_spark(value, 100), style="cyan") + text.append("\n\nTOTAL DATA EXCHANGED (Session)\n", style="cyan") - text.append(f" SENT: {net.bytes_sent/(1024**3):.2f} GB RECV: {net.bytes_recv/(1024**3):.2f} GB\n", style="dim") + text.append(f" SENT: {network.bytes_sent / GIB:.2f} GB " + f"RECV: {network.bytes_recv / GIB:.2f} GB\n", style="dim") else: - # Developer Focus: Interface Map & Socket Stats - text.append("\n80s FLOW PULSE: ", style="dim") - for val_up, val_down in zip(list(self.up_history)[-30:], list(self.down_history)[-30:]): - text.append("▲" if val_up > val_down else "▼", style="yellow" if val_up > val_down else "cyan") - + text.append("\nFLOW PULSE: ", style="dim") + for sent, recv in zip(list(self.history.net_sent)[-30:], + list(self.history.net_recv)[-30:]): + text.append("▲" if sent > recv else "▼", + style="yellow" if sent > recv else "cyan") + text.append("\n\nINTERFACE STATUS MATRIX\n", style="cyan") - try: - stats = psutil.net_if_stats() - addrs = psutil.net_if_addrs() - text.append(f"{'INTERFACE':<16} {'STATE':<8} {'SPEED':<10} {'MTU':<6}\n", style="dim") - text.append("─" * 45 + "\n", style="dim") - for nic, s in stats.items(): - color = "green" if s.isup else "red" - state = "UP" if s.isup else "DOWN" - text.append(f" {nic[:15]:<16} ", style="cyan") - text.append(f"[{state:<6}] ", style=color) - text.append(f"{s.speed:>4}Mbps ", style="dim") - text.append(f"{s.mtu:>4}\n", style="dim") - except: - text.append("Interface matrix unavailable\n", style="red") - - text.append("\nSOCKET BACKLOG\n", style="cyan") - text.append(f" Established: {len([c for c in conns if c.status == 'ESTABLISHED']):>4}\n", style="green dim") - text.append(f" Listen: {len([c for c in conns if c.status == 'LISTEN']):>4}\n", style="dim") - text.append(f" Time Wait: {len([c for c in conns if c.status == 'TIME_WAIT']):>4}\n", style="dim") + text.append(f"{'INTERFACE':<16} {'STATE':<8} {'SPEED':<10} {'MTU':<6}\n", + style="dim") + text.append("─" * 45 + "\n", style="dim") + for interface in network.interfaces: + color = "green" if interface.is_up else "red" + state = "UP" if interface.is_up else "DOWN" + text.append(f" {interface.name[:15]:<16} ", style="cyan") + text.append(f"[{state:<6}] ", style=color) + text.append(f"{interface.speed_mbps:>4}Mbps ", style="dim") + text.append(f"{interface.mtu:>4}\n", style="dim") + + text.append("\nSOCKET SUMMARY\n", style="cyan") + text.append(f" Established: {network.established_count:>4}\n", + style="green dim") + text.append(f" Listen: {network.listen_count:>4}\n", style="dim") + text.append(f" Total: {network.connection_count:>4}\n", style="dim") return text + # ------------------------------------------------------------------ + # Detail view + # ------------------------------------------------------------------ def get_detailed_view(self) -> Text: - """Detailed network diagnostics with 40s waveforms.""" + """Detailed network diagnostics.""" + snapshot = self.snapshot + if snapshot is None: + return self.waiting_text() + + network = snapshot.network + text = Text() text.append("🌐 NETWORK DIAGNOSTICS\n\n", style="bold") - - try: - net = psutil.net_io_counters() - except: - return Text("Network telemetry unavailable") - - # Waveforms - text.append("Throughput Pulse (Last 40s)\n", style="cyan") + + text.append("Throughput Pulse\n", style="cyan") text.append(" UP ", style="yellow") - for val in list(self.up_history)[-40:]: - text.append(value_to_spark(val, 100), style="yellow") + for value in list(self.history.net_sent)[-40:]: + text.append(value_to_spark(value, 100), style="yellow") text.append("\n DOWN ", style="cyan") - for val in list(self.down_history)[-40:]: - text.append(value_to_spark(val, 100), style="cyan") + for value in list(self.history.net_recv)[-40:]: + text.append(value_to_spark(value, 100), style="cyan") text.append("\n\n") - # Session Totals text.append("Traffic Totals\n", style="cyan") - text.append(f" Sent: {net.bytes_sent / (1024**2):.2f} MB ", style="yellow") - text.append(f"({net.packets_sent} pkts)\n", style="dim") - text.append(f" Recv: {net.bytes_recv / (1024**2):.2f} MB ", style="cyan") - text.append(f"({net.packets_recv} pkts)\n", style="dim") + text.append(f" Sent: {network.bytes_sent / MIB:.2f} MB ", style="yellow") + text.append(f"({network.packets_sent} pkts)\n", style="dim") + text.append(f" Recv: {network.bytes_recv / MIB:.2f} MB ", style="cyan") + text.append(f"({network.packets_recv} pkts)\n", style="dim") + + text.append("\nInterface Map\n", style="cyan") + for interface in network.interfaces: + color = "green" if interface.is_up else "red" + state = "UP" if interface.is_up else "DOWN" + text.append(f" {interface.name[:15]:<16} [{state}]", style=color) + if interface.ipv4: + text.append(f" IPv4: {interface.ipv4}", style="dim") + text.append("\n") - # Interfaces - try: - addrs = psutil.net_if_addrs() - stats = psutil.net_if_stats() - text.append("\nInterface Map\n", style="cyan") - for nic, nic_addrs in addrs.items(): - is_up = stats[nic].isup if nic in stats else False - status = "UP" if is_up else "DOWN" - color = "green" if is_up else "red" - text.append(f" {nic[:15]:<16} [{status}]", style=color) - for addr in nic_addrs: - if addr.family == 2: - text.append(f" IPv4: {addr.address}", style="dim") - break - text.append("\n") - except: - pass - return text diff --git a/src/pulse/panels/process.py b/src/pulse/panels/process.py index f4d8156..e94806a 100644 --- a/src/pulse/panels/process.py +++ b/src/pulse/panels/process.py @@ -1,4 +1,3 @@ -import psutil from rich.text import Text from pulse.panels.base import Panel @@ -8,11 +7,14 @@ from textual.containers import Container, Horizontal from textual.binding import Binding -from pulse import core +# How far the memory percentage is stretched before colouring it. A process at +# 20% of RAM is already notable, so the heat scale is compressed. +MEM_HEAT_SCALE = 5 + class ProcessPanel(Panel): """Process list showing top consumers (CPU/MEM).""" - + PANEL_NAME = "PROCESSES" BINDINGS = [ ("c", "sort('cpu')", "Sort CPU"), @@ -21,64 +23,37 @@ class ProcessPanel(Panel): Binding("plus", "renice_up", "Nice +", priority=True), Binding("minus", "renice_down", "Nice -", priority=True), ] - + def __init__(self): super().__init__("TOP PROCS", "", id="process-panel") - self.last_procs = [] - self.sort_key = 'cpu' # or 'mem' - # Transcendence Control States - self.sampling_rate = 2.0 # Slower default for processes - self.view_mode = "developer" # cinematic / developer - self.selected_pid = None - - core.init() + self.sort_key = "cpu" # or 'mem' + self.sampling_rate = 2.0 + self.view_mode = "developer" - def compose_transcendence(self): - """Interactive Process Management Matrix.""" - with Container(id="proc-transcendence-layout"): - # Top: Hero Stats - with Horizontal(classes="header-section"): - yield Static(id="proc-hero-header") - - # Middle: Interactive Table - yield DataTable(id="proc_table", cursor_type="row", zebra_stripes=True) - - # Bottom: Actions - with Horizontal(classes="footer-section", id="proc-actions"): - yield Button("KILL [K]", id="btn_kill", variant="error") - yield Button("SORT CPU [C]", id="btn_sort_cpu", variant="primary") - yield Button("SORT MEM [M]", id="btn_sort_mem", variant="default") - yield Button("REFRESH [R]", id="btn_refresh", variant="warning") - - def on_button_pressed(self, event: Button.Pressed) -> None: - if event.button.id == "btn_kill": - self.action_kill_process() - elif event.button.id == "btn_sort_cpu": - self.action_sort("cpu") - elif event.button.id == "btn_sort_mem": - self.action_sort("mem") - elif event.button.id == "btn_refresh": - self.action_refresh_stats() - - def action_refresh_stats(self): + # ------------------------------------------------------------------ + # Actions + # ------------------------------------------------------------------ + def action_sort(self, mode: str): + """Sort the process list.""" + self.sort_key = mode self.update_data() - self.refresh_content(force=True) + self.notify(f"Sorting by {mode.upper()}") def action_kill_process(self): """Terminate the process under the cursor, after confirmation.""" pid = self._selected_pid() - if pid is None: - return - self.request_kill(pid) + if pid is not None: + self.request_kill(pid) def action_renice_up(self): - """Increase nice value (lower priority).""" self.request_renice(self._selected_pid(), 1) def action_renice_down(self): - """Decrease nice value (higher priority).""" self.request_renice(self._selected_pid(), -1) + def action_refresh_stats(self): + self.app.refresh_data() + def _selected_pid(self): """PID under the table cursor, or None with a message explaining why not.""" try: @@ -99,249 +74,181 @@ def _selected_pid(self): self.notify("Could not identify the selected process.", severity="error") return None + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "btn_kill": + self.action_kill_process() + elif event.button.id == "btn_sort_cpu": + self.action_sort("cpu") + elif event.button.id == "btn_sort_mem": + self.action_sort("mem") + elif event.button.id == "btn_refresh": + self.action_refresh_stats() + + # ------------------------------------------------------------------ + # Summary view + # ------------------------------------------------------------------ + def update_data(self): + snapshot = self.snapshot + if snapshot is None: + self.update(self.waiting_text()) + return + + text = Text() + text.append(f"Sorted by {self.sort_key.upper()}\n", style="dim") + + total_memory = snapshot.memory.total + for process in snapshot.top_processes(self.sort_key, limit=4): + if self.sort_key == "cpu": + value = process.cpu_percent + color = value_to_heat_color(value) + threshold = 10 + else: + value = process.memory_percent(total_memory) + color = value_to_heat_color(value * MEM_HEAT_SCALE) + threshold = 5 + + text.append("★ " if value > threshold else "· ", style=color) + text.append(f"{process.name[:8]:<8} {value:4.1f}%\n", style="dim") + + self.update(text) + + # ------------------------------------------------------------------ + # Transcendence + # ------------------------------------------------------------------ + def compose_transcendence(self): + """Interactive Process Management Matrix.""" + with Container(id="proc-transcendence-layout"): + with Horizontal(classes="header-section"): + yield Static(id="proc-hero-header") + + yield DataTable(id="proc_table", cursor_type="row", zebra_stripes=True) + + with Horizontal(classes="footer-section", id="proc-actions"): + yield Button("KILL [K]", id="btn_kill", variant="error") + yield Button("SORT CPU [C]", id="btn_sort_cpu", variant="primary") + yield Button("SORT MEM [M]", id="btn_sort_mem", variant="default") + yield Button("REFRESH [R]", id="btn_refresh", variant="warning") + def update_transcendence(self, screen): - """Update the DataTable efficiently with rich visualization matching NetworkPanel.""" + """Update the process table, preserving the user's cursor position.""" + snapshot = self.snapshot + if snapshot is None: + return + table = screen.query_one("#proc_table", DataTable) header = screen.query_one("#proc-hero-header", Static) - - # Init columns if needed + if not table.columns: table.add_columns("PID", "NAME", "CPU %", "MEM %", "USER", "STATUS") - - # Fetch data - procs = core.get_process_list(sort_by=self.sort_key, limit=100) - - # --- Update Header (Strictly Replicating Network Design) --- - # Calculate System Globals - sys_cpu = psutil.cpu_percent() - mem_info = core.get_memory_info() - sys_mem_pct = mem_info['percent'] if mem_info else 0 - proc_count = len(procs) - + + total_memory = snapshot.memory.total + processes = snapshot.top_processes(self.sort_key, limit=100) + head = Text() - # CPU Block (Green) -> Replicating "Upload" style - head.append(f" ⚡ {sys_cpu:5.1f}% ", style="bold green") - head.append(make_bar(sys_cpu, 100, 15), style="green") - - # MEM Block (Blue) -> Replicating "Download" style - head.append(f" 💾 {sys_mem_pct:5.1f}% ", style="bold blue") - head.append(make_bar(sys_mem_pct, 100, 15), style="blue") - - # Stats Block -> Replicating "INTERFACES" style - head.append(f" ACTIVE TASKS: {proc_count}", style="dim") - + cpu = snapshot.cpu.percent + head.append(f" ⚡ {cpu:5.1f}% ", style="bold green") + head.append(make_bar(cpu, 100, 15), style="green") + memory = snapshot.memory.percent + head.append(f" 💾 {memory:5.1f}% ", style="bold blue") + head.append(make_bar(memory, 100, 15), style="blue") + head.append(f" ACTIVE TASKS: {len(snapshot.processes)}", style="dim") header.update(head) - - # --- Update Table --- - - # Save selection - old_cursor_key = None - if table.cursor_row is not None: - try: - old_cursor_key = table.coordinate_to_cell_key(table.cursor_coordinate).row_key - except: pass - - old_scroll_y = table.scroll_y - - table.clear() - - for p in procs: - pid = p['pid'] - pid_key = str(pid) - - # Format Data - name = p['name'] - cpu = p['cpu_percent'] - mem_total = mem_info['total'] if mem_info else 1 - mem_pct = (p['memory_info'] / mem_total * 100) - - # Colors - cpu_style = value_to_heat_color(cpu) - mem_style = value_to_heat_color(mem_pct * 5) - - # Fetch details - user = "?" - status = "?" - try: - proc = psutil.Process(pid) - user = proc.username() - if "\\" in user: user = user.split("\\")[1] - status = proc.status() - except: - pass - - row_data = [ - str(pid), - name, - Text(f"{cpu:5.1f}", style=cpu_style), - Text(f"{mem_pct:5.1f}", style=mem_style), - Text(user[:10], style="dim"), - Text(status[:10], style="dim") + + # Rows are updated in place and only added or removed on change, so a + # scrolled, selected row stays put instead of being rebuilt each tick. + column_keys = list(table.columns.keys()) + current_rows = set(table.rows.keys()) + seen = set() + + for process in processes: + key = str(process.pid) + seen.add(key) + + memory_percent = process.memory_percent(total_memory) + row = [ + key, + process.name, + Text(f"{process.cpu_percent:5.1f}", + style=value_to_heat_color(process.cpu_percent)), + Text(f"{memory_percent:5.1f}", + style=value_to_heat_color(memory_percent * MEM_HEAT_SCALE)), + Text(process.username[:10], style="dim"), + Text(process.status[:10], style="dim"), ] - - table.add_row(*row_data, key=pid_key) - - # Restore selection - if old_cursor_key: - try: - new_idx = table.get_row_index(old_cursor_key) - table.move_cursor(row=new_idx) - table.scroll_y = old_scroll_y - except: - pass - + + if key in current_rows: + for index, value in enumerate(row): + table.update_cell(key, column_keys[index], value) + else: + table.add_row(*row, key=key) + + for stale in current_rows - seen: + table.remove_row(stale) + def get_transcendence_view(self) -> Text: - """Ultimate Process Flow Analytics.""" + """Text fallback for the full-screen view.""" + snapshot = self.snapshot + if snapshot is None: + return self.waiting_text() + + total_memory = snapshot.memory.total + text = Text() - text.append(f"PROCESS INFUSION ", style="bold") - text.append(f"[{self.view_mode.upper()} MODE] ", style="cyan") + text.append("PROCESS INFUSION ", style="bold") text.append(f"SORT: {self.sort_key.upper()}\n", style="dim") - - if not self.last_procs: - return Text("Initializing process telemetry...") - - if self.view_mode == "cinematic": - # Cinematic: Visual dominance - text.append("\nHIGH-CONSUMPTION LANDSCAPE\n", style="cyan") - for p in self.last_procs[:15]: - val = p[self.sort_key] - color = value_to_heat_color(val if self.sort_key == 'cpu' else val * 5) - text.append(f"{p['name'][:15]:<15} ", style="cyan") - text.append(make_bar(val if self.sort_key == 'cpu' else val*5, 100, 30), style=color) - text.append(f" {val:>4.1f}% ", style=color) - text.append(f"PID: {p['pid']}\n", style="dim") - else: - # Developer Mode: Detailed Statistics - text.append("\nPROCESS REGISTRY DEPTH\n", style="cyan") - text.append(f"{'PID':<7} {'CPU%':<7} {'MEM%':<7} {'THREADS':<8} {'USER':<12} {'NAME'}\n", style="dim") - text.append("─" * 80 + "\n", style="dim") - - for p in self.last_procs[:25]: - try: - proc = psutil.Process(p['pid']) - # Supplemental data for dev mode - threads = proc.num_threads() - username = proc.username()[:12] - - cpu_c = value_to_heat_color(p['cpu']) - mem_c = value_to_heat_color(p['mem'] * 5) - - text.append(f"{p['pid']:<7}", style="dim") - text.append(f"{p['cpu']:>5.1f}% ", style=cpu_c) - text.append(f"{p['mem']:>5.1f}% ", style=mem_c) - text.append(f"{threads:<8}", style="dim") - text.append(f"{username:<12}", style="dim") - text.append(f"{p['name']}\n", style="yellow") - except: - # Fallback to last_procs data if pid vanished - text.append(f"{p['pid']:<7} {p['cpu']:>5.1f}% {p['mem']:>5.1f}% {'?':<8} {'?':<12} {p['name']}\n", style="dim") - - # Status Matrix - text.append("\nSYSTEM STATE DISTRIBUTION\n", style="cyan") - counts = {'running': 0, 'sleeping': 0, 'idle': 0, 'other': 0} - for p in psutil.process_iter(['status']): - s = p.info['status'] - if s in counts: counts[s] += 1 - else: counts['other'] += 1 - - for s, c in counts.items(): - text.append(f" {s.upper():<10}: {c:>4} ", style="dim") - text.append("█" * (c // 20) + "░" * (10 - (c // 20)) + "\n", style="cyan") - + + text.append("\nHIGH-CONSUMPTION LANDSCAPE\n", style="cyan") + for process in snapshot.top_processes(self.sort_key, limit=15): + if self.sort_key == "cpu": + value = process.cpu_percent + scaled = value + else: + value = process.memory_percent(total_memory) + scaled = value * MEM_HEAT_SCALE + + color = value_to_heat_color(scaled) + text.append(f"{process.name[:15]:<15} ", style="cyan") + text.append(make_bar(min(scaled, 100), 100, 30), style=color) + text.append(f" {value:>4.1f}% ", style=color) + text.append(f"PID: {process.pid}\n", style="dim") + + text.append("\nSTATE DISTRIBUTION\n", style="cyan") + counts = {} + for process in snapshot.processes: + counts[process.status] = counts.get(process.status, 0) + 1 + for status, count in sorted(counts.items(), key=lambda item: -item[1]): + text.append(f" {status.upper():<10}: {count:>4} ", style="dim") + filled = min(10, count // 20) + text.append("█" * filled + "░" * (10 - filled) + "\n", style="cyan") + return text - def action_sort(self, mode: str): - """Sort the process list.""" - self.sort_key = mode - self.refresh_content() - # Visual feedback - self.notify(f"Sorting by {mode.upper()}") - - def update_data(self): - procs = [] - - # Get process list from Direct OS engine - process_data = core.get_process_list(sort_by=self.sort_key, limit=60) - - # Get total memory for percentage calculation - mem_info = core.get_memory_info() - total_mem = mem_info.get('total', 1) if mem_info else 1 - - for p in process_data: - cpu = p.get('cpu_percent', 0) - mem_bytes = p.get('memory_info', 0) - mem = (mem_bytes / total_mem) * 100 if total_mem else 0 - - if cpu < 0.1 and mem < 0.1: - continue - - procs.append({ - 'name': p.get('name', '?'), - 'cpu': cpu, - 'mem': mem, - 'pid': p.get('pid', 0) - }) - - # Sort based on current mode - procs.sort(key=lambda x: x[self.sort_key], reverse=True) - self.last_procs = procs[:50] - top = procs[:4] - - self.render_panel(top) - - def render_panel(self, top_procs): - text = Text() - - # Header indicating sort mode - sort_label = "CPU" if self.sort_key == 'cpu' else "MEM" - text.append(f"Sorted by {sort_label}\n", style="dim") - - for p in top_procs: - # Choose color based on the active sort metric - val = p[self.sort_key] - color = value_to_heat_color(val if self.sort_key == 'cpu' else val * 3) # fast hack scaling for mem - - star = "★" if val > (10 if self.sort_key == 'cpu' else 5) else "·" - text.append(f"{star} ", style=color) - - name = p['name'][:8] - val_fmt = f"{val:4.1f}%" - text.append(f"{name:<8} {val_fmt}\n", style="dim") - - self.update(text) - - def refresh_content(self, force=False): - """Force a re-render with sorted data (called on keypress).""" - if force or hasattr(self.app.screen, "query_one"): - try: - self.update_transcendence(self.app.screen) - except: pass - self.update_data() - + # ------------------------------------------------------------------ + # Detail view + # ------------------------------------------------------------------ def get_detailed_view(self) -> Text: """Detailed process table.""" + snapshot = self.snapshot + if snapshot is None: + return self.waiting_text() + + total_memory = snapshot.memory.total + text = Text() text.append("⭐ TOP PROCESSES\n\n", style="bold") - - sort_mode = "CPU" if self.sort_key == 'cpu' else "MEMORY" - hint = "[C] Sort CPU [M] Sort Memory" - text.append(f"Sorted by: {sort_mode} ", style="cyan") - text.append(f"{hint}\n\n", style="dim") - + text.append(f"Sorted by: {self.sort_key.upper()} ", style="cyan") + text.append("[C] Sort CPU [M] Sort Memory\n\n", style="dim") + text.append("PID CPU% MEM% NAME\n", style="yellow") text.append("─" * 40 + "\n", style="dim") - - for p in self.last_procs: - cpu_val = p['cpu'] - mem_val = p['mem'] - - # fast scaling for heatmap colors - cpu_color = value_to_heat_color(cpu_val) - mem_color = value_to_heat_color(mem_val * 4) - - text.append(f"{p['pid']:<8}", style="dim") - text.append(f"{cpu_val:5.1f}% ", style=cpu_color) - text.append(f"{mem_val:5.1f}% ", style=mem_color) - text.append(f"{p['name']}\n") - + + for process in snapshot.top_processes(self.sort_key, limit=20): + memory_percent = process.memory_percent(total_memory) + text.append(f"{process.pid:<8}", style="dim") + text.append(f"{process.cpu_percent:5.1f}% ", + style=value_to_heat_color(process.cpu_percent)) + text.append(f"{memory_percent:5.1f}% ", + style=value_to_heat_color(memory_percent * MEM_HEAT_SCALE)) + text.append(f"{process.name}\n") + return text diff --git a/src/pulse/panels/storage.py b/src/pulse/panels/storage.py index 2b1cc16..425063e 100644 --- a/src/pulse/panels/storage.py +++ b/src/pulse/panels/storage.py @@ -2,7 +2,6 @@ import os import platform -import psutil from rich.text import Text from pulse.panels.base import Panel @@ -14,345 +13,281 @@ from pulse.screens.viewer import FileViewer +GIB = 1024 ** 3 +# Directory listings are read on demand; this caps the work for huge folders. +MAX_ENTRIES = 1000 + + class StoragePanel(Panel): - """Storage Matrix showing all mounted drives and their health.""" - + """Storage matrix showing mounted volumes, with a file browser.""" + PANEL_NAME = "STORAGE" BINDINGS = [ Binding("backspace", "go_up", "Back", priority=True), Binding("r", "refresh_stats", "Refresh", priority=True), Binding("enter", "select_item", "Enter/Select", priority=True), ] - + def __init__(self): super().__init__("STORAGE", "", id="storage-panel") - # Transcendence Control States - self.sampling_rate = 5.0 # Slow updates for disk space - self.view_mode = "developer" # cinematic / developer - self.current_path = None # None = Drive List, Str = Directory Path - - def on_button_pressed(self, event: Button.Pressed) -> None: - """Handle footer button clicks.""" - if event.button.id == "btn_explore": - self.action_explore() - elif event.button.id == "btn_up": - self.action_go_up() - elif event.button.id == "btn_refresh": - self.action_refresh_stats() + self.sampling_rate = 5.0 + self.view_mode = "developer" + self.current_path = None # None = volume list, str = directory path + # ------------------------------------------------------------------ + # Navigation + # ------------------------------------------------------------------ def action_go_up(self): """Navigate up one directory.""" - if self.current_path: - parent = os.path.dirname(self.current_path) - if parent == self.current_path: # Root - self.current_path = None - else: - self.current_path = parent - self.refresh_content(force=True) - else: - self.notify("Already at Drive Root") + if not self.current_path: + self.notify("Already at the volume list.") + return + + parent = os.path.dirname(self.current_path) + self.current_path = None if parent == self.current_path else parent + self._repaint() def action_select_item(self): - """Enter directory or open file.""" + """Enter a directory or open a file.""" try: table = self.app.screen.query_one("#storage_table", DataTable) - except: return - - if table.cursor_row is None: return - - row_key = table.coordinate_to_cell_key(table.cursor_coordinate).row_key.value - - # If in Drive List mode + except Exception: + return + + if table.cursor_row is None: + return + + try: + row_key = table.coordinate_to_cell_key(table.cursor_coordinate).row_key.value + except (AttributeError, TypeError): + return + + # Volume list mode: the key is a mount point. if self.current_path is None: - self.current_path = row_key # Drive mount point - self.refresh_content(force=True) + self.current_path = row_key + self._repaint() return - - # If in Directory mode + full_path = os.path.join(self.current_path, row_key) - if os.path.isdir(full_path): self.current_path = full_path - self.refresh_content(force=True) + self._repaint() elif os.path.isfile(full_path): - # NATIVE VIEWER + # FileViewer refuses anything that is not a regular file. self.app.push_screen(FileViewer(full_path)) else: self.notify(f"Cannot open: {row_key}") - def refresh_content(self, force=False): - # We need to trigger update_transcendence - if hasattr(self.app.screen, "query_one"): - try: - self.update_transcendence(self.app.screen) - except: pass - def action_refresh_stats(self): - """Force refresh.""" - self.refresh_content(force=True) - self.notify("Refreshing...") + self.app.refresh_data() + self._repaint() def action_explore(self): - """Standardize on Native Open.""" self.action_select_item() + def _repaint(self): + """Redraw the browser after a navigation change.""" + try: + self.update_transcendence(self.app.screen) + except Exception: + pass + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "btn_refresh": + self.action_refresh_stats() + elif event.button.id == "btn_up": + self.action_go_up() + + # ------------------------------------------------------------------ + # Summary view + # ------------------------------------------------------------------ + def update_data(self): + snapshot = self.snapshot + if snapshot is None: + self.update(self.waiting_text()) + return + + text = Text() + if not snapshot.volumes: + text.append("No volumes detected", style="dim") + self.update(text) + return + + for volume in snapshot.volumes[:3]: + color = value_to_heat_color(volume.percent) + if platform.system() == "Windows": + label = volume.mountpoint[:2] + else: + label = volume.mountpoint[-8:] + text.append(f"{label:<4} ", style="cyan") + text.append(make_bar(volume.percent, 100, 8), style=color) + text.append(f" {volume.percent:3.0f}%\n", style=color) + + self.update(text) + + # ------------------------------------------------------------------ + # Transcendence + # ------------------------------------------------------------------ def compose_transcendence(self): """Interactive Storage Matrix.""" with Container(id="storage-transcendence-layout"): with Horizontal(classes="header-section"): yield Static(id="storage-hero-header") - + yield DataTable(id="storage_table", cursor_type="row", zebra_stripes=True) - + with Horizontal(classes="footer-section", id="storage-actions"): yield Button("REFRESH [R]", id="btn_refresh", variant="primary") - yield Static(" Enter: Select/Open | Backspace: Go Up", classes="status-text") + yield Button("UP [Backspace]", id="btn_up", variant="default") + yield Static(" Enter: Select/Open", classes="status-text") def update_transcendence(self, screen): - """Update Storage Table (Drive List or File List).""" + """Show either the volume list or a directory listing.""" table = screen.query_one("#storage_table", DataTable) header = screen.query_one("#storage-hero-header", Static) - - # Determine Mode + if self.current_path is None: - self._render_drive_list(table, header) + self._render_volumes(table, header) + else: + self._render_directory(table, header, self.current_path) + + def _render_volumes(self, table, header): + """Volume list, straight from the snapshot.""" + snapshot = self.snapshot + if snapshot is None: + return + + columns = list(table.columns.values()) + if not columns or columns[0].label.plain != "MOUNT": + table.clear(columns=True) + table.add_columns("MOUNT", "TYPE", "SIZE", "USED", "FREE", "USAGE") + + column_keys = list(table.columns.keys()) + current_rows = set(table.rows.keys()) + seen = set() + + total_used = total_capacity = 0 + for volume in snapshot.volumes: + key = volume.mountpoint + seen.add(key) + total_used += volume.used + total_capacity += volume.total + + color = value_to_heat_color(volume.percent) + row = [ + volume.mountpoint, + volume.fstype, + f"{volume.total / GIB:.1f} GB", + f"{volume.used / GIB:.1f} GB", + f"{volume.free / GIB:.1f} GB", + Text(f"{volume.percent:.0f}% " + "█" * int(volume.percent / 10), style=color), + ] + + if key in current_rows: + for index, value in enumerate(row): + table.update_cell(key, column_keys[index], value) + else: + table.add_row(*row, key=key) + + for stale in current_rows - seen: + table.remove_row(stale) + + if total_capacity: + global_percent = total_used / total_capacity * 100 + header.update(f"STORAGE ARRAY GLOBAL USAGE: {global_percent:.1f}% " + f"VOLUMES: {len(seen)} [Enter to browse]") else: - self._render_file_list(table, header, self.current_path) + header.update("STORAGE ARRAY No volumes detected") - def _render_file_list(self, table, header, path): - # Setup Columns - cols = list(table.columns.values()) - if not cols or cols[0].label.plain != "NAME": + def _render_directory(self, table, header, path): + """Directory listing. Read on demand, not part of the sampling tick.""" + columns = list(table.columns.values()) + if not columns or columns[0].label.plain != "NAME": table.clear(columns=True) table.add_columns("NAME", "TYPE", "SIZE", "MODIFIED") - - header.update(f"BROWSING: {path}") - - # Save Cursor Position - saved_cursor_key = None + + header.update(f"BROWSING: {path} [Backspace to go up]") + + saved_cursor = None try: if table.cursor_row is not None: - saved_cursor_key = table.coordinate_to_cell_key(table.cursor_coordinate).row_key - except: pass + saved_cursor = table.coordinate_to_cell_key( + table.cursor_coordinate).row_key + except Exception: + pass try: - # Efficiency: Limit to 1000 items (increased from 100) - with os.scandir(path) as it: - entries = sorted(list(it), key=lambda e: (not e.is_dir(), e.name))[:1000] - - # Clear old text rows - table.clear() - - for entry in entries: - try: - name = entry.name - key = name - - if entry.is_dir(): - table.add_row(f"📂 {name}", "DIR", "", "", key=key) - else: - stat = entry.stat() - size = stat.st_size - size_str = f"{size / 1024:.1f} KB" - if size > 1024*1024: - size_str = f"{size / (1024*1024):.1f} MB" - - mtime = datetime.datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M") - table.add_row(f"📄 {name}", "FILE", size_str, mtime, key=key) - except: + with os.scandir(path) as entries: + listing = sorted(entries, key=lambda e: (not e.is_dir(), e.name)) + listing = listing[:MAX_ENTRIES] + except OSError as exc: + header.update(f"CANNOT READ {path}: {exc}") + return + + table.clear() + truncated = False + for entry in listing: + try: + if entry.is_dir(): + table.add_row(f"📂 {entry.name}", "DIR", "", "", key=entry.name) continue - - # Restore Cursor - if saved_cursor_key: - try: - index = table.get_row_index(saved_cursor_key) - table.move_cursor(row=index) - except: pass - - except Exception as e: - header.update(f"ACCESS DENIED / ERROR: {e}") - - def _render_drive_list(self, table, header): - # Check current columns - cols = list(table.columns.values()) - if not cols or cols[0].label.plain != "MOUNT": - table.clear(columns=True) - table.add_columns("MOUNT", "TYPE", "SIZE", "USED", "FREE", "USAGE") - - # Populate Drives - # ... (Existing drive logic, slightly adapted) ... - try: - parts = psutil.disk_partitions() - stats = [] - total_used = 0 - total_cap = 0 - - seen_keys = set() - col_keys = list(table.columns.keys()) # Safe key access - - current_rows = set(table.rows.keys()) - - for part in parts: - if 'cdrom' in part.opts or part.fstype == '': continue - try: - usage = psutil.disk_usage(part.mountpoint) - total_used += usage.used - total_cap += usage.total - - mount = part.mountpoint - seen_keys.add(mount) - - pct = usage.percent - color = value_to_heat_color(pct) - - # Row Logic - row_data = [ - mount, - part.fstype, - f"{usage.total / (1024**3):.1f} GB", - f"{usage.used / (1024**3):.1f} GB", - f"{usage.free / (1024**3):.1f} GB", - Text(f"{pct}% " + ("█" * int(pct/10)), style=color) - ] - - if mount in current_rows: - # Update existing - for i, val in enumerate(row_data): - table.update_cell(mount, col_keys[i], val) - else: - table.add_row(*row_data, key=mount) - - except: continue - - # Prune - for k in current_rows - seen_keys: - table.remove_row(k) - - if total_cap > 0: - global_pct = (total_used / total_cap) * 100 - header.update(f"STORAGE ARRAY GLOBAL USAGE: {global_pct:.1f}% VOLUMES: {len(seen_keys)}") - - except Exception as e: - header.update(f"STORAGE OFFLINE: {e}") - + + stat = entry.stat() + size = stat.st_size + if size > 1024 * 1024: + size_text = f"{size / (1024 * 1024):.1f} MB" + else: + size_text = f"{size / 1024:.1f} KB" + modified = datetime.datetime.fromtimestamp( + stat.st_mtime).strftime("%Y-%m-%d %H:%M") + table.add_row(f"📄 {entry.name}", "FILE", size_text, modified, + key=entry.name) + except OSError: + continue + + if len(listing) >= MAX_ENTRIES: + truncated = True + if truncated: + header.update(f"BROWSING: {path} (showing first {MAX_ENTRIES} entries)") + + if saved_cursor: + try: + table.move_cursor(row=table.get_row_index(saved_cursor)) + except Exception: + pass + def get_transcendence_view(self) -> Text: - """Ultimate Storage Matrix with Mount Point Health & Detailed Inodes.""" - text = Text() - text.append(f"STORAGE INFUSION ", style="bold") - text.append(f"[{self.view_mode.upper()} MODE]\n", style="cyan") - - try: - parts = psutil.disk_partitions() - - if self.view_mode == "cinematic": - text.append("\nVOLUME HEALTH LANDSCAPE\n", style="cyan") - for part in parts: - if 'cdrom' in part.opts or part.fstype == '': continue - try: - usage = psutil.disk_usage(part.mountpoint) - color = value_to_heat_color(usage.percent) - text.append(f"{part.mountpoint:<15}", style="cyan") - text.append(make_bar(usage.percent, 100, 30), style=color) - text.append(f" {usage.percent:>4.1f}% ", style=color) - text.append(f"[{usage.used/(1024**3):.1f}/{usage.total/(1024**3):.1f} GB]\n", style="dim") - except: continue - else: - # Developer Mode: Detailed Inodes & Mount Flags - text.append("\nMOUNT POINT REGISTRY\n", style="cyan") - text.append(f"{'MOUNT':<15} {'FSTYPE':<8} {'USAGE':<20} {'INODES':<15} {'FLAGS'}\n", style="dim") - text.append("─" * 80 + "\n", style="dim") - - for part in parts: - if 'cdrom' in part.opts or part.fstype == '': continue - try: - usage = psutil.disk_usage(part.mountpoint) - color = value_to_heat_color(usage.percent) - - text.append(f"{part.mountpoint[:15]:<15}", style="cyan") - text.append(f"{part.fstype:<8}", style="dim") - text.append(make_bar(usage.percent, 100, 10) + f" {usage.percent:>4.1f}% ", style=color) - - # Inodes (posix only usually) - try: - # psutil usage object might have .inodes_percent - if hasattr(usage, 'inodes_percent') and usage.inodes_percent is not None: - i_color = value_to_heat_color(usage.inodes_percent) - text.append(f"{usage.inodes_percent:>5.1f}% inodes ", style=i_color) - else: - text.append("N/A inodes ", style="dim") - except: - text.append("N/A inodes ", style="dim") - - # Flags (shortened) - opts = part.opts[:20] - text.append(f"{opts}", style="dim") - text.append("\n") - except: continue - - text.append("\nI/O COUNTERS (SYSTEM-WIDE)\n", style="cyan") - io = psutil.disk_io_counters() - if io: - text.append(f" READ: {io.read_bytes/(1024**3):.2f} GB ({io.read_count:,} ops)\n", style="green") - text.append(f" WRITE: {io.write_bytes/(1024**3):.2f} GB ({io.write_count:,} ops)\n", style="cyan") - text.append(f" BUSY: {io.busy_time/1000:.1f}s active time\n", style="yellow") - - except Exception as e: - text.append(f"Storage Telemetry Offline: {e}", style="red") - - return text - - def update_data(self): - text = Text() - try: - parts = psutil.disk_partitions() - # Show top 3 disks in summary - count = 0 - for part in parts: - if 'cdrom' in part.opts or part.fstype == '': - continue - try: - usage = psutil.disk_usage(part.mountpoint) - color = value_to_heat_color(usage.percent) - - drive = part.mountpoint[:2] if platform.system() == "Windows" else part.mountpoint[-8:] - text.append(f"{drive:<4} ", style="cyan") - text.append(make_bar(usage.percent, 100, 8), style=color) - text.append(f" {usage.percent:3.0f}%\n", style=color) - count += 1 - if count >= 3: break - except: - continue - except: - text.append("Storage info restricted", style="dim") - - self.update(text) - + """Text fallback for the full-screen view.""" + if self.snapshot is None: + return self.waiting_text() + return self.get_detailed_view() + + # ------------------------------------------------------------------ + # Detail view + # ------------------------------------------------------------------ def get_detailed_view(self) -> Text: """Full storage matrix with wide capacity bars.""" + snapshot = self.snapshot + if snapshot is None: + return self.waiting_text() + text = Text() text.append("🗄️ STORAGE ANALYTICS\n\n", style="bold") - - try: - parts = psutil.disk_partitions() - text.append(f"{'VOLUME':<12} {'TYPE':<8} {'CAPACITY USAGE':<25} {'FREE':<10}\n", style="dim") - text.append("─" * 60 + "\n", style="dim") - - for part in parts: - if 'cdrom' in part.opts or part.fstype == '': - continue - try: - usage = psutil.disk_usage(part.mountpoint) - color = value_to_heat_color(usage.percent) - - label = part.mountpoint[:12] - text.append(f"{label:<12}", style="cyan") - text.append(f"{part.fstype:<8}", style="dim") - # Wide bar - text.append(make_bar(usage.percent, 100, 20) + f" {usage.percent:>3.0f}% ", style=color) - text.append(f"{usage.free / (1024**3):>6.1f} GB\n", style="dim") - except: - continue - except: - text.append("Access Denied to Storage API", style="red") - + + if not snapshot.volumes: + text.append("No volumes detected.", style="dim") + return text + + text.append(f"{'VOLUME':<12} {'TYPE':<8} {'CAPACITY USAGE':<25} {'FREE':<10}\n", + style="dim") + text.append("─" * 60 + "\n", style="dim") + + for volume in snapshot.volumes: + color = value_to_heat_color(volume.percent) + text.append(f"{volume.mountpoint[:12]:<12}", style="cyan") + text.append(f"{volume.fstype:<8}", style="dim") + text.append(make_bar(volume.percent, 100, 20) + f" {volume.percent:>3.0f}% ", + style=color) + text.append(f"{volume.free / GIB:>6.1f} GB\n", style="dim") + return text diff --git a/tests/test_app_integration.py b/tests/test_app_integration.py new file mode 100644 index 0000000..0b66467 --- /dev/null +++ b/tests/test_app_integration.py @@ -0,0 +1,276 @@ +"""End-to-end tests driving the real UI from a deterministic fake machine. + +Because the app takes an injectable source, the whole interface can now be +exercised with no real system access - which is what makes these assertions +about consistency possible at all. +""" +import asyncio +import threading + +import pytest + +from pulse.app import PulseApp +from pulse.core import MockSource +from pulse.panels.base import Panel + +pytestmark = pytest.mark.asyncio + +PANEL_IDS = [ + "cpu-panel", "memory-panel", "net-panel", "disk-panel", + "storage-panel", "process-panel", "insight-panel", "main-panel", + "docker-panel", +] + + +async def boot(pilot, app, wait_for_sample=True): + """Settle the app, dismiss the boot screen, and wait for the first tick. + + Freezing afterwards stops the periodic timer, so a test that drives ticks + itself is not racing an interval firing in the background. + """ + await pilot.pause() + await asyncio.sleep(0.3) + await pilot.pause() + while len(app.screen_stack) > 1: + app.pop_screen() + await pilot.pause() + + if wait_for_sample: + for _ in range(60): + if app.store.ready: + break + await asyncio.sleep(0.05) + await pilot.pause() + + await quiesce(pilot, app) + + +async def quiesce(pilot, app): + """Stop periodic sampling and let any in-flight tick finish.""" + app.frozen = True + await app.workers.wait_for_complete() + await pilot.pause() + + +async def tick(pilot, app): + """Take exactly one sample and wait for it to be applied. + + Goes through the worker rather than the interval timer, so the test + controls precisely how many snapshots exist and what is between them. + """ + before = app.store.tick_count + worker = app._sample_metrics() + await worker.wait() + for _ in range(40): + if app.store.tick_count > before: + break + await asyncio.sleep(0.02) + await pilot.pause() + await pilot.pause() + + +class TestSamplingPipeline: + async def test_app_runs_on_a_mock_machine(self): + app = PulseApp(source=MockSource()) + async with app.run_test(size=(140, 45)) as pilot: + await boot(pilot, app) + + assert app.store.ready + assert app.store.snapshot.cpu.core_count == 4 + assert app.store.snapshot.healthy + + async def test_each_subsystem_is_sampled_once_per_tick(self): + """The core fix: four panels used to ask for CPU independently.""" + source = MockSource() + app = PulseApp(source=source) + async with app.run_test(size=(140, 45)) as pilot: + await boot(pilot, app) + ticks = app.store.tick_count + + assert source.call_counts["cpu"] == ticks + assert source.call_counts["memory"] == ticks + assert source.call_counts["network"] == ticks + + async def test_every_panel_reads_the_same_snapshot(self): + """One tick, one set of numbers, everywhere on screen.""" + app = PulseApp(source=MockSource()) + async with app.run_test(size=(140, 45)) as pilot: + await boot(pilot, app) + + snapshots = {id(panel.snapshot) for panel in app.query(Panel)} + assert len(snapshots) == 1 + + async def test_sampling_runs_off_the_event_loop(self): + """Reading /proc and walking sockets must not happen on the render thread.""" + sampled_on = [] + + class ThreadRecordingSource(MockSource): + def cpu(self): + sampled_on.append(threading.get_ident()) + return super().cpu() + + app = PulseApp(source=ThreadRecordingSource()) + async with app.run_test(size=(140, 45)) as pilot: + await boot(pilot, app) + + event_loop_thread = threading.get_ident() + assert sampled_on, "no sampling happened" + assert all(ident != event_loop_thread for ident in sampled_on), ( + "sampling ran on the event loop thread - the UI will stutter" + ) + + async def test_snapshots_are_applied_on_the_event_loop(self): + """Widget updates must come back to the main thread to be safe.""" + applied_on = [] + app = PulseApp(source=MockSource()) + original = app.apply_snapshot + + def record(snapshot): + applied_on.append(threading.get_ident()) + original(snapshot) + + app.apply_snapshot = record + + async with app.run_test(size=(140, 45)) as pilot: + await boot(pilot, app) + assert applied_on + assert all(ident == threading.get_ident() for ident in applied_on) + + async def test_freeze_stops_sampling(self): + source = MockSource() + app = PulseApp(source=source) + async with app.run_test(size=(140, 45)) as pilot: + await boot(pilot, app) + + # boot() leaves the app frozen; start from the running state. + app.frozen = False + await pilot.pause() + + await pilot.press("f") + await pilot.pause() + assert app.frozen is True + + before = source.call_counts["cpu"] + app.refresh_data() + await pilot.pause() + await asyncio.sleep(0.2) + + assert source.call_counts["cpu"] == before + + +class TestRenderingBeforeData: + async def test_panels_render_before_the_first_snapshot(self): + """A slow first sample must not crash the UI with None everywhere.""" + app = PulseApp(source=MockSource()) + async with app.run_test(size=(140, 45)) as pilot: + await boot(pilot, app, wait_for_sample=False) + + # Force a repaint with an empty store. + app.store._snapshot = None + app.refresh_panels() + await pilot.pause() + + for panel in app.query(Panel): + assert panel.get_detailed_view() is not None + + +class TestPanelRendering: + @pytest.mark.parametrize("panel_id", PANEL_IDS) + async def test_panel_renders_every_view(self, panel_id): + app = PulseApp(source=MockSource()) + async with app.run_test(size=(140, 45)) as pilot: + await boot(pilot, app) + panel = app.query_one(f"#{panel_id}") + + panel.update_data() + assert panel.get_detailed_view() is not None + if hasattr(panel, "get_transcendence_view"): + assert panel.get_transcendence_view() is not None + + @pytest.mark.parametrize("panel_id", PANEL_IDS) + async def test_panel_opens_full_screen(self, panel_id): + app = PulseApp(source=MockSource()) + async with app.run_test(size=(140, 45)) as pilot: + await boot(pilot, app) + + app.query_one(f"#{panel_id}").focus() + await pilot.pause() + await pilot.press("x") + await pilot.pause() + await asyncio.sleep(0.2) + + assert type(app.screen).__name__ == "ImmersiveScreen" + + await pilot.press("escape") + await pilot.pause() + + +class TestDegradedTelemetry: + async def test_a_failing_subsystem_does_not_break_the_ui(self): + """A restricted net_connections should cost the network panel, not the app.""" + app = PulseApp(source=MockSource(fail={"network"})) + async with app.run_test(size=(140, 45)) as pilot: + await boot(pilot, app) + + snapshot = app.store.snapshot + assert "network" in snapshot.errors + # Everything else still rendered. + assert snapshot.cpu.core_count == 4 + assert len(snapshot.processes) == 12 + app.refresh_panels() + await pilot.pause() + + async def test_total_telemetry_failure_still_renders(self): + broken = {"cpu", "memory", "disk_io", "network", + "processes", "volumes", "system", "containers"} + app = PulseApp(source=MockSource(fail=broken)) + async with app.run_test(size=(140, 45)) as pilot: + await boot(pilot, app) + + assert len(app.store.snapshot.errors) == 8 + app.refresh_panels() + await pilot.pause() + + +class TestDerivedValuesReachTheUI: + async def test_rates_are_available_after_two_ticks(self): + source = MockSource() + app = PulseApp(source=source) + async with app.run_test(size=(140, 45)) as pilot: + await boot(pilot, app) + + # Advance the fake machine so its counters actually move, then take + # one controlled sample. tick() bypasses the periodic timer, so the + # two snapshots being compared are the two this test produced. + source.advance(10) + await tick(pilot, app) + + assert app.store.tick_count >= 2 + assert app.store.rates.interval > 0 + assert app.store.rates.net_recv_kbps > 0 + + async def test_history_accumulates_across_ticks(self): + source = MockSource() + app = PulseApp(source=source) + async with app.run_test(size=(140, 45)) as pilot: + await boot(pilot, app) + + for _ in range(3): + source.advance() + await tick(pilot, app) + + assert len(app.store.history.cpu) >= 4 + assert app.store.peaks.cpu > 0 + + async def test_rates_stay_zero_when_the_machine_is_idle(self): + """No counter movement between ticks must read as zero, not as noise.""" + source = MockSource() + app = PulseApp(source=source) + async with app.run_test(size=(140, 45)) as pilot: + await boot(pilot, app) + + # Sample twice without advancing: the counters are identical. + await tick(pilot, app) + + assert app.store.rates.net_recv_kbps == 0.0 + assert app.store.rates.disk_read_mbps == 0.0 diff --git a/tests/test_core_models.py b/tests/test_core_models.py new file mode 100644 index 0000000..f0c6651 --- /dev/null +++ b/tests/test_core_models.py @@ -0,0 +1,116 @@ +"""Tests for the immutable sample types.""" +import dataclasses + +import pytest + +from pulse.core.models import ( + CpuSample, + MemorySample, + NetworkSample, + InterfaceInfo, + ProcessSample, + Snapshot, +) + + +class TestImmutability: + @pytest.mark.parametrize("sample", [ + CpuSample(per_core=(1.0,)), + MemorySample(total=100), + NetworkSample(bytes_sent=1), + ProcessSample(pid=1), + Snapshot(), + ]) + def test_samples_cannot_be_mutated(self, sample): + """A snapshot is shared across every panel, so it has to be read-only.""" + field_name = dataclasses.fields(sample)[0].name + with pytest.raises(dataclasses.FrozenInstanceError): + setattr(sample, field_name, None) + + +class TestCpuSample: + def test_percent_is_the_mean_of_cores(self): + assert CpuSample(per_core=(0.0, 50.0, 100.0, 50.0)).percent == 50.0 + + def test_empty_sample_reports_zero(self): + assert CpuSample().percent == 0.0 + assert CpuSample().core_count == 0 + + def test_core_count_follows_the_tuple(self): + assert CpuSample(per_core=(1.0, 2.0, 3.0)).core_count == 3 + + +class TestMemorySample: + def test_swap_percent(self): + sample = MemorySample(swap_total=1000, swap_used=250) + assert sample.swap_percent == 25.0 + + def test_swap_percent_without_swap_is_zero(self): + """Machines with swap disabled must not divide by zero.""" + assert MemorySample(swap_total=0, swap_used=0).swap_percent == 0.0 + + +class TestNetworkSample: + def test_primary_ipv4_skips_loopback(self): + sample = NetworkSample(interfaces=( + InterfaceInfo("lo", is_up=True, ipv4="127.0.0.1"), + InterfaceInfo("eth0", is_up=True, ipv4="10.0.0.5"), + )) + assert sample.primary_ipv4 == "10.0.0.5" + + def test_primary_ipv4_is_none_when_only_loopback(self): + sample = NetworkSample(interfaces=( + InterfaceInfo("lo", is_up=True, ipv4="127.0.0.1"), + )) + assert sample.primary_ipv4 is None + + def test_active_interfaces_counts_only_up(self): + sample = NetworkSample(interfaces=( + InterfaceInfo("eth0", is_up=True), + InterfaceInfo("wlan0", is_up=False), + InterfaceInfo("lo", is_up=True), + )) + assert sample.active_interfaces == 2 + + +class TestProcessSample: + def test_memory_percent(self): + process = ProcessSample(pid=1, memory_bytes=512) + assert process.memory_percent(2048) == 25.0 + + def test_memory_percent_without_total_is_zero(self): + assert ProcessSample(pid=1, memory_bytes=512).memory_percent(0) == 0.0 + + +class TestSnapshot: + def _snapshot(self): + return Snapshot(processes=( + ProcessSample(pid=1, name="low", cpu_percent=1.0, memory_bytes=900), + ProcessSample(pid=2, name="high", cpu_percent=90.0, memory_bytes=100), + ProcessSample(pid=3, name="mid", cpu_percent=50.0, memory_bytes=500), + )) + + def test_top_processes_by_cpu(self): + top = self._snapshot().top_processes("cpu", limit=2) + assert [p.name for p in top] == ["high", "mid"] + + def test_top_processes_by_memory(self): + top = self._snapshot().top_processes("mem", limit=2) + assert [p.name for p in top] == ["low", "mid"] + + def test_top_processes_respects_limit(self): + assert len(self._snapshot().top_processes(limit=1)) == 1 + + def test_find_process(self): + assert self._snapshot().find_process(2).name == "high" + assert self._snapshot().find_process(999) is None + + def test_healthy_reflects_errors(self): + assert Snapshot().healthy + assert not Snapshot(errors={"cpu": "boom"}).healthy + + def test_defaults_are_independent_between_instances(self): + """Mutable defaults shared across snapshots would be a real hazard.""" + first, second = Snapshot(), Snapshot() + assert first.cpu is not second.cpu + assert first.errors is not second.errors diff --git a/tests/test_core_sampler.py b/tests/test_core_sampler.py new file mode 100644 index 0000000..5eeaf88 --- /dev/null +++ b/tests/test_core_sampler.py @@ -0,0 +1,113 @@ +"""Tests for the sampler, mostly about what happens when a subsystem breaks.""" +import pytest + +from pulse.core.models import Snapshot +from pulse.core.sampler import Sampler +from pulse.core.sources.mock import MockSource + + +def make_sampler(**kwargs): + source = MockSource(**kwargs) + return Sampler(source, clock=lambda: 1000.0), source + + +class TestSampling: + def test_produces_a_complete_snapshot(self): + sampler, source = make_sampler() + source.advance() + + snapshot = sampler.sample() + + assert isinstance(snapshot, Snapshot) + assert snapshot.timestamp == 1000.0 + assert snapshot.cpu.core_count == 4 + assert snapshot.memory.total > 0 + assert len(snapshot.processes) == 12 + assert len(snapshot.volumes) == 2 + assert snapshot.system.hostname == "mock-host" + assert snapshot.healthy + + def test_reads_each_subsystem_exactly_once_per_tick(self): + """The bug this design replaces: four panels sampling CPU separately.""" + sampler, source = make_sampler() + + sampler.sample() + + assert source.call_counts["cpu"] == 1 + assert source.call_counts["memory"] == 1 + assert source.call_counts["network"] == 1 + + def test_primes_the_source_before_the_first_sample(self): + sampler, source = make_sampler() + assert not source.primed + + sampler.sample() + + assert source.primed + + def test_priming_happens_only_once(self): + sampler, source = make_sampler() + sampler.sample() + source.primed = False + sampler.sample() + assert not source.primed # not re-primed + + def test_snapshots_are_independent(self): + sampler, source = make_sampler() + first = sampler.sample() + source.advance(5) + second = sampler.sample() + assert first.cpu.per_core != second.cpu.per_core + + +class TestFaultIsolation: + def test_one_broken_subsystem_does_not_break_the_tick(self): + sampler, _ = make_sampler(fail={"network"}) + + snapshot = sampler.sample() + + # Network is empty and flagged... + assert "network" in snapshot.errors + assert snapshot.network.bytes_sent == 0 + # ...but everything else still came through. + assert snapshot.cpu.core_count == 4 + assert snapshot.memory.total > 0 + assert len(snapshot.processes) == 12 + assert not snapshot.healthy + + def test_records_the_error_text(self): + sampler, _ = make_sampler(fail={"cpu"}) + snapshot = sampler.sample() + assert "RuntimeError" in snapshot.errors["cpu"] + + def test_every_subsystem_failing_still_returns_a_snapshot(self): + """A totally hostile machine should degrade, not crash the app.""" + sampler, _ = make_sampler(fail={ + "cpu", "memory", "disk_io", "network", + "processes", "volumes", "system", "containers", + }) + + snapshot = sampler.sample() + + assert isinstance(snapshot, Snapshot) + assert len(snapshot.errors) == 8 + assert snapshot.cpu.percent == 0.0 + assert snapshot.processes == () + + def test_sample_never_raises(self): + class Hostile: + def __getattr__(self, name): + def explode(): + raise OSError("everything is broken") + return explode + + snapshot = Sampler(Hostile()).sample() + assert isinstance(snapshot, Snapshot) + + def test_failed_priming_does_not_stop_sampling(self): + class BadPrime(MockSource): + def prime(self): + raise OSError("cannot prime") + + snapshot = Sampler(BadPrime()).sample() + assert snapshot.cpu.core_count == 4 diff --git a/tests/test_core_sources.py b/tests/test_core_sources.py new file mode 100644 index 0000000..9d7e879 --- /dev/null +++ b/tests/test_core_sources.py @@ -0,0 +1,238 @@ +"""Tests for the metric sources. + +SystemSource runs against the real machine, so assertions here are about shape +and plausibility rather than exact values. The point is that every method +returns a well-formed model on this platform and that the sampler contract +holds. +""" +import os + +import pytest + +from pulse.core.models import ( + ContainerSample, + CpuSample, + DiskIOSample, + MemorySample, + NetworkSample, + ProcessSample, + SystemSample, + VolumeSample, +) +from pulse.core.sampler import Sampler +from pulse.core.sources import MetricSource, MockSource, SystemSource +from pulse.core.sources import system as system_module + + +@pytest.fixture(scope="module") +def system_source(): + source = SystemSource() + source.prime() + return source + + +class TestProtocolConformance: + @pytest.mark.parametrize("source", [SystemSource(), MockSource()]) + def test_sources_satisfy_the_protocol(self, source): + assert isinstance(source, MetricSource) + + +class TestSystemSourceShape: + def test_cpu(self, system_source): + cpu = system_source.cpu() + assert isinstance(cpu, CpuSample) + assert cpu.core_count > 0 + assert all(0.0 <= value <= 100.0 for value in cpu.per_core) + assert 0.0 <= cpu.percent <= 100.0 + + def test_memory(self, system_source): + memory = system_source.memory() + assert isinstance(memory, MemorySample) + assert memory.total > 0 + assert 0.0 <= memory.percent <= 100.0 + assert memory.used <= memory.total + assert 0.0 <= memory.swap_percent <= 100.0 + + def test_disk_io(self, system_source): + disk = system_source.disk_io() + assert isinstance(disk, DiskIOSample) + assert disk.read_bytes >= 0 + assert disk.write_bytes >= 0 + + def test_network(self, system_source): + network = system_source.network() + assert isinstance(network, NetworkSample) + assert network.bytes_sent >= 0 + assert network.bytes_recv >= 0 + assert len(network.interfaces) > 0 + + def test_processes_include_this_one(self, system_source): + processes = system_source.processes() + assert len(processes) > 0 + assert all(isinstance(p, ProcessSample) for p in processes) + assert os.getpid() in {p.pid for p in processes} + + def test_process_memory_is_plausible(self, system_source): + """Guards the statm mix-up that reported virtual size as resident.""" + me = next(p for p in system_source.processes() if p.pid == os.getpid()) + assert me.memory_bytes > 0 + # A Python test process using more than 8 GB resident means we are + # reading the wrong field again. + assert me.memory_bytes < 8 * 1024 ** 3 + + def test_volumes(self, system_source): + volumes = system_source.volumes() + assert all(isinstance(v, VolumeSample) for v in volumes) + for volume in volumes: + assert 0.0 <= volume.percent <= 100.0 + assert volume.used <= volume.total + + def test_system(self, system_source): + info = system_source.system() + assert isinstance(info, SystemSample) + assert info.hostname + assert info.platform_name + assert info.boot_time > 0 + + def test_system_facts_are_cached_but_battery_is_not(self, system_source): + """Host identity cannot change mid-session; battery level can.""" + first, second = system_source.system(), system_source.system() + assert first.hostname == second.hostname + assert first.boot_time == second.boot_time + + def test_containers_empty_without_a_controller(self, system_source): + assert system_source.containers() == () + + +class TestProcessSelection: + """The snapshot must keep the heaviest processes, not the lowest PIDs. + + Regression test: the source used to pass an unsorted limit straight to + direct_os, so on a host with more processes than the cap it returned an + arbitrary prefix in PID order. Every high PID was dropped - including the + busiest processes, and Pulse itself. It only showed up on macOS CI, where + the runner has enough processes to cross the cap. + """ + + def _raw(self, count): + # Ascending PID, and deliberately heaviest at the highest PIDs, so a + # prefix-based selection keeps exactly the wrong ones. + return [ + {"pid": 1000 + i, "name": f"p{i}", + "cpu_percent": float(i), "memory_info": i * 1024} + for i in range(count) + ] + + def test_keeps_the_busiest_processes(self): + raw = self._raw(system_module.PROCESSES_PER_AXIS * 3) + selected = {entry["pid"] for entry in SystemSource._select(raw)} + busiest = max(raw, key=lambda e: e["cpu_percent"]) + assert busiest["pid"] in selected + + def test_keeps_the_largest_processes(self): + raw = self._raw(system_module.PROCESSES_PER_AXIS * 3) + selected = {entry["pid"] for entry in SystemSource._select(raw)} + largest = max(raw, key=lambda e: e["memory_info"]) + assert largest["pid"] in selected + + def test_does_not_select_by_pid_order(self): + raw = self._raw(system_module.PROCESSES_PER_AXIS * 3) + selected = [entry["pid"] for entry in SystemSource._select(raw)] + assert selected != [entry["pid"] for entry in raw[:len(selected)]] + + def test_selection_is_bounded(self): + raw = self._raw(5000) + selected = SystemSource._select(raw) + assert len(selected) <= system_module.PROCESSES_PER_AXIS * 2 + + def test_small_machines_keep_everything(self): + raw = self._raw(10) + selected = SystemSource._select(raw) + assert len(selected) == 10 + + def test_no_duplicates_when_a_process_is_heavy_on_both_axes(self): + raw = self._raw(20) + pids = [entry["pid"] for entry in SystemSource._select(raw)] + assert len(pids) == len(set(pids)) + + def test_handles_an_empty_machine(self): + assert SystemSource._select([]) == [] + + +class TestSystemSourceContainers: + def test_containers_come_from_the_controller(self): + class FakeController: + def get_containers(self): + return [{ + "id": "abc", "name": "web", "image": "nginx", + "status": "running", "state": "running", + }] + + containers = SystemSource(FakeController()).containers() + assert len(containers) == 1 + assert isinstance(containers[0], ContainerSample) + assert containers[0].name == "web" + + +class TestSystemSourceEndToEnd: + def test_sampler_produces_a_healthy_snapshot_from_the_real_machine(self): + snapshot = Sampler(SystemSource()).sample() + + assert snapshot.cpu.core_count > 0 + assert snapshot.memory.total > 0 + assert len(snapshot.processes) > 0 + # A subsystem may legitimately be restricted (net_connections on macOS), + # but nothing should have thrown an unexpected error type. + for name, message in snapshot.errors.items(): + pytest.fail(f"unexpected sampling failure in {name}: {message}") + + def test_consecutive_samples_yield_usable_rates(self): + from pulse.core.store import MetricStore + + sampler = Sampler(SystemSource()) + store = MetricStore() + store.push(sampler.sample()) + store.push(sampler.sample()) + + assert store.rates.interval > 0 + assert store.rates.net_sent_kbps >= 0 + assert store.rates.disk_read_mbps >= 0 + + +class TestMockSource: + def test_is_deterministic(self): + first, second = MockSource(), MockSource() + first.advance(3) + second.advance(3) + assert first.cpu().per_core == second.cpu().per_core + + def test_advancing_changes_readings(self): + source = MockSource() + before = source.cpu().per_core + source.advance(2) + assert source.cpu().per_core != before + + def test_counters_increase_monotonically(self): + source = MockSource() + source.advance() + first = source.network().bytes_sent + source.advance() + assert source.network().bytes_sent > first + + def test_processes_are_sorted_by_cpu_descending(self): + loads = [p.cpu_percent for p in MockSource().processes()] + assert loads == sorted(loads, reverse=True) + + def test_failure_injection(self): + source = MockSource(fail={"cpu"}) + with pytest.raises(RuntimeError): + source.cpu() + # Other subsystems are unaffected. + assert source.memory().total > 0 + + def test_call_counts_are_recorded(self): + source = MockSource() + source.cpu() + source.cpu() + source.memory() + assert source.call_counts == {"cpu": 2, "memory": 1} diff --git a/tests/test_core_store.py b/tests/test_core_store.py new file mode 100644 index 0000000..a70ab74 --- /dev/null +++ b/tests/test_core_store.py @@ -0,0 +1,209 @@ +"""Tests for the store: derived rates, history bounds, and peaks.""" +import pytest + +from pulse.core.models import ( + CpuSample, + DiskIOSample, + MemorySample, + NetworkSample, + Snapshot, +) +from pulse.core.store import MetricStore + + +def snapshot(timestamp=0.0, cpu=0.0, memory=0.0, sent=0, recv=0, + read=0, write=0, read_count=0, write_count=0, + read_time=0, write_time=0, cores=4): + return Snapshot( + timestamp=timestamp, + cpu=CpuSample(per_core=tuple([cpu] * cores)), + memory=MemorySample(total=1000, used=int(memory * 10), percent=memory), + network=NetworkSample(bytes_sent=sent, bytes_recv=recv), + disk_io=DiskIOSample(read_bytes=read, write_bytes=write, + read_count=read_count, write_count=write_count, + read_time_ms=read_time, write_time_ms=write_time), + ) + + +class TestFirstTick: + def test_store_starts_empty(self): + store = MetricStore() + assert not store.ready + assert store.snapshot is None + assert store.tension == 0.0 + + def test_first_push_has_no_rates(self): + """There is nothing to diff against yet, so rates must read zero.""" + store = MetricStore() + store.push(snapshot(timestamp=100.0, sent=5000)) + + assert store.ready + assert store.rates.net_sent_kbps == 0.0 + assert store.rates.interval == 0.0 + + +class TestDerivedRates: + def test_network_rate_uses_real_elapsed_time(self): + store = MetricStore() + store.push(snapshot(timestamp=100.0, sent=0, recv=0)) + # 2048 bytes over 2 seconds = 1 KB/s + store.push(snapshot(timestamp=102.0, sent=2048, recv=4096)) + + assert store.rates.net_sent_kbps == pytest.approx(1.0) + assert store.rates.net_recv_kbps == pytest.approx(2.0) + + def test_rate_scales_with_the_interval(self): + """The old code divided by an assumed 1s, so a 4s tick read 4x high.""" + store = MetricStore() + store.push(snapshot(timestamp=0.0, sent=0)) + store.push(snapshot(timestamp=4.0, sent=4096)) + + assert store.rates.net_sent_kbps == pytest.approx(1.0) + assert store.rates.interval == pytest.approx(4.0) + + def test_disk_throughput(self): + mib = 1024 ** 2 + store = MetricStore() + store.push(snapshot(timestamp=0.0)) + store.push(snapshot(timestamp=2.0, read=10 * mib, write=4 * mib)) + + assert store.rates.disk_read_mbps == pytest.approx(5.0) + assert store.rates.disk_write_mbps == pytest.approx(2.0) + + def test_disk_latency_is_time_over_ops(self): + store = MetricStore() + store.push(snapshot(timestamp=0.0)) + store.push(snapshot(timestamp=1.0, read_count=10, read_time=50, + write_count=4, write_time=20)) + + assert store.rates.disk_read_latency_ms == pytest.approx(5.0) + assert store.rates.disk_write_latency_ms == pytest.approx(5.0) + + def test_latency_without_operations_is_zero(self): + store = MetricStore() + store.push(snapshot(timestamp=0.0)) + store.push(snapshot(timestamp=1.0, read_count=0, read_time=0)) + assert store.rates.disk_read_latency_ms == 0.0 + + def test_counter_reset_does_not_produce_negative_rates(self): + """Interfaces going away can make a cumulative counter go backwards.""" + store = MetricStore() + store.push(snapshot(timestamp=0.0, sent=100000)) + store.push(snapshot(timestamp=1.0, sent=5)) + + assert store.rates.net_sent_kbps == 0.0 + + def test_zero_interval_is_survivable(self): + store = MetricStore() + store.push(snapshot(timestamp=5.0, sent=0)) + store.push(snapshot(timestamp=5.0, sent=9999)) + assert store.rates.net_sent_kbps == 0.0 + + def test_backwards_clock_is_survivable(self): + store = MetricStore() + store.push(snapshot(timestamp=10.0)) + store.push(snapshot(timestamp=9.0, sent=1024)) + assert store.rates.net_sent_kbps == 0.0 + + +class TestHistory: + def test_history_grows_with_ticks(self): + store = MetricStore() + for i in range(5): + store.push(snapshot(timestamp=float(i), cpu=10.0 * i)) + + assert list(store.history.cpu) == [0.0, 10.0, 20.0, 30.0, 40.0] + + def test_history_is_bounded(self): + store = MetricStore(history_length=10) + for i in range(50): + store.push(snapshot(timestamp=float(i), cpu=float(i))) + + assert len(store.history.cpu) == 10 + # Keeps the newest samples, not the oldest. + assert list(store.history.cpu)[-1] == 49.0 + + def test_per_core_history_is_tracked(self): + store = MetricStore() + for i in range(3): + store.push(snapshot(timestamp=float(i), cpu=float(i * 10), cores=2)) + + assert store.per_core_history(0) == (0.0, 10.0, 20.0) + assert store.per_core_history(1) == (0.0, 10.0, 20.0) + + def test_per_core_history_out_of_range_is_empty(self): + store = MetricStore() + store.push(snapshot(cores=2)) + assert store.per_core_history(99) == () + + def test_core_count_growing_is_handled(self): + """CPU hotplug should not raise.""" + store = MetricStore() + store.push(snapshot(timestamp=0.0, cores=2)) + store.push(snapshot(timestamp=1.0, cores=8)) + assert len(store.per_core_history(7)) == 1 + + +class TestPeaks: + def test_tracks_session_highs(self): + store = MetricStore() + store.push(snapshot(timestamp=0.0, cpu=20.0, memory=30.0)) + store.push(snapshot(timestamp=1.0, cpu=90.0, memory=40.0)) + store.push(snapshot(timestamp=2.0, cpu=10.0, memory=20.0)) + + assert store.peaks.cpu == 90.0 + assert store.peaks.memory == 40.0 + + def test_network_peaks_track_rates(self): + store = MetricStore() + store.push(snapshot(timestamp=0.0)) + store.push(snapshot(timestamp=1.0, sent=10240)) + store.push(snapshot(timestamp=2.0, sent=10240)) + + assert store.peaks.net_sent_kbps == pytest.approx(10.0) + + +class TestTension: + def test_blends_cpu_memory_and_io(self): + store = MetricStore() + store.push(snapshot(timestamp=0.0, cpu=100.0, memory=100.0)) + # 0.4 * 100 + 0.4 * 100 + 0 = 80 + assert store.tension == pytest.approx(80.0) + + def test_is_capped_at_100(self): + mib = 1024 ** 2 + store = MetricStore() + store.push(snapshot(timestamp=0.0)) + store.push(snapshot(timestamp=1.0, cpu=100.0, memory=100.0, + read=999 * mib, write=999 * mib)) + assert store.tension <= 100.0 + + def test_idle_machine_scores_low(self): + store = MetricStore() + store.push(snapshot(timestamp=0.0, cpu=0.0, memory=0.0)) + assert store.tension == 0.0 + + +class TestSessionTracking: + def test_uptime_measures_from_the_first_tick(self): + store = MetricStore() + store.push(snapshot(timestamp=1000.0)) + store.push(snapshot(timestamp=1042.0)) + assert store.uptime_seconds == pytest.approx(42.0) + + def test_tick_count(self): + store = MetricStore() + for i in range(7): + store.push(snapshot(timestamp=float(i))) + assert store.tick_count == 7 + + def test_network_baseline_reset_leaves_other_history(self): + store = MetricStore() + for i in range(5): + store.push(snapshot(timestamp=float(i), cpu=50.0, sent=i * 1024)) + + store.reset_network_baseline() + + assert len(store.history.net_sent) == 0 + assert store.peaks.net_sent_kbps == 0.0 + assert len(store.history.cpu) == 5 diff --git a/tests/test_ui_isolation.py b/tests/test_ui_isolation.py new file mode 100644 index 0000000..b14e115 --- /dev/null +++ b/tests/test_ui_isolation.py @@ -0,0 +1,94 @@ +"""Tests that the UI layer stays out of the metrics business. + +These are architectural guards. Before the core existed, panels sampled psutil +directly from their render path - 80-odd call sites, four of them asking for +CPU in the same tick and getting four different answers. If someone reaches for +psutil in a panel again, these fail. +""" +import ast +import pathlib + +import pytest + +import pulse + +PACKAGE_ROOT = pathlib.Path(pulse.__file__).parent + +# Directories that render. Nothing here may collect its own metrics. +UI_DIRECTORIES = ["panels", "screens", "aether"] + +# Modules allowed to touch the system directly, and why. +SAMPLING_MODULES = { + "direct_os.py", # the per-platform OS layer + "actions.py", # guarded kill/renice, needs process lookup + "system.py", # the one metric source + "container_api.py", # the Docker client +} + + +def _python_files(directory: str): + return sorted((PACKAGE_ROOT / directory).rglob("*.py")) + + +def _imported_modules(path: pathlib.Path): + """Top-level module names imported by a file.""" + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + modules = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + modules.add(alias.name.split(".")[0]) + elif isinstance(node, ast.ImportFrom) and node.module: + modules.add(node.module.split(".")[0]) + return modules + + +@pytest.mark.parametrize("directory", UI_DIRECTORIES) +def test_ui_layer_does_not_import_psutil(directory): + offenders = [ + path.name for path in _python_files(directory) + if "psutil" in _imported_modules(path) + ] + assert not offenders, ( + f"{directory}/ must read from the store, not sample directly. " + f"Offending files: {offenders}" + ) + + +@pytest.mark.parametrize("directory", UI_DIRECTORIES) +def test_ui_layer_does_not_shell_out(directory): + """A panel spawning a subprocess is a metrics call wearing a disguise.""" + offenders = [ + path.name for path in _python_files(directory) + if "subprocess" in _imported_modules(path) + ] + assert not offenders, f"{directory}/ should not run subprocesses: {offenders}" + + +def test_core_does_not_import_the_ui(): + """The dependency arrow points one way: UI depends on core, never back. + + This is what lets core be tested headless and reused by a future exporter. + """ + forbidden = {"textual", "rich"} + offenders = {} + + for path in (PACKAGE_ROOT / "core").rglob("*.py"): + leaked = _imported_modules(path) & forbidden + if leaked: + offenders[path.name] = sorted(leaked) + + assert not offenders, f"pulse.core must not import UI libraries: {offenders}" + + +def test_only_designated_modules_sample_the_system(): + """Keeps the set of files that touch psutil small and deliberate.""" + offenders = [ + str(path.relative_to(PACKAGE_ROOT)) + for path in PACKAGE_ROOT.rglob("*.py") + if "psutil" in _imported_modules(path) and path.name not in SAMPLING_MODULES + ] + assert not offenders, ( + "New psutil call site outside the sampling layer. Either read from the " + f"store or add the module to SAMPLING_MODULES deliberately: {offenders}" + )