monitorify 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. monitorify/__init__.py +3 -0
  2. monitorify/__main__.py +4 -0
  3. monitorify/collector/__init__.py +5 -0
  4. monitorify/collector/manager.py +48 -0
  5. monitorify/collector/schema.py +22 -0
  6. monitorify/collector/worker.py +44 -0
  7. monitorify/config.py +26 -0
  8. monitorify/daemon.py +32 -0
  9. monitorify/main.py +53 -0
  10. monitorify/stats/DiskStats.py +108 -0
  11. monitorify/stats/__init__.py +1 -0
  12. monitorify/stats/cpuStats.py +46 -0
  13. monitorify/stats/networkStats.py +69 -0
  14. monitorify/stats/programmList.py +272 -0
  15. monitorify/stats/ramStats.py +18 -0
  16. monitorify/storage/__init__.py +3 -0
  17. monitorify/storage/db.py +154 -0
  18. monitorify/ui/__init__.py +1 -0
  19. monitorify/ui/tui/__init__.py +1 -0
  20. monitorify/ui/tui/components/menu.py +180 -0
  21. monitorify/ui/tui/components/widgetManager.py +43 -0
  22. monitorify/ui/tui/css/tui.css +168 -0
  23. monitorify/ui/tui/tui.py +187 -0
  24. monitorify/ui/tui/widgets/__init__.py +1 -0
  25. monitorify/ui/tui/widgets/brailleGraph.py +261 -0
  26. monitorify/ui/tui/widgets/cpuWidget.py +46 -0
  27. monitorify/ui/tui/widgets/diskWidget.py +207 -0
  28. monitorify/ui/tui/widgets/networkWidget.py +98 -0
  29. monitorify/ui/tui/widgets/procInfoWidget.py +44 -0
  30. monitorify/ui/tui/widgets/programmListWidget.py +202 -0
  31. monitorify/ui/tui/widgets/ramWidget.py +36 -0
  32. monitorify/ui/tui/widgets/statusWidget.py +45 -0
  33. monitorify-1.0.0.dist-info/METADATA +135 -0
  34. monitorify-1.0.0.dist-info/RECORD +37 -0
  35. monitorify-1.0.0.dist-info/WHEEL +5 -0
  36. monitorify-1.0.0.dist-info/entry_points.txt +3 -0
  37. monitorify-1.0.0.dist-info/top_level.txt +1 -0
monitorify/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Monitorify - A lightweight Linux terminal system monitor."""
2
+
3
+ __version__ = "0.1.0"
monitorify/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from monitorify.main import cli
2
+
3
+ if __name__ == "__main__":
4
+ cli()
@@ -0,0 +1,5 @@
1
+ from .manager import Manager
2
+ from .worker import Worker
3
+ from .schema import MetricSnapshot, NetworkMetrics
4
+
5
+ __all__ = ["Manager", "Worker", "MetricSnapshot", "NetworkMetrics"]
@@ -0,0 +1,48 @@
1
+ from typing import List, Optional, Any, Callable
2
+ from monitorify.collector.worker import Worker
3
+
4
+
5
+ class Manager:
6
+ def __init__(self, db: Optional[Any] = None, interval: float = 1.0):
7
+ self._workers: List[Worker] = []
8
+ self._interval = interval
9
+ if db is None:
10
+ from monitorify.storage.db import Database
11
+ self._db = Database()
12
+ else:
13
+ self._db = db
14
+ self._is_running = False
15
+
16
+ @property
17
+ def is_running(self) -> bool:
18
+ return self._is_running
19
+
20
+ def add_worker(self, worker: Worker):
21
+ self._workers.append(worker)
22
+
23
+ def setup_default_worker(self) -> Worker:
24
+ """Create and register default metric worker connected to Database."""
25
+ worker = Worker(interval=self._interval, on_data=self._db.save)
26
+ self.add_worker(worker)
27
+ return worker
28
+
29
+ def start(self):
30
+ if self._is_running:
31
+ return
32
+ self._db.connect()
33
+ self._is_running = True
34
+ if not self._workers:
35
+ self.setup_default_worker()
36
+ for worker in self._workers:
37
+ worker.start()
38
+
39
+ def stop(self):
40
+ if not self._is_running:
41
+ return
42
+ for worker in self._workers:
43
+ worker.stop()
44
+ self._db.disconnect()
45
+ self._is_running = False
46
+
47
+
48
+
@@ -0,0 +1,22 @@
1
+ from dataclasses import dataclass, asdict
2
+ from typing import Dict, Any, Tuple, Optional
3
+
4
+ @dataclass
5
+ class NetworkMetrics:
6
+ rx_bytes: float
7
+ tx_bytes: float
8
+
9
+ @dataclass
10
+ class MetricSnapshot:
11
+ timestamp: float
12
+ cpu: float
13
+ ram: float
14
+ network: Tuple[float, float]
15
+ processes_count: int
16
+ disk_io: Optional[Dict[str, Any]] = None
17
+ disk_storage: Optional[Dict[str, Any]] = None
18
+
19
+ def to_dict(self) -> Dict[str, Any]:
20
+ """Converts the dataclass object into a standard dictionary."""
21
+ return asdict(self)
22
+
@@ -0,0 +1,44 @@
1
+ import time
2
+ from threading import Thread
3
+ from monitorify.stats import cpuStats, ramStats, networkStats, programmList, DiskStats
4
+ from monitorify.collector.schema import MetricSnapshot
5
+
6
+ from typing import Callable, Optional
7
+
8
+ class Worker:
9
+ def __init__(self, interval: float = 1.0, on_data: Optional[Callable[[MetricSnapshot], None]] = None):
10
+ self._interval = interval
11
+ self._on_data = on_data
12
+ self._running = False
13
+ self._thread = None
14
+
15
+ def start(self):
16
+ self._running = True
17
+ self._thread = Thread(target=self._worker_loop)
18
+ self._thread.start()
19
+
20
+ def stop(self):
21
+ self._running = False
22
+ if self._thread:
23
+ self._thread.join()
24
+
25
+ def _worker_loop(self):
26
+ while self._running:
27
+ snapshot = self.collect()
28
+ if self._on_data:
29
+ try:
30
+ self._on_data(snapshot)
31
+ except Exception as e:
32
+ print(f"Error handling metric snapshot: {e}")
33
+ time.sleep(self._interval)
34
+
35
+ def collect(self) -> MetricSnapshot:
36
+ return MetricSnapshot(
37
+ timestamp=time.time(),
38
+ cpu=cpuStats.get_cpu_usage(),
39
+ ram=ramStats.get_ram_usage(),
40
+ network=networkStats.get_network_usage(),
41
+ processes_count=len(programmList.get_process_list()),
42
+ disk_io=DiskStats.get_disk_IO(),
43
+ disk_storage=DiskStats.get_disk_storage(),
44
+ )
monitorify/config.py ADDED
@@ -0,0 +1,26 @@
1
+ import os
2
+ from pathlib import Path
3
+ from platformdirs import user_data_dir, user_runtime_dir
4
+
5
+ # Data directory for database storage
6
+ DATA_DIR = Path(user_data_dir("monitorify", appauthor=False))
7
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
8
+
9
+ # Runtime directory for PID files
10
+ try:
11
+ RUNTIME_DIR = Path(user_runtime_dir("monitorify", appauthor=False))
12
+ RUNTIME_DIR.mkdir(parents=True, exist_ok=True)
13
+ except Exception:
14
+ RUNTIME_DIR = DATA_DIR
15
+
16
+ # Database settings
17
+ DB_PATH = os.getenv("MONITORIFY_DB_PATH", str(DATA_DIR / "monitorify.db"))
18
+
19
+ # Daemon PID file path
20
+ DAEMON_PIDFILE = Path(os.getenv("MONITORIFY_PID_PATH", str(RUNTIME_DIR / "daemon.pid")))
21
+
22
+ # Collection settings (seconds)
23
+ DEFAULT_COLLECTION_INTERVAL = float(os.getenv("MONITORIFY_INTERVAL", "1.0"))
24
+
25
+ # Retention settings (default: 7 days in seconds)
26
+ RETENTION_SECONDS = float(os.getenv("MONITORIFY_RETENTION", str(7 * 24 * 3600)))
monitorify/daemon.py ADDED
@@ -0,0 +1,32 @@
1
+ import time
2
+ import signal
3
+ import sys
4
+ from monitorify.collector import Manager
5
+ from monitorify.storage import Database
6
+ from monitorify import config
7
+
8
+
9
+ def main():
10
+ print("Starting Monitorify background metrics collector daemon...")
11
+ db = Database(config.DB_PATH)
12
+ manager = Manager(db=db, interval=config.DEFAULT_COLLECTION_INTERVAL)
13
+
14
+ def signal_handler(sig, frame):
15
+ print("\nStopping Monitorify daemon...")
16
+ manager.stop()
17
+ sys.exit(0)
18
+
19
+ signal.signal(signal.SIGINT, signal_handler)
20
+ signal.signal(signal.SIGTERM, signal_handler)
21
+
22
+ manager.start()
23
+ print(
24
+ f"Metrics collector running (Interval: {config.DEFAULT_COLLECTION_INTERVAL}s, DB: {config.DB_PATH}). Press Ctrl+C to stop."
25
+ )
26
+
27
+ while True:
28
+ time.sleep(1)
29
+
30
+
31
+ if __name__ == "__main__":
32
+ main()
monitorify/main.py ADDED
@@ -0,0 +1,53 @@
1
+ import subprocess
2
+ import sys
3
+ import os
4
+ from monitorify import config
5
+ from monitorify.ui.tui.tui import MonitorifyApp
6
+
7
+
8
+ def daemon_already_running() -> bool:
9
+ """Check if a daemon process from a previous run is still alive."""
10
+ if not config.DAEMON_PIDFILE.exists():
11
+ return False
12
+ try:
13
+ pid = int(config.DAEMON_PIDFILE.read_text().strip())
14
+ # Signal 0 checks if the process exists without killing it
15
+ os.kill(pid, 0)
16
+ return True
17
+ except (ValueError, ProcessLookupError, PermissionError):
18
+ config.DAEMON_PIDFILE.unlink(missing_ok=True)
19
+ return False
20
+
21
+
22
+ def start_daemon() -> subprocess.Popen | None:
23
+ """Start the daemon as a background subprocess if not already running."""
24
+ if daemon_already_running():
25
+ return None
26
+
27
+ proc = subprocess.Popen(
28
+ [sys.executable, "-m", "monitorify.daemon"],
29
+ stdout=subprocess.DEVNULL,
30
+ stderr=subprocess.DEVNULL,
31
+ )
32
+ config.DAEMON_PIDFILE.write_text(str(proc.pid))
33
+ return proc
34
+
35
+
36
+ def cli():
37
+ """Main CLI entrypoint for Monitorify."""
38
+ daemon = start_daemon()
39
+ try:
40
+ app = MonitorifyApp()
41
+ app.run()
42
+ finally:
43
+ if daemon is not None:
44
+ daemon.terminate()
45
+ try:
46
+ daemon.wait(timeout=2)
47
+ except subprocess.TimeoutExpired:
48
+ daemon.kill()
49
+ config.DAEMON_PIDFILE.unlink(missing_ok=True)
50
+
51
+
52
+ if __name__ == "__main__":
53
+ cli()
@@ -0,0 +1,108 @@
1
+ import os
2
+
3
+ def get_real_disks() -> list:
4
+ disks = []
5
+
6
+ with open("/proc/diskstats", "r") as f:
7
+ for line in f:
8
+ parts = line.split()
9
+ if len(parts) < 3:
10
+ continue
11
+
12
+ disk_name = parts[2]
13
+ # Check if it is a real physical disk (not a partition or virtual device)
14
+ if not os.path.exists(f"/sys/block/{disk_name}/device"):
15
+ continue
16
+
17
+ disks.append(disk_name)
18
+
19
+ return disks
20
+
21
+ def get_disk_IO(disks: list = None) -> dict:
22
+ if disks is None:
23
+ disks = get_real_disks()
24
+
25
+ disk_io = {}
26
+
27
+ with open("/proc/diskstats", "r") as f:
28
+ for line in f:
29
+ parts = line.split()
30
+ if len(parts) < 14:
31
+ continue
32
+
33
+ disk_name = parts[2]
34
+ if disk_name in disks:
35
+ # Sector size in Linux is 512 bytes
36
+ read_bytes = int(parts[5]) * 512
37
+ write_bytes = int(parts[9]) * 512
38
+ disk_io[disk_name] = {
39
+ "read_bytes": read_bytes,
40
+ "write_bytes": write_bytes,
41
+ "reads": int(parts[3]),
42
+ "writes": int(parts[7]),
43
+ }
44
+
45
+ return disk_io
46
+
47
+ def get_disk_storage(disks: list = None) -> dict:
48
+ if disks is None:
49
+ disks = get_real_disks()
50
+
51
+ disk_storage = {}
52
+
53
+ # Map mount points for each device partition
54
+ mounts = {}
55
+ if os.path.exists("/proc/mounts"):
56
+ with open("/proc/mounts", "r") as f:
57
+ for line in f:
58
+ parts = line.split()
59
+ if len(parts) >= 2 and parts[0].startswith("/dev/"):
60
+ mounts.setdefault(parts[0], parts[1])
61
+
62
+ for disk in disks:
63
+ # Read total raw hardware capacity from /sys/block/<disk>/size (512-byte sectors)
64
+ total_hw = 0
65
+ size_file = f"/sys/block/{disk}/size"
66
+ if os.path.exists(size_file):
67
+ try:
68
+ with open(size_file, "r") as f:
69
+ total_hw = int(f.read().strip()) * 512
70
+ except (ValueError, IOError):
71
+ total_hw = 0
72
+
73
+ total_fs = 0
74
+ used_fs = 0
75
+ free_fs = 0
76
+
77
+ for dev_path, mount_point in mounts.items():
78
+ dev_name = os.path.basename(dev_path)
79
+ # Match disk or its partitions (e.g. sda1 -> sda, nvme0n1p1 -> nvme0n1)
80
+ if dev_name == disk or dev_name.startswith(disk):
81
+ try:
82
+ st = os.statvfs(mount_point)
83
+ total_fs += st.f_blocks * st.f_frsize
84
+ free_fs += st.f_bavail * st.f_frsize
85
+ used_fs += (st.f_blocks - st.f_bfree) * st.f_frsize
86
+ except OSError:
87
+ pass
88
+
89
+ total = total_fs if total_fs > 0 else total_hw
90
+ used = used_fs
91
+ free = free_fs if total_fs > 0 else max(0, total_hw - used_fs)
92
+
93
+ disk_storage[disk] = {
94
+ "total": total,
95
+ "used": used,
96
+ "free": free,
97
+ }
98
+
99
+ return disk_storage
100
+
101
+ if __name__ == "__main__":
102
+ disks = get_real_disks()
103
+ disk_IO = get_disk_IO(disks)
104
+ disk_storage = get_disk_storage(disks)
105
+ print("Disks:", disks)
106
+ print("Disk IO:", disk_IO)
107
+ print("Disk Storage:", disk_storage)
108
+
@@ -0,0 +1 @@
1
+ # Package init
@@ -0,0 +1,46 @@
1
+ import time
2
+
3
+ prev_cpu = None
4
+
5
+ def _read_cpu_times():
6
+ with open("/proc/stat", "r") as f:
7
+ line = f.readline()
8
+ parts = [float(x) for x in line.split()[1:]]
9
+ idle_time = parts[3] + parts[4] # idle + iowait
10
+ total_time = sum(parts)
11
+ return idle_time, total_time
12
+
13
+ def get_cpu_usage(interval=None, unit="percent"):
14
+ """Calculate total CPU usage via /proc/stat."""
15
+ global prev_cpu
16
+
17
+ if prev_cpu is None:
18
+ t1_idle, t1_total = _read_cpu_times()
19
+ time.sleep(interval if interval and interval > 0 else 0.1)
20
+ t2_idle, t2_total = _read_cpu_times()
21
+ prev_cpu = (t2_idle, t2_total)
22
+ diff_total = t2_total - t1_total
23
+ diff_idle = t2_idle - t1_idle
24
+ elif interval is not None and interval > 0:
25
+ t1_idle, t1_total = _read_cpu_times()
26
+ time.sleep(interval)
27
+ t2_idle, t2_total = _read_cpu_times()
28
+ prev_cpu = (t2_idle, t2_total)
29
+ diff_total = t2_total - t1_total
30
+ diff_idle = t2_idle - t1_idle
31
+ else:
32
+ t2_idle, t2_total = _read_cpu_times()
33
+ prev_idle, prev_total = prev_cpu
34
+ prev_cpu = (t2_idle, t2_total)
35
+ diff_total = t2_total - prev_total
36
+ diff_idle = t2_idle - prev_idle
37
+
38
+ if diff_total == 0:
39
+ return 0.0
40
+ # Return requested unit
41
+ if unit == "percent":
42
+ return round((1.0 - (diff_idle / diff_total)) * 100, 2)
43
+ elif unit == "raw":
44
+ return 1.0 - (diff_idle / diff_total)
45
+ else:
46
+ raise ValueError("Invalid unit. Use 'percent' or 'raw'.")
@@ -0,0 +1,69 @@
1
+ import time
2
+
3
+ prev_net = None
4
+
5
+ def _read_net_dev():
6
+ with open("/proc/net/dev", "r") as f:
7
+ next(f) # Skip header
8
+ next(f)
9
+ net_stats = {}
10
+ for line in f:
11
+ parts = line.split()
12
+ interface = parts[0].strip(":")
13
+
14
+ rx_bytes = int(parts[1])
15
+
16
+ tx_bytes = int(parts[9])
17
+
18
+ net_stats[interface] = (rx_bytes, tx_bytes)
19
+
20
+ return net_stats
21
+
22
+ def get_default_interface():
23
+ with open("/proc/net/dev", "r") as f:
24
+ next(f) # Skip header
25
+ next(f)
26
+ for line in f:
27
+ parts = line.split()
28
+ interface = parts[0].strip(":")
29
+ if interface != "lo":
30
+ return interface
31
+
32
+ def get_network_usage(interface=None, interval=1, max_speed=None):
33
+ """Set max_speed as bytes per seconds"""
34
+ global prev_net
35
+
36
+ if callable(interface):
37
+ interface = interface()
38
+ elif interface is None:
39
+ interface = get_default_interface()
40
+
41
+ if prev_net is None:
42
+ prev_net = _read_net_dev()
43
+ time.sleep(interval if interval and interval > 0 else 1)
44
+ current_net = _read_net_dev()
45
+ else:
46
+ current_net = _read_net_dev()
47
+
48
+ prev_rx, prev_tx = prev_net.get(interface, (0, 0))
49
+ current_rx, current_tx = current_net.get(interface, (0, 0))
50
+
51
+ prev_net = current_net
52
+
53
+ diff_rx = max(0, current_rx - prev_rx)
54
+ diff_tx = max(0, current_tx - prev_tx)
55
+
56
+ if interval <= 0:
57
+ interval = 1
58
+
59
+
60
+ # Return in bytes per second
61
+ rx_bps = diff_rx / interval
62
+ tx_bps = diff_tx / interval
63
+ return rx_bps, tx_bps
64
+
65
+ def get_total_network_stats():
66
+ net_stats = _read_net_dev()
67
+ total_rx = sum(rx for rx, tx in net_stats.values())
68
+ total_tx = sum(tx for rx, tx in net_stats.values())
69
+ return total_rx, total_tx