monitorify 1.0.0__tar.gz → 1.0.2__tar.gz

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 (52) hide show
  1. monitorify-1.0.2/PKG-INFO +62 -0
  2. monitorify-1.0.2/README.md +50 -0
  3. {monitorify-1.0.0 → monitorify-1.0.2}/pyproject.toml +1 -1
  4. {monitorify-1.0.0 → monitorify-1.0.2}/src/monitorify/__init__.py +1 -1
  5. {monitorify-1.0.0 → monitorify-1.0.2}/src/monitorify/collector/manager.py +2 -2
  6. {monitorify-1.0.0 → monitorify-1.0.2}/src/monitorify/collector/worker.py +14 -3
  7. monitorify-1.0.2/src/monitorify/config/__init__.py +29 -0
  8. monitorify-1.0.2/src/monitorify/config/configManager.py +47 -0
  9. {monitorify-1.0.0 → monitorify-1.0.2}/src/monitorify/config.py +3 -0
  10. monitorify-1.0.2/src/monitorify/config_manager.py +47 -0
  11. monitorify-1.0.2/src/monitorify/daemon.py +46 -0
  12. monitorify-1.0.2/src/monitorify/main.py +107 -0
  13. monitorify-1.0.2/src/monitorify/remote/__init__.py +3 -0
  14. monitorify-1.0.2/src/monitorify/remote/connector.py +115 -0
  15. {monitorify-1.0.0 → monitorify-1.0.2}/src/monitorify/stats/DiskStats.py +3 -4
  16. {monitorify-1.0.0 → monitorify-1.0.2}/src/monitorify/ui/tui/components/menu.py +30 -8
  17. {monitorify-1.0.0 → monitorify-1.0.2}/src/monitorify/ui/tui/css/tui.css +0 -1
  18. {monitorify-1.0.0 → monitorify-1.0.2}/src/monitorify/ui/tui/tui.py +37 -15
  19. {monitorify-1.0.0 → monitorify-1.0.2}/src/monitorify/ui/tui/widgets/brailleGraph.py +7 -10
  20. {monitorify-1.0.0 → monitorify-1.0.2}/src/monitorify/ui/tui/widgets/cpuWidget.py +2 -2
  21. {monitorify-1.0.0 → monitorify-1.0.2}/src/monitorify/ui/tui/widgets/diskWidget.py +1 -1
  22. {monitorify-1.0.0 → monitorify-1.0.2}/src/monitorify/ui/tui/widgets/networkWidget.py +3 -3
  23. monitorify-1.0.2/src/monitorify.egg-info/PKG-INFO +62 -0
  24. {monitorify-1.0.0 → monitorify-1.0.2}/src/monitorify.egg-info/SOURCES.txt +5 -0
  25. monitorify-1.0.0/PKG-INFO +0 -135
  26. monitorify-1.0.0/README.md +0 -123
  27. monitorify-1.0.0/src/monitorify/daemon.py +0 -32
  28. monitorify-1.0.0/src/monitorify/main.py +0 -53
  29. monitorify-1.0.0/src/monitorify.egg-info/PKG-INFO +0 -135
  30. {monitorify-1.0.0 → monitorify-1.0.2}/setup.cfg +0 -0
  31. {monitorify-1.0.0 → monitorify-1.0.2}/src/monitorify/__main__.py +0 -0
  32. {monitorify-1.0.0 → monitorify-1.0.2}/src/monitorify/collector/__init__.py +0 -0
  33. {monitorify-1.0.0 → monitorify-1.0.2}/src/monitorify/collector/schema.py +0 -0
  34. {monitorify-1.0.0 → monitorify-1.0.2}/src/monitorify/stats/__init__.py +0 -0
  35. {monitorify-1.0.0 → monitorify-1.0.2}/src/monitorify/stats/cpuStats.py +0 -0
  36. {monitorify-1.0.0 → monitorify-1.0.2}/src/monitorify/stats/networkStats.py +0 -0
  37. {monitorify-1.0.0 → monitorify-1.0.2}/src/monitorify/stats/programmList.py +0 -0
  38. {monitorify-1.0.0 → monitorify-1.0.2}/src/monitorify/stats/ramStats.py +0 -0
  39. {monitorify-1.0.0 → monitorify-1.0.2}/src/monitorify/storage/__init__.py +0 -0
  40. {monitorify-1.0.0 → monitorify-1.0.2}/src/monitorify/storage/db.py +0 -0
  41. {monitorify-1.0.0 → monitorify-1.0.2}/src/monitorify/ui/__init__.py +0 -0
  42. {monitorify-1.0.0 → monitorify-1.0.2}/src/monitorify/ui/tui/__init__.py +0 -0
  43. {monitorify-1.0.0 → monitorify-1.0.2}/src/monitorify/ui/tui/components/widgetManager.py +0 -0
  44. {monitorify-1.0.0 → monitorify-1.0.2}/src/monitorify/ui/tui/widgets/__init__.py +0 -0
  45. {monitorify-1.0.0 → monitorify-1.0.2}/src/monitorify/ui/tui/widgets/procInfoWidget.py +0 -0
  46. {monitorify-1.0.0 → monitorify-1.0.2}/src/monitorify/ui/tui/widgets/programmListWidget.py +0 -0
  47. {monitorify-1.0.0 → monitorify-1.0.2}/src/monitorify/ui/tui/widgets/ramWidget.py +0 -0
  48. {monitorify-1.0.0 → monitorify-1.0.2}/src/monitorify/ui/tui/widgets/statusWidget.py +0 -0
  49. {monitorify-1.0.0 → monitorify-1.0.2}/src/monitorify.egg-info/dependency_links.txt +0 -0
  50. {monitorify-1.0.0 → monitorify-1.0.2}/src/monitorify.egg-info/entry_points.txt +0 -0
  51. {monitorify-1.0.0 → monitorify-1.0.2}/src/monitorify.egg-info/requires.txt +0 -0
  52. {monitorify-1.0.0 → monitorify-1.0.2}/src/monitorify.egg-info/top_level.txt +0 -0
@@ -0,0 +1,62 @@
1
+ Metadata-Version: 2.4
2
+ Name: monitorify
3
+ Version: 1.0.2
4
+ Summary: A lightweight Linux terminal system monitor built with Textual
5
+ Author: Leon
6
+ License: MIT
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: textual>=0.80.0
10
+ Requires-Dist: platformdirs>=4.0.0
11
+ Requires-Dist: rich>=13.0.0
12
+
13
+ # Monitorify
14
+ ### A terminal montioring tool for linux
15
+
16
+ - Features
17
+ - CPU monitoring
18
+ - RAM monitoring
19
+ - Network monitoring
20
+ - Disk monitoring (I/O and storage capacity)
21
+ - Process monitoring
22
+ - Background daemon
23
+ - Customization menu (toggle widgets, set intervals, and change history window)
24
+ - Historical graphs
25
+ ## Installation
26
+ ### Using pipx
27
+ ```bash
28
+ pipx install git+https://github.com/leonst036/Monitorify.git
29
+ ```
30
+
31
+ ### Using pip
32
+ ```bash
33
+ pip install git+https://github.com/leonst036/Monitorify.git
34
+ ```
35
+
36
+ ### Using source
37
+ ```bash
38
+ git clone https://github.com/leonst036/Monitorify.git
39
+ cd Monitorify
40
+ pip install .
41
+ ```
42
+
43
+ ## Usage
44
+ ```bash
45
+ monitorify
46
+ ```
47
+
48
+ ## Tech Stack
49
+
50
+ * **Language & TUI:** Python, Textual
51
+ * **Database:** SQLite
52
+ * **Target OS:** Linux
53
+
54
+ ## Remote
55
+
56
+ To use Monitorify remotely over SSH on your server, you can set the following parameters:
57
+
58
+ - `--ip` – Set the target IP address.
59
+ - `--port` – Set the SSH port.
60
+ - `--username` – Set the username for the remote host.
61
+ - `--password` – Set the password for the SSH server.
62
+ - `--key_filename` – Set the path to an SSH key *(optional)*.
@@ -0,0 +1,50 @@
1
+ # Monitorify
2
+ ### A terminal montioring tool for linux
3
+
4
+ - Features
5
+ - CPU monitoring
6
+ - RAM monitoring
7
+ - Network monitoring
8
+ - Disk monitoring (I/O and storage capacity)
9
+ - Process monitoring
10
+ - Background daemon
11
+ - Customization menu (toggle widgets, set intervals, and change history window)
12
+ - Historical graphs
13
+ ## Installation
14
+ ### Using pipx
15
+ ```bash
16
+ pipx install git+https://github.com/leonst036/Monitorify.git
17
+ ```
18
+
19
+ ### Using pip
20
+ ```bash
21
+ pip install git+https://github.com/leonst036/Monitorify.git
22
+ ```
23
+
24
+ ### Using source
25
+ ```bash
26
+ git clone https://github.com/leonst036/Monitorify.git
27
+ cd Monitorify
28
+ pip install .
29
+ ```
30
+
31
+ ## Usage
32
+ ```bash
33
+ monitorify
34
+ ```
35
+
36
+ ## Tech Stack
37
+
38
+ * **Language & TUI:** Python, Textual
39
+ * **Database:** SQLite
40
+ * **Target OS:** Linux
41
+
42
+ ## Remote
43
+
44
+ To use Monitorify remotely over SSH on your server, you can set the following parameters:
45
+
46
+ - `--ip` – Set the target IP address.
47
+ - `--port` – Set the SSH port.
48
+ - `--username` – Set the username for the remote host.
49
+ - `--password` – Set the password for the SSH server.
50
+ - `--key_filename` – Set the path to an SSH key *(optional)*.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "monitorify"
7
- version = "1.0.0"
7
+ version = "1.0.2"
8
8
  description = "A lightweight Linux terminal system monitor built with Textual"
9
9
  readme = "README.md"
10
10
  authors = [
@@ -1,3 +1,3 @@
1
1
  """Monitorify - A lightweight Linux terminal system monitor."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "1.0.2"
@@ -1,9 +1,9 @@
1
- from typing import List, Optional, Any, Callable
1
+ from typing import List, Optional, Any, Callable, Union
2
2
  from monitorify.collector.worker import Worker
3
3
 
4
4
 
5
5
  class Manager:
6
- def __init__(self, db: Optional[Any] = None, interval: float = 1.0):
6
+ def __init__(self, db: Optional[Any] = None, interval: Union[float, Callable[[], float]] = 1.0):
7
7
  self._workers: List[Worker] = []
8
8
  self._interval = interval
9
9
  if db is None:
@@ -3,10 +3,10 @@ from threading import Thread
3
3
  from monitorify.stats import cpuStats, ramStats, networkStats, programmList, DiskStats
4
4
  from monitorify.collector.schema import MetricSnapshot
5
5
 
6
- from typing import Callable, Optional
6
+ from typing import Callable, Optional, Union
7
7
 
8
8
  class Worker:
9
- def __init__(self, interval: float = 1.0, on_data: Optional[Callable[[MetricSnapshot], None]] = None):
9
+ def __init__(self, interval: Union[float, Callable[[], float]] = 1.0, on_data: Optional[Callable[[MetricSnapshot], None]] = None):
10
10
  self._interval = interval
11
11
  self._on_data = on_data
12
12
  self._running = False
@@ -22,6 +22,17 @@ class Worker:
22
22
  if self._thread:
23
23
  self._thread.join()
24
24
 
25
+ def _get_interval(self) -> float:
26
+ if callable(self._interval):
27
+ try:
28
+ return float(self._interval())
29
+ except Exception:
30
+ return 1.0
31
+ try:
32
+ return float(self._interval)
33
+ except Exception:
34
+ return 1.0
35
+
25
36
  def _worker_loop(self):
26
37
  while self._running:
27
38
  snapshot = self.collect()
@@ -30,7 +41,7 @@ class Worker:
30
41
  self._on_data(snapshot)
31
42
  except Exception as e:
32
43
  print(f"Error handling metric snapshot: {e}")
33
- time.sleep(self._interval)
44
+ time.sleep(max(0.1, self._get_interval()))
34
45
 
35
46
  def collect(self) -> MetricSnapshot:
36
47
  return MetricSnapshot(
@@ -0,0 +1,29 @@
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
+ # User configuration file path
20
+ CONFIG_PATH = Path(os.getenv("MONITORIFY_CONFIG_PATH", str(DATA_DIR / "config.json")))
21
+
22
+ # Daemon PID file path
23
+ DAEMON_PIDFILE = Path(os.getenv("MONITORIFY_PID_PATH", str(RUNTIME_DIR / "daemon.pid")))
24
+
25
+ # Collection settings (seconds)
26
+ DEFAULT_COLLECTION_INTERVAL = float(os.getenv("MONITORIFY_INTERVAL", "1.0"))
27
+
28
+ # Retention settings (default: 7 days in seconds)
29
+ RETENTION_SECONDS = float(os.getenv("MONITORIFY_RETENTION", str(7 * 24 * 3600)))
@@ -0,0 +1,47 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+
5
+ class configManager:
6
+ def __init__(self, config_path: str):
7
+ self.config_path = config_path
8
+ self.config_data = {}
9
+ self.load_config()
10
+
11
+ def load_config(self) -> dict:
12
+ path = Path(self.config_path)
13
+ if not path.exists():
14
+ self.config_data = {}
15
+ return self.config_data
16
+
17
+ try:
18
+ with path.open("r", encoding="utf-8") as file:
19
+ data = json.load(file)
20
+ if isinstance(data, dict):
21
+ self.config_data = data
22
+ else:
23
+ self.config_data = {}
24
+ except (json.JSONDecodeError, OSError):
25
+ self.config_data = {}
26
+
27
+ return self.config_data
28
+
29
+ def save_config(self, config_data: dict):
30
+ try:
31
+ path = Path(self.config_path)
32
+ path.parent.mkdir(parents=True, exist_ok=True)
33
+ with path.open("w", encoding="utf-8") as file:
34
+ json.dump(config_data, file, indent=4)
35
+ self.config_data = config_data
36
+ except Exception as e:
37
+ print(f"Error saving configuration: {e}")
38
+
39
+ def get(self, key: str, default=None):
40
+ return self.config_data.get(key, default)
41
+
42
+ def get_connfig(self, key: str):
43
+ return self.get(key, None)
44
+
45
+ def set(self, key: str, value):
46
+ self.config_data[key] = value
47
+ self.save_config(self.config_data)
@@ -16,6 +16,9 @@ except Exception:
16
16
  # Database settings
17
17
  DB_PATH = os.getenv("MONITORIFY_DB_PATH", str(DATA_DIR / "monitorify.db"))
18
18
 
19
+ # User configuration file path
20
+ CONFIG_PATH = Path(os.getenv("MONITORIFY_CONFIG_PATH", str(DATA_DIR / "config.json")))
21
+
19
22
  # Daemon PID file path
20
23
  DAEMON_PIDFILE = Path(os.getenv("MONITORIFY_PID_PATH", str(RUNTIME_DIR / "daemon.pid")))
21
24
 
@@ -0,0 +1,47 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+
5
+ class configManager:
6
+ def __init__(self, config_path: str):
7
+ self.config_path = config_path
8
+ self.config_data = {}
9
+ self.load_config()
10
+
11
+ def load_config(self) -> dict:
12
+ path = Path(self.config_path)
13
+ if not path.exists():
14
+ self.config_data = {}
15
+ return self.config_data
16
+
17
+ try:
18
+ with path.open("r", encoding="utf-8") as file:
19
+ data = json.load(file)
20
+ if isinstance(data, dict):
21
+ self.config_data = data
22
+ else:
23
+ self.config_data = {}
24
+ except (json.JSONDecodeError, OSError):
25
+ self.config_data = {}
26
+
27
+ return self.config_data
28
+
29
+ def save_config(self, config_data: dict):
30
+ try:
31
+ path = Path(self.config_path)
32
+ path.parent.mkdir(parents=True, exist_ok=True)
33
+ with path.open("w", encoding="utf-8") as file:
34
+ json.dump(config_data, file, indent=4)
35
+ self.config_data = config_data
36
+ except Exception as e:
37
+ print(f"Error saving configuration: {e}")
38
+
39
+ def get(self, key: str, default=None):
40
+ return self.config_data.get(key, default)
41
+
42
+ def get_connfig(self, key: str):
43
+ return self.get(key, None)
44
+
45
+ def set(self, key: str, value):
46
+ self.config_data[key] = value
47
+ self.save_config(self.config_data)
@@ -0,0 +1,46 @@
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
+ from monitorify.config.configManager import configManager
8
+
9
+
10
+ def get_collection_interval() -> float:
11
+ try:
12
+ cfg = configManager(str(config.CONFIG_PATH))
13
+ for key in ("update_interval", "collection_interval", "interval"):
14
+ val = cfg.get(key)
15
+ if val is not None:
16
+ return float(val)
17
+ except Exception:
18
+ pass
19
+ return config.DEFAULT_COLLECTION_INTERVAL
20
+
21
+
22
+ def main():
23
+ print("Starting Monitorify background metrics collector daemon...")
24
+ db = Database(config.DB_PATH)
25
+ interval = get_collection_interval()
26
+ manager = Manager(db=db, interval=get_collection_interval)
27
+
28
+ def signal_handler(sig, frame):
29
+ print("\nStopping Monitorify daemon...")
30
+ manager.stop()
31
+ sys.exit(0)
32
+
33
+ signal.signal(signal.SIGINT, signal_handler)
34
+ signal.signal(signal.SIGTERM, signal_handler)
35
+
36
+ manager.start()
37
+ print(
38
+ f"Metrics collector running (Interval: {interval}s, DB: {config.DB_PATH}). Press Ctrl+C to stop."
39
+ )
40
+
41
+ while True:
42
+ time.sleep(1)
43
+
44
+
45
+ if __name__ == "__main__":
46
+ main()
@@ -0,0 +1,107 @@
1
+ import subprocess
2
+ import sys
3
+ import os
4
+ from monitorify import config
5
+ from monitorify.ui.tui.tui import MonitorifyApp
6
+ import argparse
7
+
8
+ def parse_args():
9
+ """Parse CLI arguments."""
10
+ parser = argparse.ArgumentParser(description="Monitorify")
11
+ parser.add_argument("--ip", type=str, default=None, help="IP address of the remote machine")
12
+ parser.add_argument("--port", type=int, default=None, help="Port of the remote machine (default: 22)")
13
+ parser.add_argument("--username", type=str, default=None, help="Username for the remote machine")
14
+ parser.add_argument("--password", type=str, default=None, help="Password for the remote machine")
15
+ parser.add_argument("--key_filename", type=str, default=None, help="Path to the key file for the remote machine")
16
+ args = parser.parse_args()
17
+
18
+ if not args.ip and (args.port is not None or args.username or args.password or args.key_filename):
19
+ parser.error("--ip is required when specifying remote connection arguments")
20
+
21
+ return args
22
+
23
+
24
+ def daemon_already_running() -> bool:
25
+ """Check if a daemon process from a previous run is still alive."""
26
+ if not config.DAEMON_PIDFILE.exists():
27
+ return False
28
+ try:
29
+ pid = int(config.DAEMON_PIDFILE.read_text().strip())
30
+ # Signal 0 checks if the process exists without killing it
31
+ os.kill(pid, 0)
32
+ return True
33
+ except (ValueError, ProcessLookupError, PermissionError):
34
+ config.DAEMON_PIDFILE.unlink(missing_ok=True)
35
+ return False
36
+
37
+
38
+ def start_daemon() -> subprocess.Popen | None:
39
+ """Start the daemon as a background subprocess if not already running."""
40
+ if daemon_already_running():
41
+ return None
42
+
43
+ proc = subprocess.Popen(
44
+ [sys.executable, "-m", "monitorify.daemon"],
45
+ stdout=subprocess.DEVNULL,
46
+ stderr=subprocess.DEVNULL,
47
+ )
48
+ config.DAEMON_PIDFILE.write_text(str(proc.pid))
49
+ return proc
50
+
51
+
52
+ def cli():
53
+ """Main CLI entrypoint"""
54
+ args = parse_args()
55
+
56
+ if args.ip:
57
+ from monitorify.remote.connector import RemoteConnector
58
+
59
+ try:
60
+ connector = RemoteConnector(
61
+ host=args.ip,
62
+ port=args.port or 22,
63
+ username=args.username,
64
+ password=args.password,
65
+ key_filename=args.key_filename,
66
+ )
67
+ client = connector.connect()
68
+ try:
69
+ if not args.command and not connector.is_installed(client):
70
+ print(f"Error: Monitorify is not installed on the remote host ({args.ip}).")
71
+ response = input("Do you want to install it? (y/n): ").strip().lower()
72
+ if response == "y":
73
+ success = connector.install(client)
74
+ if not success:
75
+ print("Installation failed.")
76
+ sys.exit(1)
77
+ else:
78
+ sys.exit(1)
79
+
80
+ remote_cmd = args.command or "bash -l -c 'monitorify || python3 -m monitorify'"
81
+ connector.run_interactive(client, command=remote_cmd)
82
+ finally:
83
+ client.close()
84
+ except ConnectionError as e:
85
+ print(f"Connection failed: {e}")
86
+ sys.exit(1)
87
+ except KeyboardInterrupt:
88
+ pass
89
+ return
90
+
91
+ # Local mode
92
+ daemon = start_daemon()
93
+ try:
94
+ app = MonitorifyApp()
95
+ app.run()
96
+ finally:
97
+ if daemon is not None:
98
+ daemon.terminate()
99
+ try:
100
+ daemon.wait(timeout=2)
101
+ except subprocess.TimeoutExpired:
102
+ daemon.kill()
103
+ config.DAEMON_PIDFILE.unlink(missing_ok=True)
104
+
105
+
106
+ if __name__ == "__main__":
107
+ cli()
@@ -0,0 +1,3 @@
1
+ from monitorify.remote.connector import RemoteConnector
2
+
3
+ __all__ = ["RemoteConnector"]
@@ -0,0 +1,115 @@
1
+ import paramiko
2
+
3
+ class RemoteConnector:
4
+ def __init__(
5
+ self,
6
+ host: str,
7
+ port: int = 22,
8
+ username: str | None = None,
9
+ password: str | None = None,
10
+ key_filename: str | None = None,
11
+ ):
12
+ self.host = host
13
+ self.port = port
14
+ self.username = username
15
+ self.password = password
16
+ self.key_filename = key_filename
17
+
18
+ def connect(self):
19
+ client = paramiko.SSHClient()
20
+ client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
21
+ try:
22
+ client.connect(hostname=self.host, port=self.port, username=self.username, password=self.password, key_filename=self.key_filename)
23
+ except paramiko.AuthenticationException as e:
24
+ raise ConnectionError(f"Authentication failed: {e}")
25
+ except paramiko.SSHException as e:
26
+ raise ConnectionError(f"SSH error: {e}")
27
+ except Exception as e:
28
+ raise ConnectionError(f"Error: {e}")
29
+ return client
30
+
31
+ def is_installed(self, client: paramiko.SSHClient) -> bool:
32
+ """Check if monitorify is installed on the remote host."""
33
+ try:
34
+ _, stdout, _ = client.exec_command(
35
+ "bash -l -c 'command -v monitorify >/dev/null 2>&1 || python3 -c \"import monitorify\" >/dev/null 2>&1'"
36
+ )
37
+ return stdout.channel.recv_exit_status() == 0
38
+ except Exception:
39
+ return False
40
+
41
+ def install(self, client: paramiko.SSHClient) -> bool:
42
+ """Install monitorify on the remote host using install.sh."""
43
+ import shlex
44
+ from pathlib import Path
45
+
46
+ script_path = Path(__file__).parent / "install.sh"
47
+ script_content = script_path.read_text(encoding="utf-8")
48
+ install_cmd = f"bash -l -c {shlex.quote(script_content)}"
49
+ self.run_interactive(client, command=install_cmd)
50
+ return self.is_installed(client)
51
+
52
+ def run_interactive(self, client: paramiko.SSHClient, command: str = "monitorify") -> None:
53
+ """Run a command interactively on the remote host with a PTY."""
54
+ import os
55
+ import select
56
+ import shutil
57
+ import signal
58
+ import sys
59
+ import termios
60
+ import tty
61
+
62
+ transport = client.get_transport()
63
+ if transport is None:
64
+ raise ConnectionError("SSH transport is not active")
65
+
66
+ channel = transport.open_session()
67
+ cols, rows = shutil.get_terminal_size()
68
+ term = os.environ.get("TERM", "xterm-256color")
69
+ channel.get_pty(term=term, width=cols, height=rows)
70
+ channel.exec_command(command)
71
+
72
+ stdin_fd = sys.stdin.fileno()
73
+ stdout_fd = sys.stdout.fileno()
74
+ is_atty = sys.stdin.isatty()
75
+ old_tty = termios.tcgetattr(stdin_fd) if is_atty else None
76
+
77
+ def handle_resize(signum, frame):
78
+ w, h = shutil.get_terminal_size()
79
+ try:
80
+ channel.resize_pty(width=w, height=h)
81
+ except Exception:
82
+ pass
83
+
84
+ try:
85
+ signal.signal(signal.SIGWINCH, handle_resize)
86
+ except Exception:
87
+ pass
88
+
89
+ try:
90
+ if is_atty:
91
+ tty.setraw(stdin_fd)
92
+ channel.setblocking(0)
93
+
94
+ while True:
95
+ r, _, _ = select.select([channel, stdin_fd], [], [])
96
+ if channel in r:
97
+ try:
98
+ data = channel.recv(4096)
99
+ if not data:
100
+ break
101
+ os.write(stdout_fd, data)
102
+ except Exception:
103
+ break
104
+ if stdin_fd in r:
105
+ try:
106
+ user_input = os.read(stdin_fd, 4096)
107
+ if not user_input:
108
+ break
109
+ channel.sendall(user_input)
110
+ except Exception:
111
+ break
112
+ finally:
113
+ if is_atty and old_tty is not None:
114
+ termios.tcsetattr(stdin_fd, termios.TCSADRAIN, old_tty)
115
+ channel.close()
@@ -10,7 +10,7 @@ def get_real_disks() -> list:
10
10
  continue
11
11
 
12
12
  disk_name = parts[2]
13
- # Check if it is a real physical disk (not a partition or virtual device)
13
+ # Check if it is a real physical disk
14
14
  if not os.path.exists(f"/sys/block/{disk_name}/device"):
15
15
  continue
16
16
 
@@ -32,7 +32,6 @@ def get_disk_IO(disks: list = None) -> dict:
32
32
 
33
33
  disk_name = parts[2]
34
34
  if disk_name in disks:
35
- # Sector size in Linux is 512 bytes
36
35
  read_bytes = int(parts[5]) * 512
37
36
  write_bytes = int(parts[9]) * 512
38
37
  disk_io[disk_name] = {
@@ -60,7 +59,7 @@ def get_disk_storage(disks: list = None) -> dict:
60
59
  mounts.setdefault(parts[0], parts[1])
61
60
 
62
61
  for disk in disks:
63
- # Read total raw hardware capacity from /sys/block/<disk>/size (512-byte sectors)
62
+ # Read total raw hardware capacity from /sys/block/<disk>/size
64
63
  total_hw = 0
65
64
  size_file = f"/sys/block/{disk}/size"
66
65
  if os.path.exists(size_file):
@@ -76,7 +75,7 @@ def get_disk_storage(disks: list = None) -> dict:
76
75
 
77
76
  for dev_path, mount_point in mounts.items():
78
77
  dev_name = os.path.basename(dev_path)
79
- # Match disk or its partitions (e.g. sda1 -> sda, nvme0n1p1 -> nvme0n1)
78
+ # Match disk or its partitions
80
79
  if dev_name == disk or dev_name.startswith(disk):
81
80
  try:
82
81
  st = os.statvfs(mount_point)