Initial Commit
This commit is contained in:
145
.gitignore
vendored
Normal file
145
.gitignore
vendored
Normal file
@@ -0,0 +1,145 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
pip-wheel-metadata/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
.python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Exil Productions
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
80
README.md
Normal file
80
README.md
Normal file
@@ -0,0 +1,80 @@
|
||||
StatsMan is a terminal-based system monitoring tool that provides real-time system information using ASCII visualizations.
|
||||
|
||||
## Installation
|
||||
|
||||
### Using pipx
|
||||
|
||||
```bash
|
||||
pipx install statsman
|
||||
```
|
||||
|
||||
### Using pip
|
||||
|
||||
```bash
|
||||
pip install statsman
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Run the tool:
|
||||
|
||||
```bash
|
||||
statsman
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
```bash
|
||||
statsman --help # Show help
|
||||
statsman --refresh-rate 1.0 # Set refresh rate to 1 second
|
||||
statsman --no-color # Disable color output
|
||||
statsman --config ~/.statsman.yaml # Use a custom configuration file
|
||||
```
|
||||
|
||||
### Keyboard Controls
|
||||
|
||||
- `q` or `Ctrl+C`: Quit
|
||||
- `p`: Pause or resume updates
|
||||
- `c`: Sort processes by CPU usage
|
||||
- `m`: Sort processes by memory usage
|
||||
- `r`: Reset sorting
|
||||
- `↑` / `↓`: Navigate the process list
|
||||
- `Enter`: Terminate the selected process
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.8 or higher
|
||||
- Currently statsman is built only for Linux, Windows support may be added in the future
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
git clone https://github.com/ExilProductions/statsman.git
|
||||
cd statsman
|
||||
|
||||
pip install -e ".[dev]"
|
||||
|
||||
python -m statsman
|
||||
```
|
||||
|
||||
### Build wheel
|
||||
|
||||
To build a wheel distribution locally from the project root (where `pyproject.toml` lives):
|
||||
|
||||
```bash
|
||||
# Install the build backend
|
||||
python -m pip install build
|
||||
|
||||
# Build only the wheel
|
||||
python -m build --wheel
|
||||
```
|
||||
|
||||
The wheel file will be created in the `dist/` directory and can then be installed with:
|
||||
|
||||
```bash
|
||||
pip install dist/statsman-<version>-py3-none-any.whl
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
Released under the MIT License. See the [LICENSE](LICENSE) file for details.
|
||||
63
pyproject.toml
Normal file
63
pyproject.toml
Normal file
@@ -0,0 +1,63 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "statsman"
|
||||
version = "0.1.2"
|
||||
description = "A real-time terminal-based system monitoring tool with ASCII visualizations"
|
||||
authors = [{name = "Exil Productions", email = "exil.productions.business@gmail.com"}]
|
||||
license = {text = "MIT"}
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.8"
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Environment :: Console",
|
||||
"Intended Audience :: System Administrators",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: POSIX :: Linux",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.8",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Topic :: System :: Monitoring",
|
||||
"Topic :: System :: Systems Administration",
|
||||
]
|
||||
dependencies = [
|
||||
"psutil>=5.9.0",
|
||||
"rich>=13.0.0",
|
||||
"click>=8.0.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
statsman = "statsman.cli:main"
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://exil.dev"
|
||||
Repository = "https://github.com/ExilProductions/statsman"
|
||||
Issues = "https://github.com/ExilProductions/statsman/issues"
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"black>=23.0.0",
|
||||
"flake8>=6.0.0",
|
||||
"mypy>=1.0.0",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[tool.setuptools.package-dir]
|
||||
"" = "src"
|
||||
|
||||
[tool.black]
|
||||
line-length = 88
|
||||
target-version = ['py38']
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.8"
|
||||
warn_return_any = true
|
||||
warn_unused_configs = true
|
||||
disallow_untyped_defs = true
|
||||
9
requirements-dev.txt
Normal file
9
requirements-dev.txt
Normal file
@@ -0,0 +1,9 @@
|
||||
|
||||
psutil>=5.9.0
|
||||
rich>=13.0.0
|
||||
click>=8.0.0
|
||||
black>=23.0.0
|
||||
flake8>=6.0.0
|
||||
mypy>=1.0.0
|
||||
build>=1.0.0
|
||||
twine>=4.0.0
|
||||
3
src/statsman/__init__.py
Normal file
3
src/statsman/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
__version__ = "0.1.2"
|
||||
__author__ = "ExilProductions"
|
||||
__email__ = "exil.productions.business@gmail.com"
|
||||
9
src/statsman/__main__.py
Normal file
9
src/statsman/__main__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
try:
|
||||
from .cli import main
|
||||
except ImportError:
|
||||
print("Error: Could not import CLI module")
|
||||
import sys
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
102
src/statsman/app.py
Normal file
102
src/statsman/app.py
Normal file
@@ -0,0 +1,102 @@
|
||||
import sys
|
||||
import time
|
||||
import signal
|
||||
import threading
|
||||
|
||||
import click
|
||||
from rich.console import Console
|
||||
from rich.live import Live
|
||||
|
||||
from .system_monitor import SystemMonitor
|
||||
from .ui.dashboard import Dashboard
|
||||
|
||||
|
||||
class StatsManApp:
|
||||
def __init__(self, refresh_rate: float = 1.0, no_color: bool = False):
|
||||
self.refresh_rate = refresh_rate
|
||||
self.no_color = no_color
|
||||
|
||||
self.console = Console(color_system=None if no_color else "auto")
|
||||
self.dashboard = Dashboard(self.console, no_color)
|
||||
self.live = None
|
||||
self.running = False
|
||||
self.paused = False
|
||||
|
||||
signal.signal(signal.SIGINT, self._signal_handler)
|
||||
signal.signal(signal.SIGTERM, self._signal_handler)
|
||||
|
||||
def _signal_handler(self, signum, frame):
|
||||
self.running = False
|
||||
if self.live:
|
||||
self.live.stop()
|
||||
|
||||
def _handle_keyboard_input(self):
|
||||
try:
|
||||
import select
|
||||
import termios
|
||||
import tty
|
||||
|
||||
old_settings = termios.tcgetattr(sys.stdin)
|
||||
tty.setraw(sys.stdin.fileno())
|
||||
|
||||
while self.running:
|
||||
if select.select([sys.stdin], [], [], 0) == ([sys.stdin], [], []):
|
||||
char = sys.stdin.read(1)
|
||||
|
||||
if char.lower() == 'q':
|
||||
self.running = False
|
||||
break
|
||||
elif char.lower() == 'p':
|
||||
self.paused = not self.paused
|
||||
elif char.lower() == 'c':
|
||||
self.dashboard.set_process_sort('cpu')
|
||||
elif char.lower() == 'm':
|
||||
self.dashboard.set_process_sort('memory')
|
||||
elif char.lower() == 'r':
|
||||
self.dashboard.set_process_sort('cpu')
|
||||
|
||||
time.sleep(0.1)
|
||||
|
||||
except (ImportError, OSError):
|
||||
pass
|
||||
|
||||
finally:
|
||||
try:
|
||||
if 'old_settings' in locals():
|
||||
termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
|
||||
except:
|
||||
pass
|
||||
|
||||
def run(self):
|
||||
self.running = True
|
||||
|
||||
keyboard_thread = threading.Thread(target=self._handle_keyboard_input, daemon=True)
|
||||
keyboard_thread.start()
|
||||
|
||||
try:
|
||||
with Live(
|
||||
self.dashboard.render(),
|
||||
console=self.console,
|
||||
refresh_per_second=1.0 / self.refresh_rate,
|
||||
screen=True,
|
||||
auto_refresh=True,
|
||||
) as self.live:
|
||||
self.live = self.live
|
||||
|
||||
while self.running:
|
||||
if not self.paused:
|
||||
layout = self.dashboard.render()
|
||||
self.live.update(layout)
|
||||
|
||||
time.sleep(self.refresh_rate)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
finally:
|
||||
self.running = False
|
||||
if self.live:
|
||||
self.live.stop()
|
||||
|
||||
self.console.clear()
|
||||
self.console.print("StatsMan - Goodbye!", justify="center")
|
||||
26
src/statsman/cli.py
Normal file
26
src/statsman/cli.py
Normal file
@@ -0,0 +1,26 @@
|
||||
import click
|
||||
from .app import StatsManApp
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--refresh-rate",
|
||||
"-r",
|
||||
default=1.0,
|
||||
type=float,
|
||||
help="Refresh rate in seconds (default: 1.0)",
|
||||
)
|
||||
@click.option(
|
||||
"--no-color",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Disable colored output",
|
||||
)
|
||||
@click.version_option(version="0.1.0", prog_name="statsman")
|
||||
def main(refresh_rate: float, no_color: bool) -> None:
|
||||
app = StatsManApp(refresh_rate=refresh_rate, no_color=no_color)
|
||||
app.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
220
src/statsman/system_monitor.py
Normal file
220
src/statsman/system_monitor.py
Normal file
@@ -0,0 +1,220 @@
|
||||
import psutil
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Dict, Any
|
||||
from collections import deque
|
||||
|
||||
|
||||
@dataclass
|
||||
class CPUInfo:
|
||||
percent: float
|
||||
percent_per_core: List[float]
|
||||
frequency: float
|
||||
load_avg: List[float]
|
||||
count: int
|
||||
count_logical: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class MemoryInfo:
|
||||
total: int
|
||||
available: int
|
||||
used: int
|
||||
percent: float
|
||||
swap_total: int
|
||||
swap_used: int
|
||||
swap_percent: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class DiskInfo:
|
||||
total: int
|
||||
used: int
|
||||
free: int
|
||||
percent: float
|
||||
read_bytes: int
|
||||
write_bytes: int
|
||||
read_count: int
|
||||
write_count: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class NetworkInfo:
|
||||
bytes_sent: int
|
||||
bytes_recv: int
|
||||
packets_sent: int
|
||||
packets_recv: int
|
||||
interfaces: Dict[str, Dict[str, int]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProcessInfo:
|
||||
"""Process information."""
|
||||
pid: int
|
||||
name: str
|
||||
cpu_percent: float
|
||||
memory_percent: float
|
||||
memory_rss: int
|
||||
status: str
|
||||
cmdline: List[str]
|
||||
|
||||
|
||||
class SystemMonitor:
|
||||
def __init__(self, history_size: int = 60):
|
||||
self.history_size = history_size
|
||||
self.cpu_history = deque(maxlen=history_size)
|
||||
self.memory_history = deque(maxlen=history_size)
|
||||
self.network_history = deque(maxlen=history_size)
|
||||
self._last_net_io = psutil.net_io_counters()
|
||||
self._last_disk_io = psutil.disk_io_counters()
|
||||
self._last_time = time.time()
|
||||
|
||||
def get_cpu_info(self) -> CPUInfo:
|
||||
cpu_percent = psutil.cpu_percent(interval=0.1)
|
||||
cpu_percent_per_core = psutil.cpu_percent(interval=0.1, percpu=True)
|
||||
|
||||
try:
|
||||
freq = psutil.cpu_freq().current if psutil.cpu_freq() else 0.0
|
||||
except (AttributeError, OSError):
|
||||
freq = 0.0
|
||||
#linux only
|
||||
try:
|
||||
load_avg = list(psutil.getloadavg())
|
||||
except (AttributeError, OSError):
|
||||
load_avg = [0.0, 0.0, 0.0]
|
||||
|
||||
return CPUInfo(
|
||||
percent=cpu_percent,
|
||||
percent_per_core=cpu_percent_per_core,
|
||||
frequency=freq,
|
||||
load_avg=load_avg,
|
||||
count=psutil.cpu_count(logical=False) or 0,
|
||||
count_logical=psutil.cpu_count(logical=True) or 0,
|
||||
)
|
||||
|
||||
def get_memory_info(self) -> MemoryInfo:
|
||||
memory = psutil.virtual_memory()
|
||||
swap = psutil.swap_memory()
|
||||
|
||||
return MemoryInfo(
|
||||
total=memory.total,
|
||||
available=memory.available,
|
||||
used=memory.used,
|
||||
percent=memory.percent,
|
||||
swap_total=swap.total,
|
||||
swap_used=swap.used,
|
||||
swap_percent=swap.percent,
|
||||
)
|
||||
|
||||
def get_disk_info(self) -> DiskInfo:
|
||||
disk_usage = psutil.disk_usage('/')
|
||||
|
||||
current_disk_io = psutil.disk_io_counters()
|
||||
if current_disk_io and self._last_disk_io:
|
||||
time_delta = time.time() - self._last_time
|
||||
read_bytes = max(0, current_disk_io.read_bytes - self._last_disk_io.read_bytes)
|
||||
write_bytes = max(0, current_disk_io.write_bytes - self._last_disk_io.write_bytes)
|
||||
read_count = max(0, current_disk_io.read_count - self._last_disk_io.read_count)
|
||||
write_count = max(0, current_disk_io.write_count - self._last_disk_io.write_count)
|
||||
else:
|
||||
read_bytes = write_bytes = read_count = write_count = 0
|
||||
|
||||
self._last_disk_io = current_disk_io
|
||||
|
||||
return DiskInfo(
|
||||
total=disk_usage.total,
|
||||
used=disk_usage.used,
|
||||
free=disk_usage.free,
|
||||
percent=disk_usage.percent,
|
||||
read_bytes=read_bytes,
|
||||
write_bytes=write_bytes,
|
||||
read_count=read_count,
|
||||
write_count=write_count,
|
||||
)
|
||||
|
||||
def get_network_info(self) -> NetworkInfo:
|
||||
current_net_io = psutil.net_io_counters()
|
||||
|
||||
if current_net_io and self._last_net_io:
|
||||
bytes_sent = max(0, current_net_io.bytes_sent - self._last_net_io.bytes_sent)
|
||||
bytes_recv = max(0, current_net_io.bytes_recv - self._last_net_io.bytes_recv)
|
||||
packets_sent = max(0, current_net_io.packets_sent - self._last_net_io.packets_sent)
|
||||
packets_recv = max(0, current_net_io.packets_recv - self._last_net_io.packets_recv)
|
||||
else:
|
||||
bytes_sent = bytes_recv = packets_sent = packets_recv = 0
|
||||
|
||||
self._last_net_io = current_net_io
|
||||
|
||||
interfaces = {}
|
||||
try:
|
||||
net_if_addrs = psutil.net_if_addrs()
|
||||
net_io_stats = psutil.net_io_counters(pernic=True)
|
||||
|
||||
for interface_name in net_if_addrs:
|
||||
if interface_name in net_io_stats:
|
||||
interfaces[interface_name] = {
|
||||
'bytes_sent': net_io_stats[interface_name].bytes_sent,
|
||||
'bytes_recv': net_io_stats[interface_name].bytes_recv,
|
||||
'packets_sent': net_io_stats[interface_name].packets_sent,
|
||||
'packets_recv': net_io_stats[interface_name].packets_recv,
|
||||
}
|
||||
except (OSError, AttributeError):
|
||||
pass
|
||||
|
||||
return NetworkInfo(
|
||||
bytes_sent=bytes_sent,
|
||||
bytes_recv=bytes_recv,
|
||||
packets_sent=packets_sent,
|
||||
packets_recv=packets_recv,
|
||||
interfaces=interfaces,
|
||||
)
|
||||
|
||||
def get_process_info(self, limit: int = 10) -> List[ProcessInfo]:
|
||||
processes = []
|
||||
|
||||
for proc in psutil.process_iter(['pid', 'name', 'cpu_percent', 'memory_percent',
|
||||
'memory_info', 'status', 'cmdline']):
|
||||
try:
|
||||
pinfo = proc.info
|
||||
if pinfo['cpu_percent'] is None:
|
||||
pinfo['cpu_percent'] = 0.0
|
||||
if pinfo['memory_percent'] is None:
|
||||
pinfo['memory_percent'] = 0.0
|
||||
|
||||
processes.append(ProcessInfo(
|
||||
pid=pinfo['pid'],
|
||||
name=pinfo['name'] or 'Unknown',
|
||||
cpu_percent=pinfo['cpu_percent'],
|
||||
memory_percent=pinfo['memory_percent'],
|
||||
memory_rss=pinfo['memory_info'].rss if pinfo['memory_info'] else 0,
|
||||
status=pinfo['status'] or 'Unknown',
|
||||
cmdline=pinfo['cmdline'] or [],
|
||||
))
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
||||
continue
|
||||
|
||||
processes.sort(key=lambda p: p.cpu_percent, reverse=True)
|
||||
return processes[:limit]
|
||||
|
||||
def update_history(self) -> None:
|
||||
cpu_info = self.get_cpu_info()
|
||||
memory_info = self.get_memory_info()
|
||||
network_info = self.get_network_info()
|
||||
|
||||
self.cpu_history.append(cpu_info.percent)
|
||||
self.memory_history.append(memory_info.percent)
|
||||
self.network_history.append({
|
||||
'bytes_sent': network_info.bytes_sent,
|
||||
'bytes_recv': network_info.bytes_recv,
|
||||
})
|
||||
|
||||
self._last_time = time.time()
|
||||
|
||||
def get_cpu_history(self) -> List[float]:
|
||||
return list(self.cpu_history)
|
||||
|
||||
def get_memory_history(self) -> List[float]:
|
||||
return list(self.memory_history)
|
||||
|
||||
def get_network_history(self) -> List[Dict[str, int]]:
|
||||
return list(self.network_history)
|
||||
0
src/statsman/ui/__init__.py
Normal file
0
src/statsman/ui/__init__.py
Normal file
157
src/statsman/ui/charts.py
Normal file
157
src/statsman/ui/charts.py
Normal file
@@ -0,0 +1,157 @@
|
||||
from rich.console import Console, Group
|
||||
from rich.progress import Progress, BarColumn, TextColumn
|
||||
from rich.table import Table
|
||||
from rich.panel import Panel
|
||||
from rich.text import Text
|
||||
from rich.align import Align
|
||||
from typing import List, Dict, Any
|
||||
import math
|
||||
|
||||
|
||||
class ChartRenderer:
|
||||
def __init__(self, console: Console):
|
||||
self.console = console
|
||||
|
||||
def create_sparkline(self, data: List[float], width: int = 80, height: int = 8) -> str:
|
||||
if not data:
|
||||
return " " * width
|
||||
|
||||
min_val = min(data)
|
||||
max_val = max(data)
|
||||
range_val = max_val - min_val if max_val != min_val else 1
|
||||
|
||||
spark_chars = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']
|
||||
|
||||
recent_data = data[-width:] if len(data) > width else data
|
||||
|
||||
sparkline = ""
|
||||
for value in recent_data:
|
||||
normalized = (value - min_val) / range_val
|
||||
char_index = min(int(normalized * len(spark_chars)), len(spark_chars) - 1)
|
||||
sparkline += spark_chars[max(0, char_index)]
|
||||
|
||||
return sparkline
|
||||
|
||||
def create_vertical_bars(self, data: Dict[str, float], height: int = 12, width: int = 60) -> Panel:
|
||||
if not data:
|
||||
return Panel("No data", border_style="red")
|
||||
|
||||
max_val = max(data.values()) if data.values() else 1
|
||||
labels = list(data.keys())
|
||||
values = list(data.values())
|
||||
|
||||
bar_widths = [int((v / max_val) * width) if max_val > 0 else 0 for v in values]
|
||||
|
||||
lines = []
|
||||
for level in range(height, 0, -1):
|
||||
line = ""
|
||||
for bar_width in bar_widths:
|
||||
threshold = (level / height) * width
|
||||
if bar_width >= threshold:
|
||||
line += "█"
|
||||
else:
|
||||
line += " "
|
||||
line += " "
|
||||
lines.append(line)
|
||||
|
||||
label_line = ""
|
||||
for label in labels:
|
||||
label_line += f"{label[:8]:8} "
|
||||
|
||||
chart_text = "\n".join(lines + [label_line])
|
||||
return Panel(chart_text, border_style="blue")
|
||||
|
||||
def create_horizontal_bars(self, data: Dict[str, float], max_width: int = 70) -> Panel:
|
||||
if not data:
|
||||
return Panel("No data", border_style="red")
|
||||
|
||||
max_val = max(data.values()) if data.values() else 1
|
||||
|
||||
lines = []
|
||||
for label, value in data.items():
|
||||
bar_width = int((value / max_val) * max_width) if max_val > 0 else 0
|
||||
bar = "█" * bar_width + "░" * (max_width - bar_width)
|
||||
lines.append(f"{label:6} {bar} {value:5.1f}%")
|
||||
|
||||
return Panel("\n".join(lines), border_style="green")
|
||||
|
||||
def create_mini_process_table(self, processes: List[Any], limit: int = 12) -> Panel:
|
||||
if not processes:
|
||||
return Panel("No processes", border_style="red")
|
||||
|
||||
sorted_processes = sorted(processes, key=lambda p: p.cpu_percent, reverse=True)
|
||||
|
||||
lines = ["PID Name CPU MEM ", "=" * 55]
|
||||
|
||||
for proc in sorted_processes[:limit]:
|
||||
cpu_bar = self._create_mini_bar(proc.cpu_percent, 10)
|
||||
mem_bar = self._create_mini_bar(proc.memory_percent, 10)
|
||||
|
||||
name = proc.name[:14] + ".." if len(proc.name) > 16 else proc.name.ljust(16)
|
||||
lines.append(f"{proc.pid:<7} {name} {cpu_bar} {mem_bar}")
|
||||
|
||||
return Panel("\n".join(lines), title="Top Processes", border_style="magenta")
|
||||
|
||||
def _create_mini_bar(self, percentage: float, width: int = 10) -> str:
|
||||
filled = int((percentage / 100) * width)
|
||||
bar = "█" * filled + "░" * (width - filled)
|
||||
return bar
|
||||
|
||||
def create_system_gauges(self, cpu_info: Any, memory_info: Any, disk_info: Any) -> Panel:
|
||||
gauges = []
|
||||
|
||||
cpu_gauge = self._create_gauge(cpu_info.percent, "CPU")
|
||||
gauges.append(cpu_gauge)
|
||||
|
||||
mem_gauge = self._create_gauge(memory_info.percent, "MEM")
|
||||
gauges.append(mem_gauge)
|
||||
|
||||
disk_gauge = self._create_gauge(disk_info.percent, "DSK")
|
||||
gauges.append(disk_gauge)
|
||||
|
||||
return Panel(Group(*gauges), border_style="cyan")
|
||||
|
||||
def _create_gauge(self, percentage: float, label: str, width: int = 30) -> Text:
|
||||
filled = int((percentage / 100) * width)
|
||||
bar = "█" * filled + "░" * (width - filled)
|
||||
return Text.from_markup(f"[bold]{label}:[/bold] {bar} {percentage:5.1f}%")
|
||||
|
||||
def create_network_visualization(self, network_info: Any) -> Panel:
|
||||
sent_mb = network_info.bytes_sent / (1024 * 1024)
|
||||
recv_mb = network_info.bytes_recv / (1024 * 1024)
|
||||
|
||||
network_data = {
|
||||
"UPLOAD": min(sent_mb * 10, 100),
|
||||
"DOWNLOAD": min(recv_mb * 10, 100),
|
||||
}
|
||||
|
||||
return self.create_horizontal_bars(network_data)
|
||||
|
||||
def create_cpu_core_visualization(self, cpu_info: Any) -> Panel:
|
||||
if not cpu_info.percent_per_core:
|
||||
return Panel("No core data", border_style="red")
|
||||
|
||||
core_data = {}
|
||||
for i, core_percent in enumerate(cpu_info.percent_per_core):
|
||||
core_data[f"C{i:02d}"] = core_percent
|
||||
|
||||
return self.create_vertical_bars(core_data, height=8, width=40)
|
||||
|
||||
def create_memory_breakdown(self, memory_info: Any) -> Panel:
|
||||
used_gb = memory_info.used / (1024**3)
|
||||
total_gb = memory_info.total / (1024**3)
|
||||
|
||||
memory_data = {
|
||||
"USED": (used_gb / total_gb) * 100,
|
||||
"FREE": ((total_gb - used_gb) / total_gb) * 100,
|
||||
}
|
||||
|
||||
return self.create_horizontal_bars(memory_data)
|
||||
|
||||
def format_bytes(self, bytes_value: int) -> str:
|
||||
bytes_float = float(bytes_value)
|
||||
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
|
||||
if bytes_float < 1024.0:
|
||||
return f"{bytes_float:.1f} {unit}"
|
||||
bytes_float /= 1024.0
|
||||
return f"{bytes_float:.1f} PB"
|
||||
133
src/statsman/ui/dashboard.py
Normal file
133
src/statsman/ui/dashboard.py
Normal file
@@ -0,0 +1,133 @@
|
||||
from rich.console import Console, Group
|
||||
from rich.layout import Layout
|
||||
from rich.panel import Panel
|
||||
from rich.live import Live
|
||||
from rich.text import Text
|
||||
from rich.align import Align
|
||||
from typing import Optional
|
||||
import time
|
||||
|
||||
from ..system_monitor import SystemMonitor
|
||||
from .charts import ChartRenderer
|
||||
|
||||
|
||||
class Dashboard:
|
||||
def __init__(self, console: Optional[Console] = None, no_color: bool = False):
|
||||
self.console = console or Console(color_system=None if no_color else "auto")
|
||||
self.monitor = SystemMonitor()
|
||||
self.charts = ChartRenderer(self.console)
|
||||
self.layout = Layout()
|
||||
self.sort_processes_by = "cpu"
|
||||
|
||||
self._setup_visual_layout()
|
||||
|
||||
def _setup_visual_layout(self) -> None:
|
||||
self.layout.split(
|
||||
Layout(name="header", size=3),
|
||||
Layout(name="main"),
|
||||
Layout(name="footer", size=3),
|
||||
)
|
||||
|
||||
self.layout["main"].split_column(
|
||||
Layout(name="top", size=16),
|
||||
Layout(name="middle", size=14),
|
||||
Layout(name="bottom", ratio=1),
|
||||
)
|
||||
|
||||
self.layout["top"].split_row(
|
||||
Layout(name="gauges", ratio=1),
|
||||
Layout(name="cores", ratio=1),
|
||||
)
|
||||
|
||||
self.layout["middle"].split_row(
|
||||
Layout(name="memory", ratio=1),
|
||||
Layout(name="network", ratio=1),
|
||||
)
|
||||
|
||||
self.layout["bottom"].split_row(
|
||||
Layout(name="processes"),
|
||||
)
|
||||
|
||||
def _create_header(self) -> Panel:
|
||||
header_text = Text.from_markup(
|
||||
"[bold blue]StatsMan[/bold blue] - System Monitor",
|
||||
justify="center"
|
||||
)
|
||||
return Panel(
|
||||
Align.center(header_text),
|
||||
border_style="blue"
|
||||
)
|
||||
|
||||
def _create_footer(self) -> Panel:
|
||||
controls = Text.from_markup(
|
||||
"[cyan]q:quit p:pause c:cpu m:mem r:reset[/cyan]",
|
||||
justify="center"
|
||||
)
|
||||
return Panel(
|
||||
Align.center(controls),
|
||||
border_style="cyan"
|
||||
)
|
||||
|
||||
def _create_system_gauges(self) -> Panel:
|
||||
cpu_info = self.monitor.get_cpu_info()
|
||||
memory_info = self.monitor.get_memory_info()
|
||||
disk_info = self.monitor.get_disk_info()
|
||||
|
||||
return self.charts.create_system_gauges(cpu_info, memory_info, disk_info)
|
||||
|
||||
def _create_cpu_cores(self) -> Panel:
|
||||
cpu_info = self.monitor.get_cpu_info()
|
||||
cpu_history = self.monitor.get_cpu_history()
|
||||
|
||||
sparkline = self.charts.create_sparkline(cpu_history, width=60, height=8)
|
||||
sparkline_text = Text.from_markup(f"[cyan]CPU History:[/cyan] {sparkline}")
|
||||
|
||||
cores_panel = self.charts.create_cpu_core_visualization(cpu_info)
|
||||
|
||||
return Panel(
|
||||
Group(sparkline_text, cores_panel),
|
||||
title=f"CPU: {cpu_info.percent:.1f}%",
|
||||
border_style="red"
|
||||
)
|
||||
|
||||
def _create_memory_visual(self) -> Panel:
|
||||
memory_info = self.monitor.get_memory_info()
|
||||
memory_history = self.monitor.get_memory_history()
|
||||
|
||||
sparkline = self.charts.create_sparkline(memory_history, width=50, height=6)
|
||||
sparkline_text = Text.from_markup(f"[green]Memory History:[/green] {sparkline}")
|
||||
|
||||
breakdown_panel = self.charts.create_memory_breakdown(memory_info)
|
||||
|
||||
return Panel(
|
||||
Group(sparkline_text, breakdown_panel),
|
||||
title=f"Memory: {memory_info.percent:.1f}%",
|
||||
border_style="green"
|
||||
)
|
||||
|
||||
def _create_network_visual(self) -> Panel:
|
||||
network_info = self.monitor.get_network_info()
|
||||
return self.charts.create_network_visualization(network_info)
|
||||
|
||||
def _create_processes_visual(self) -> Panel:
|
||||
processes = self.monitor.get_process_info(limit=20)
|
||||
return self.charts.create_mini_process_table(processes, limit=16)
|
||||
|
||||
def update_layout(self) -> None:
|
||||
self.layout["header"].update(self._create_header())
|
||||
self.layout["footer"].update(self._create_footer())
|
||||
|
||||
self.layout["top"]["gauges"].update(self._create_system_gauges())
|
||||
self.layout["top"]["cores"].update(self._create_cpu_cores())
|
||||
self.layout["middle"]["memory"].update(self._create_memory_visual())
|
||||
self.layout["middle"]["network"].update(self._create_network_visual())
|
||||
self.layout["bottom"]["processes"].update(self._create_processes_visual())
|
||||
|
||||
def render(self) -> Layout:
|
||||
self.monitor.update_history()
|
||||
self.update_layout()
|
||||
return self.layout
|
||||
|
||||
def set_process_sort(self, sort_by: str) -> None:
|
||||
if sort_by in ['cpu', 'memory']:
|
||||
self.sort_processes_by = sort_by
|
||||
Reference in New Issue
Block a user