From 6edbf3f3e6f08268425fbd6966bbf87e3f054fba Mon Sep 17 00:00:00 2001 From: Fatin-Ishraq Date: Mon, 17 Aug 2026 00:43:23 +0600 Subject: [PATCH 1/4] Add the core metrics layer Introduces pulse.core: immutable models, pluggable sources, a fault-isolating sampler, and a store that owns history and derived rates. Nothing in the package imports Textual or Rich, so it is testable with no real system access and reusable by a future exporter or headless mode. - models: frozen dataclasses; one tick is one Snapshot - sources: SystemSource (the only module that touches psutil/direct_os) and a deterministic MockSource - sampler: reads each subsystem independently, so a restricted net_connections degrades its own panel instead of blanking the tick - store: rates derived once per tick from real elapsed time, rather than each panel tracking its own counters and assuming a one-second interval No UI changes yet; panels still sample directly. 75 tests, 95% coverage on the package. Co-Authored-By: Claude Opus 5 --- src/pulse/core.py | 43 ---- src/pulse/core/__init__.py | 93 +++++++++ src/pulse/core/models.py | 217 +++++++++++++++++++ src/pulse/core/sampler.py | 72 +++++++ src/pulse/core/sources/__init__.py | 6 + src/pulse/core/sources/base.py | 56 +++++ src/pulse/core/sources/mock.py | 189 +++++++++++++++++ src/pulse/core/sources/system.py | 324 +++++++++++++++++++++++++++++ src/pulse/core/store.py | 209 +++++++++++++++++++ tests/test_core_models.py | 116 +++++++++++ tests/test_core_sampler.py | 113 ++++++++++ tests/test_core_sources.py | 182 ++++++++++++++++ tests/test_core_store.py | 209 +++++++++++++++++++ 13 files changed, 1786 insertions(+), 43 deletions(-) delete mode 100644 src/pulse/core.py create mode 100644 src/pulse/core/__init__.py create mode 100644 src/pulse/core/models.py create mode 100644 src/pulse/core/sampler.py create mode 100644 src/pulse/core/sources/__init__.py create mode 100644 src/pulse/core/sources/base.py create mode 100644 src/pulse/core/sources/mock.py create mode 100644 src/pulse/core/sources/system.py create mode 100644 src/pulse/core/store.py create mode 100644 tests/test_core_models.py create mode 100644 tests/test_core_sampler.py create mode 100644 tests/test_core_sources.py create mode 100644 tests/test_core_store.py 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..0d6776d --- /dev/null +++ b/src/pulse/core/sources/system.py @@ -0,0 +1,324 @@ +""" +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 +# Enough processes to fill any table view with room to sort. +MAX_PROCESSES = 300 + + +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, ...]: + raw = direct_os.get_process_list(limit=MAX_PROCESSES) + + samples = [] + for entry in 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 _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/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..f81f484 --- /dev/null +++ b/tests/test_core_sources.py @@ -0,0 +1,182 @@ +"""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 + + +@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 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 From 636a2664b70ee528ddac164152ca58020d3f12eb Mon Sep 17 00:00:00 2001 From: Fatin-Ishraq Date: Mon, 17 Aug 2026 00:56:29 +0600 Subject: [PATCH 2/4] Move metrics out of the widgets and onto a worker thread Ports every panel off direct psutil calls and onto the shared store, so one tick produces one Snapshot that the whole UI renders from. What this fixes: - Four panels used to call cpu_percent() independently in the same refresh, so four different CPU numbers could be on screen at once. Now there is one. - Sampling blocked the event loop: reading /proc, walking the socket table and stat-ing mount points all ran on the render thread. It now runs on a worker, and an overrunning tick is skipped instead of queueing behind itself. - Each panel derived its own rates against an assumed one-second interval. Rates are now computed once from the real elapsed time between snapshots. - DataTables were cleared and rebuilt every tick with cursor restore that mostly did not hold. Rows are diffed and updated in place. - A failing subsystem was swallowed by a bare except. It now degrades its own panel and is reported in the Insight view. - The Aether visualisation sampled psutil at 20 FPS on its own; it now takes metrics from the shared snapshot. Because the source is injectable, the entire UI can be driven from a deterministic MockSource with no real system access. That is what took coverage from 39% to 73% - the panels were previously untestable. Architectural tests parse imports and fail if anything under panels/, screens/ or aether/ reaches for psutil or subprocess, or if pulse.core imports Textual or Rich. 199 tests (was 94). CI floor raised to 65%, with 85% on the action and config modules and 90% on pulse.core. No visible redesign: same layout, panels, themes and keys. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 10 +- CHANGELOG.md | 33 +++ docs/AGENTS.md | 29 +- src/pulse/aether/engine.py | 72 ++--- src/pulse/app.py | 79 ++++-- src/pulse/panels/base.py | 30 ++ src/pulse/panels/cpu.py | 403 +++++++++++++-------------- src/pulse/panels/disk.py | 303 +++++++++----------- src/pulse/panels/docker.py | 173 ++++++------ src/pulse/panels/insight.py | 295 ++++++++++---------- src/pulse/panels/main_view.py | 208 +++++++------- src/pulse/panels/memory.py | 377 +++++++++++-------------- src/pulse/panels/network.py | 457 +++++++++++++----------------- src/pulse/panels/process.py | 451 ++++++++++++------------------ src/pulse/panels/storage.py | 503 +++++++++++++++------------------- tests/test_app_integration.py | 233 ++++++++++++++++ tests/test_ui_isolation.py | 94 +++++++ 17 files changed, 1936 insertions(+), 1814 deletions(-) create mode 100644 tests/test_app_integration.py create mode 100644 tests/test_ui_isolation.py 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/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..fae032a --- /dev/null +++ b/tests/test_app_integration.py @@ -0,0 +1,233 @@ +"""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.""" + 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() + + +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) + + await pilot.press("f") + await pilot.pause() + 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 counters actually move. + source.advance(10) + app.refresh_data() + for _ in range(40): + if app.store.tick_count >= 2: + break + await asyncio.sleep(0.05) + await pilot.pause() + + 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() + app.refresh_data() + await asyncio.sleep(0.15) + await pilot.pause() + + assert len(app.store.history.cpu) >= 2 + assert app.store.peaks.cpu > 0 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}" + ) From 363c10504c5d7c51aa74d0a44566ed922a0b2d84 Mon Sep 17 00:00:00 2001 From: Fatin-Ishraq Date: Mon, 17 Aug 2026 01:12:24 +0600 Subject: [PATCH 3/4] Make the integration tests drive ticks deterministically test_rates_are_available_after_two_ticks raced the app's own refresh timer: if an interval fired between advancing the mock machine and taking the sample, both snapshots landed on the same mock tick, the counter delta was genuinely zero, and the rate assertion failed. It passed on ubuntu 3.10 and windows 3.12 purely on timing. The fault was in the test, not the sampling code. Tests now freeze the app after boot and drive each sample through a `tick()` helper that waits on the worker, so the two snapshots being compared are the two the test produced. Also adds the inverse case: an idle machine must report a zero rate rather than noise. Verified with six consecutive runs of the integration suite. Co-Authored-By: Claude Opus 5 --- tests/test_app_integration.py | 69 ++++++++++++++++++++++++++++------- 1 file changed, 56 insertions(+), 13 deletions(-) diff --git a/tests/test_app_integration.py b/tests/test_app_integration.py index fae032a..0b66467 100644 --- a/tests/test_app_integration.py +++ b/tests/test_app_integration.py @@ -23,7 +23,11 @@ async def boot(pilot, app, wait_for_sample=True): - """Settle the app, dismiss the boot screen, and wait for the first tick.""" + """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() @@ -38,6 +42,32 @@ async def boot(pilot, app, wait_for_sample=True): 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): @@ -112,10 +142,15 @@ async def test_freeze_stops_sampling(self): 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() - before = source.call_counts["cpu"] + assert app.frozen is True + before = source.call_counts["cpu"] app.refresh_data() await pilot.pause() await asyncio.sleep(0.2) @@ -204,14 +239,11 @@ async def test_rates_are_available_after_two_ticks(self): async with app.run_test(size=(140, 45)) as pilot: await boot(pilot, app) - # Advance the fake machine so counters actually move. + # 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) - app.refresh_data() - for _ in range(40): - if app.store.tick_count >= 2: - break - await asyncio.sleep(0.05) - await pilot.pause() + await tick(pilot, app) assert app.store.tick_count >= 2 assert app.store.rates.interval > 0 @@ -225,9 +257,20 @@ async def test_history_accumulates_across_ticks(self): for _ in range(3): source.advance() - app.refresh_data() - await asyncio.sleep(0.15) - await pilot.pause() + await tick(pilot, app) - assert len(app.store.history.cpu) >= 2 + 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 From 58cb32777a65cef254ad6473fced989730f0a0ca Mon Sep 17 00:00:00 2001 From: Fatin-Ishraq Date: Mon, 17 Aug 2026 01:17:50 +0600 Subject: [PATCH 4/4] Select processes by weight instead of truncating in PID order SystemSource passed an unsorted limit straight to direct_os, so on a host with more processes than the cap it kept an arbitrary prefix - the 300 lowest PIDs. Every high PID was dropped, including the heaviest processes the panel exists to show, and Pulse itself. The snapshot now holds the union of the heaviest processes by CPU and by memory, so whichever way a view sorts, what it wants is present. The expensive per-process psutil lookups run only for that selection rather than for an arbitrary slice. Caught by the macOS CI jobs, where the runner has enough processes to cross the cap; ubuntu and windows runners stayed under it and hid the bug. Added tests that reproduce it directly with a synthetic oversized machine, so it no longer depends on how busy the runner happens to be. Co-Authored-By: Claude Opus 5 --- src/pulse/core/sources/system.py | 35 +++++++++++++++++--- tests/test_core_sources.py | 56 ++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 4 deletions(-) diff --git a/src/pulse/core/sources/system.py b/src/pulse/core/sources/system.py index 0d6776d..94a5435 100644 --- a/src/pulse/core/sources/system.py +++ b/src/pulse/core/sources/system.py @@ -30,8 +30,15 @@ # 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 -# Enough processes to fill any table view with room to sort. -MAX_PROCESSES = 300 + +# 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: @@ -193,10 +200,13 @@ def _interfaces() -> Tuple[InterfaceInfo, ...]: # Processes # ------------------------------------------------------------------ def processes(self) -> Tuple[ProcessSample, ...]: - raw = direct_os.get_process_list(limit=MAX_PROCESSES) + # 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 raw: + for entry in self._select(raw): pid = entry["pid"] username, status, threads = self._process_details(pid) samples.append(ProcessSample( @@ -210,6 +220,23 @@ def processes(self) -> Tuple[ProcessSample, ...]: )) 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.""" diff --git a/tests/test_core_sources.py b/tests/test_core_sources.py index f81f484..9d7e879 100644 --- a/tests/test_core_sources.py +++ b/tests/test_core_sources.py @@ -21,6 +21,7 @@ ) 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") @@ -103,6 +104,61 @@ 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: