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.
- monitorify/__init__.py +3 -0
- monitorify/__main__.py +4 -0
- monitorify/collector/__init__.py +5 -0
- monitorify/collector/manager.py +48 -0
- monitorify/collector/schema.py +22 -0
- monitorify/collector/worker.py +44 -0
- monitorify/config.py +26 -0
- monitorify/daemon.py +32 -0
- monitorify/main.py +53 -0
- monitorify/stats/DiskStats.py +108 -0
- monitorify/stats/__init__.py +1 -0
- monitorify/stats/cpuStats.py +46 -0
- monitorify/stats/networkStats.py +69 -0
- monitorify/stats/programmList.py +272 -0
- monitorify/stats/ramStats.py +18 -0
- monitorify/storage/__init__.py +3 -0
- monitorify/storage/db.py +154 -0
- monitorify/ui/__init__.py +1 -0
- monitorify/ui/tui/__init__.py +1 -0
- monitorify/ui/tui/components/menu.py +180 -0
- monitorify/ui/tui/components/widgetManager.py +43 -0
- monitorify/ui/tui/css/tui.css +168 -0
- monitorify/ui/tui/tui.py +187 -0
- monitorify/ui/tui/widgets/__init__.py +1 -0
- monitorify/ui/tui/widgets/brailleGraph.py +261 -0
- monitorify/ui/tui/widgets/cpuWidget.py +46 -0
- monitorify/ui/tui/widgets/diskWidget.py +207 -0
- monitorify/ui/tui/widgets/networkWidget.py +98 -0
- monitorify/ui/tui/widgets/procInfoWidget.py +44 -0
- monitorify/ui/tui/widgets/programmListWidget.py +202 -0
- monitorify/ui/tui/widgets/ramWidget.py +36 -0
- monitorify/ui/tui/widgets/statusWidget.py +45 -0
- monitorify-1.0.0.dist-info/METADATA +135 -0
- monitorify-1.0.0.dist-info/RECORD +37 -0
- monitorify-1.0.0.dist-info/WHEEL +5 -0
- monitorify-1.0.0.dist-info/entry_points.txt +3 -0
- monitorify-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import time
|
|
3
|
+
import threading
|
|
4
|
+
|
|
5
|
+
# pyrefly: ignore [missing-import]
|
|
6
|
+
from textual.widgets import ListItem, Static
|
|
7
|
+
|
|
8
|
+
_cache_lock = threading.Lock()
|
|
9
|
+
_current_cache = {}
|
|
10
|
+
_current_pids = []
|
|
11
|
+
_current_items_data = []
|
|
12
|
+
_cache_version = 0
|
|
13
|
+
_thread_started = False
|
|
14
|
+
|
|
15
|
+
_PAGE_SIZE = os.sysconf('SC_PAGE_SIZE') if hasattr(os, 'sysconf') else 4096
|
|
16
|
+
_CLK_TCK = float(os.sysconf(os.sysconf_names.get("SC_CLK_TCK", "SC_CLK_TCK"))) if hasattr(os, "sysconf") else 100.0
|
|
17
|
+
|
|
18
|
+
_previous_cpu_times = {}
|
|
19
|
+
_previous_cpu_timestamp = 0.0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_process_list():
|
|
23
|
+
# Get active PIDs
|
|
24
|
+
try:
|
|
25
|
+
return [int(p) for p in os.listdir("/proc") if p.isdigit()]
|
|
26
|
+
except (FileNotFoundError, PermissionError, OSError):
|
|
27
|
+
return []
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_system_uptime() -> float:
|
|
31
|
+
"""Fetch total system uptime in seconds once per cycle."""
|
|
32
|
+
try:
|
|
33
|
+
with open("/proc/uptime", "r") as f:
|
|
34
|
+
return float(f.read().split()[0])
|
|
35
|
+
except Exception:
|
|
36
|
+
return 0.0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _format_running_time(uptime_seconds: float, start_time_ticks: float) -> str:
|
|
40
|
+
"""Format process running time from starttime ticks."""
|
|
41
|
+
start_time_seconds = start_time_ticks / _CLK_TCK
|
|
42
|
+
elapsed_seconds = int(uptime_seconds - start_time_seconds)
|
|
43
|
+
|
|
44
|
+
if elapsed_seconds < 0:
|
|
45
|
+
return "00:00"
|
|
46
|
+
|
|
47
|
+
days, remainder = divmod(elapsed_seconds, 86400)
|
|
48
|
+
hours, remainder = divmod(remainder, 3600)
|
|
49
|
+
minutes, seconds = divmod(remainder, 60)
|
|
50
|
+
|
|
51
|
+
if days > 0:
|
|
52
|
+
return f"{days}d {hours:02d}:{minutes:02d}:{seconds:02d}"
|
|
53
|
+
elif hours > 0:
|
|
54
|
+
return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
|
|
55
|
+
else:
|
|
56
|
+
return f"{minutes:02d}:{seconds:02d}"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def build_process_cache(pids=None):
|
|
60
|
+
"""Build process cache in a single pass per process by reading /proc/{pid}/stat."""
|
|
61
|
+
global _previous_cpu_times, _previous_cpu_timestamp
|
|
62
|
+
if pids is None:
|
|
63
|
+
pids = get_process_list()
|
|
64
|
+
|
|
65
|
+
current_timestamp = time.time()
|
|
66
|
+
elapsed_seconds = current_timestamp - _previous_cpu_timestamp
|
|
67
|
+
if elapsed_seconds <= 0:
|
|
68
|
+
elapsed_seconds = 0.1
|
|
69
|
+
|
|
70
|
+
uptime = get_system_uptime()
|
|
71
|
+
|
|
72
|
+
state_map = {
|
|
73
|
+
'S': 'Sleeping',
|
|
74
|
+
'R': 'Running',
|
|
75
|
+
'D': 'Waiting',
|
|
76
|
+
'Z': 'Zombie',
|
|
77
|
+
'T': 'Stopped',
|
|
78
|
+
't': 'Stopped'
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
cache = {}
|
|
82
|
+
new_cpu_times = {}
|
|
83
|
+
|
|
84
|
+
for pid in pids:
|
|
85
|
+
try:
|
|
86
|
+
with open(f"/proc/{pid}/stat", "r") as f:
|
|
87
|
+
data = f.read()
|
|
88
|
+
|
|
89
|
+
start = data.find('(')
|
|
90
|
+
end = data.rfind(')')
|
|
91
|
+
if start == -1 or end == -1:
|
|
92
|
+
continue
|
|
93
|
+
|
|
94
|
+
name = data[start + 1:end]
|
|
95
|
+
fields = data[end + 1:].split()
|
|
96
|
+
|
|
97
|
+
# State
|
|
98
|
+
state = state_map.get(fields[0], "Unknown")
|
|
99
|
+
|
|
100
|
+
# CPU times: utime (field 11) + stime (field 12)
|
|
101
|
+
utime = int(fields[11])
|
|
102
|
+
stime = int(fields[12])
|
|
103
|
+
cpu_time = utime + stime
|
|
104
|
+
new_cpu_times[pid] = cpu_time
|
|
105
|
+
|
|
106
|
+
# CPU percentage calculation
|
|
107
|
+
prev_time = _previous_cpu_times.get(pid)
|
|
108
|
+
if prev_time is not None:
|
|
109
|
+
delta_ticks = cpu_time - prev_time
|
|
110
|
+
cpu_usage = round(100.0 * (delta_ticks / _CLK_TCK) / elapsed_seconds, 1)
|
|
111
|
+
else:
|
|
112
|
+
cpu_usage = 0.0
|
|
113
|
+
|
|
114
|
+
# Running time: starttime (field 19)
|
|
115
|
+
start_time_ticks = float(fields[19])
|
|
116
|
+
running_time = _format_running_time(uptime, start_time_ticks)
|
|
117
|
+
|
|
118
|
+
# RAM usage: rss (field 21 in pages)
|
|
119
|
+
ram_bytes = int(fields[21]) * _PAGE_SIZE
|
|
120
|
+
ram_usage = round(ram_bytes / (1024 ** 2), 1)
|
|
121
|
+
|
|
122
|
+
cache[pid] = {
|
|
123
|
+
'name': name,
|
|
124
|
+
'ram_ussage': ram_usage,
|
|
125
|
+
'cpu_ussage': cpu_usage,
|
|
126
|
+
'state': state,
|
|
127
|
+
'running_time': running_time,
|
|
128
|
+
}
|
|
129
|
+
except (FileNotFoundError, PermissionError, ProcessLookupError, OSError, ValueError, IndexError):
|
|
130
|
+
continue
|
|
131
|
+
|
|
132
|
+
_previous_cpu_times = new_cpu_times
|
|
133
|
+
_previous_cpu_timestamp = current_timestamp
|
|
134
|
+
|
|
135
|
+
return cache
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def get_process_data_list(pids, cache):
|
|
139
|
+
if not isinstance(cache, dict):
|
|
140
|
+
cache = {}
|
|
141
|
+
if pids is None:
|
|
142
|
+
return []
|
|
143
|
+
|
|
144
|
+
data_list = []
|
|
145
|
+
for pid in pids:
|
|
146
|
+
info = cache.get(pid, {})
|
|
147
|
+
if isinstance(info, str):
|
|
148
|
+
info = {'name': info, 'ram_ussage': 0, 'cpu_ussage': 0, 'state': 'Unknown', 'running_time': 'Unknown'}
|
|
149
|
+
|
|
150
|
+
name = info.get('name', 'Unknown')
|
|
151
|
+
ram = info.get('ram_ussage', 0)
|
|
152
|
+
cpu = info.get('cpu_ussage', 0)
|
|
153
|
+
state = info.get('state', 'Unknown')
|
|
154
|
+
running_time = info.get('running_time', 'Unknown')
|
|
155
|
+
data_list.append({
|
|
156
|
+
'pid': pid,
|
|
157
|
+
'name': name,
|
|
158
|
+
'ram_ussage': ram,
|
|
159
|
+
'cpu_ussage': cpu,
|
|
160
|
+
'state': state,
|
|
161
|
+
'running_time': running_time
|
|
162
|
+
})
|
|
163
|
+
return data_list
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _cache_worker(interval: float = 2.0):
|
|
167
|
+
global _current_cache, _current_pids, _current_items_data, _cache_version
|
|
168
|
+
while True:
|
|
169
|
+
pids = get_process_list()
|
|
170
|
+
if pids:
|
|
171
|
+
cache = build_process_cache(pids)
|
|
172
|
+
items_data = get_process_data_list(pids, cache)
|
|
173
|
+
with _cache_lock:
|
|
174
|
+
_current_pids = pids
|
|
175
|
+
_current_cache = cache
|
|
176
|
+
_current_items_data = items_data
|
|
177
|
+
_cache_version += 1
|
|
178
|
+
time.sleep(interval)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def start_process_cache_thread(interval: float = 2.0):
|
|
182
|
+
global _thread_started
|
|
183
|
+
with _cache_lock:
|
|
184
|
+
if _thread_started:
|
|
185
|
+
return
|
|
186
|
+
_thread_started = True
|
|
187
|
+
|
|
188
|
+
t = threading.Thread(target=_cache_worker, args=(interval,), daemon=True)
|
|
189
|
+
t.start()
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def get_latest_process_data():
|
|
193
|
+
"""Return latest process data tuple."""
|
|
194
|
+
global _current_cache, _current_pids, _cache_version
|
|
195
|
+
start_process_cache_thread()
|
|
196
|
+
with _cache_lock:
|
|
197
|
+
if _cache_version == 0:
|
|
198
|
+
pids = get_process_list()
|
|
199
|
+
if pids:
|
|
200
|
+
cache = build_process_cache(pids)
|
|
201
|
+
_current_pids = pids
|
|
202
|
+
_current_cache = cache
|
|
203
|
+
_cache_version = 1
|
|
204
|
+
version = _cache_version
|
|
205
|
+
pids = list(_current_pids)
|
|
206
|
+
cache = dict(_current_cache)
|
|
207
|
+
data_list = get_process_data_list(pids, cache)
|
|
208
|
+
return version, pids, data_list
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def get_latest_process_items_data():
|
|
212
|
+
"""Return (version, count, items_data_list)."""
|
|
213
|
+
global _current_cache, _current_pids, _current_items_data, _cache_version
|
|
214
|
+
start_process_cache_thread()
|
|
215
|
+
with _cache_lock:
|
|
216
|
+
if _cache_version == 0:
|
|
217
|
+
pids = get_process_list()
|
|
218
|
+
if pids:
|
|
219
|
+
cache = build_process_cache(pids)
|
|
220
|
+
_current_pids = pids
|
|
221
|
+
_current_cache = cache
|
|
222
|
+
_current_items_data = get_process_data_list(pids, cache)
|
|
223
|
+
_cache_version = 1
|
|
224
|
+
version = _cache_version
|
|
225
|
+
count = len(_current_pids)
|
|
226
|
+
items_data = list(_current_items_data)
|
|
227
|
+
|
|
228
|
+
return version, count, items_data
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _format_process_item(d: dict) -> str:
|
|
232
|
+
ram_mb = f"{d['ram_ussage']:.1f} MB" if isinstance(d['ram_ussage'], (int, float)) else "0.0 MB"
|
|
233
|
+
|
|
234
|
+
cpu_val = d.get('cpu_ussage', 0.0)
|
|
235
|
+
cpu_str = f"{cpu_val:.1f}%" if isinstance(cpu_val, (int, float)) else "0.0%"
|
|
236
|
+
|
|
237
|
+
name = str(d.get('name', ''))
|
|
238
|
+
if len(name) > 25:
|
|
239
|
+
name = name[:22] + "..."
|
|
240
|
+
|
|
241
|
+
return f"[bold #61afef]{d.get('pid', 0):>6d}[/bold #61afef] {name:<25} [yellow]{ram_mb:>10}[/yellow] [red]{cpu_str:>7}[/red]"
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def get_latest_process_items():
|
|
245
|
+
"""Return ListItems for UI hot swap."""
|
|
246
|
+
version, count, items_data = get_latest_process_items_data()
|
|
247
|
+
list_items = [ListItem(Static(_format_process_item(d))) for d in items_data]
|
|
248
|
+
return version, count, list_items
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def get_process_ram_ussage(pids: list[int]):
|
|
252
|
+
"""Deprecated: Standalone RAM fetcher."""
|
|
253
|
+
cache = build_process_cache(pids)
|
|
254
|
+
return [cache.get(pid, {}).get('ram_ussage', 0.0) for pid in pids]
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def get_process_cpu_usage(pids: list[int]):
|
|
258
|
+
"""Deprecated: Standalone CPU fetcher."""
|
|
259
|
+
cache = build_process_cache(pids)
|
|
260
|
+
return [cache.get(pid, {}).get('cpu_ussage', 0.0) for pid in pids]
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def get_process_state(pid: int):
|
|
264
|
+
"""Standalone State fetcher."""
|
|
265
|
+
cache = build_process_cache([pid])
|
|
266
|
+
return cache.get(pid, {}).get('state', 'Unknown')
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def get_process_running_time(pid: int) -> str:
|
|
270
|
+
"""Standalone Running Time fetcher."""
|
|
271
|
+
cache = build_process_cache([pid])
|
|
272
|
+
return cache.get(pid, {}).get('running_time', 'Unknown')
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
def get_ram_usage(unit="percent"):
|
|
2
|
+
mem_available = None
|
|
3
|
+
mem_total = None
|
|
4
|
+
with open("/proc/meminfo", "r") as f:
|
|
5
|
+
for line in f:
|
|
6
|
+
if line.startswith("MemAvailable:"):
|
|
7
|
+
mem_available = int(line.split()[1])
|
|
8
|
+
elif line.startswith("MemTotal:"):
|
|
9
|
+
mem_total = int(line.split()[1])
|
|
10
|
+
if mem_available is not None and mem_total is not None:
|
|
11
|
+
break
|
|
12
|
+
|
|
13
|
+
if unit == "percent":
|
|
14
|
+
return round((1.0 - (mem_available / mem_total)) * 100, 2)
|
|
15
|
+
elif unit == "raw":
|
|
16
|
+
return 1.0 - (mem_available / mem_total)
|
|
17
|
+
else:
|
|
18
|
+
raise ValueError("Invalid unit. Use 'percent' or 'raw'.")
|
monitorify/storage/db.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import sqlite3
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
from typing import List, Optional
|
|
5
|
+
from monitorify.collector.schema import MetricSnapshot
|
|
6
|
+
from monitorify import config
|
|
7
|
+
|
|
8
|
+
class Database:
|
|
9
|
+
def __init__(self, path: str = config.DB_PATH):
|
|
10
|
+
self.path = path
|
|
11
|
+
self._connection: Optional[sqlite3.Connection] = None
|
|
12
|
+
|
|
13
|
+
def connect(self):
|
|
14
|
+
dir_name = os.path.dirname(self.path)
|
|
15
|
+
if dir_name and not os.path.exists(dir_name):
|
|
16
|
+
try:
|
|
17
|
+
os.makedirs(dir_name, exist_ok=True)
|
|
18
|
+
except Exception as e:
|
|
19
|
+
print(f"Error creating directory: {e}")
|
|
20
|
+
|
|
21
|
+
# check_same_thread=False enables background worker threads to write safely
|
|
22
|
+
self._connection = sqlite3.connect(self.path, check_same_thread=False)
|
|
23
|
+
self._connection.row_factory = sqlite3.Row
|
|
24
|
+
self._connection.execute(
|
|
25
|
+
"""
|
|
26
|
+
CREATE TABLE IF NOT EXISTS metrics (
|
|
27
|
+
timestamp REAL PRIMARY KEY,
|
|
28
|
+
cpu REAL,
|
|
29
|
+
ram REAL,
|
|
30
|
+
network_rx REAL,
|
|
31
|
+
network_tx REAL,
|
|
32
|
+
processes_count INTEGER,
|
|
33
|
+
disk_io TEXT,
|
|
34
|
+
disk_storage TEXT
|
|
35
|
+
)
|
|
36
|
+
"""
|
|
37
|
+
)
|
|
38
|
+
self._connection.commit()
|
|
39
|
+
|
|
40
|
+
# Migrate existing table schema if columns are missing
|
|
41
|
+
cursor = self._connection.cursor()
|
|
42
|
+
cursor.execute("PRAGMA table_info(metrics)")
|
|
43
|
+
columns = [row["name"] for row in cursor.fetchall()]
|
|
44
|
+
if "disk_io" not in columns:
|
|
45
|
+
self._connection.execute("ALTER TABLE metrics ADD COLUMN disk_io TEXT")
|
|
46
|
+
if "disk_storage" not in columns:
|
|
47
|
+
self._connection.execute("ALTER TABLE metrics ADD COLUMN disk_storage TEXT")
|
|
48
|
+
self._connection.commit()
|
|
49
|
+
|
|
50
|
+
def disconnect(self):
|
|
51
|
+
if self._connection:
|
|
52
|
+
self._connection.close()
|
|
53
|
+
self._connection = None
|
|
54
|
+
|
|
55
|
+
def __enter__(self):
|
|
56
|
+
self.connect()
|
|
57
|
+
return self
|
|
58
|
+
|
|
59
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
60
|
+
self.disconnect()
|
|
61
|
+
|
|
62
|
+
def _ensure_connected(self):
|
|
63
|
+
if self._connection is None:
|
|
64
|
+
self.connect()
|
|
65
|
+
|
|
66
|
+
def save(self, metric_snapshot: MetricSnapshot):
|
|
67
|
+
self._ensure_connected()
|
|
68
|
+
try:
|
|
69
|
+
disk_io_json = json.dumps(metric_snapshot.disk_io) if metric_snapshot.disk_io is not None else None
|
|
70
|
+
disk_storage_json = json.dumps(metric_snapshot.disk_storage) if metric_snapshot.disk_storage is not None else None
|
|
71
|
+
|
|
72
|
+
self._connection.execute(
|
|
73
|
+
"""
|
|
74
|
+
INSERT INTO metrics (timestamp, cpu, ram, network_rx, network_tx, processes_count, disk_io, disk_storage)
|
|
75
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
76
|
+
""",
|
|
77
|
+
(
|
|
78
|
+
metric_snapshot.timestamp,
|
|
79
|
+
metric_snapshot.cpu,
|
|
80
|
+
metric_snapshot.ram,
|
|
81
|
+
metric_snapshot.network[0],
|
|
82
|
+
metric_snapshot.network[1],
|
|
83
|
+
metric_snapshot.processes_count,
|
|
84
|
+
disk_io_json,
|
|
85
|
+
disk_storage_json,
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
self._connection.commit()
|
|
89
|
+
except Exception as e:
|
|
90
|
+
print(f"Error saving metrics: {e}")
|
|
91
|
+
|
|
92
|
+
def get_history(self, limit: int = 100, start_time: Optional[float] = None, end_time: Optional[float] = None) -> List[MetricSnapshot]:
|
|
93
|
+
"""Fetch historical metric snapshots."""
|
|
94
|
+
self._ensure_connected()
|
|
95
|
+
query = "SELECT timestamp, cpu, ram, network_rx, network_tx, processes_count, disk_io, disk_storage FROM metrics"
|
|
96
|
+
params = []
|
|
97
|
+
conditions = []
|
|
98
|
+
|
|
99
|
+
if start_time is not None:
|
|
100
|
+
conditions.append("timestamp >= ?")
|
|
101
|
+
params.append(start_time)
|
|
102
|
+
if end_time is not None:
|
|
103
|
+
conditions.append("timestamp <= ?")
|
|
104
|
+
params.append(end_time)
|
|
105
|
+
|
|
106
|
+
if conditions:
|
|
107
|
+
query += " WHERE " + " AND ".join(conditions)
|
|
108
|
+
|
|
109
|
+
query += " ORDER BY timestamp DESC LIMIT ?"
|
|
110
|
+
params.append(limit)
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
cursor = self._connection.cursor()
|
|
114
|
+
cursor.execute(query, params)
|
|
115
|
+
rows = cursor.fetchall()
|
|
116
|
+
|
|
117
|
+
snapshots = []
|
|
118
|
+
for row in reversed(rows): # Return chronological order
|
|
119
|
+
disk_io = json.loads(row["disk_io"]) if ("disk_io" in row.keys() and row["disk_io"]) else None
|
|
120
|
+
disk_storage = json.loads(row["disk_storage"]) if ("disk_storage" in row.keys() and row["disk_storage"]) else None
|
|
121
|
+
|
|
122
|
+
snapshots.append(
|
|
123
|
+
MetricSnapshot(
|
|
124
|
+
timestamp=row["timestamp"],
|
|
125
|
+
cpu=row["cpu"],
|
|
126
|
+
ram=row["ram"],
|
|
127
|
+
network=(row["network_rx"], row["network_tx"]),
|
|
128
|
+
processes_count=row["processes_count"],
|
|
129
|
+
disk_io=disk_io,
|
|
130
|
+
disk_storage=disk_storage,
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
return snapshots
|
|
134
|
+
except Exception as e:
|
|
135
|
+
print(f"Error fetching history: {e}")
|
|
136
|
+
return []
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def get_latest(self) -> Optional[MetricSnapshot]:
|
|
140
|
+
"""Fetch the most recent metric snapshot."""
|
|
141
|
+
history = self.get_history(limit=1)
|
|
142
|
+
return history[0] if history else None
|
|
143
|
+
|
|
144
|
+
def prune_old_metrics(self, max_age_seconds: float):
|
|
145
|
+
"""Remove metrics older than specified seconds."""
|
|
146
|
+
self._ensure_connected()
|
|
147
|
+
cutoff_time = os.time.time() if hasattr(os, 'time') else sqlite3.connect # time reference
|
|
148
|
+
import time
|
|
149
|
+
cutoff = time.time() - max_age_seconds
|
|
150
|
+
try:
|
|
151
|
+
self._connection.execute("DELETE FROM metrics WHERE timestamp < ?", (cutoff,))
|
|
152
|
+
self._connection.commit()
|
|
153
|
+
except Exception as e:
|
|
154
|
+
print(f"Error pruning metrics: {e}")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Package init
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Package init
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# pyrefly: ignore [missing-import]
|
|
2
|
+
from textual.containers import Container, Vertical
|
|
3
|
+
# pyrefly: ignore [missing-import]
|
|
4
|
+
from textual.widgets import SelectionList, Static, Select, Input
|
|
5
|
+
# pyrefly: ignore [missing-import]
|
|
6
|
+
from textual.widgets.selection_list import Selection
|
|
7
|
+
|
|
8
|
+
INTERVAL_OPTIONS: list[tuple[str, float]] = [
|
|
9
|
+
("0.25s", 0.25),
|
|
10
|
+
("0.50s", 0.5),
|
|
11
|
+
("1.00s", 1.0),
|
|
12
|
+
("2.00s", 2.0),
|
|
13
|
+
("5.00s", 5.0),
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
HISTORY_OPTIONS: list[tuple[str, float | None]] = [
|
|
17
|
+
("1m", 60.0),
|
|
18
|
+
("5m", 300.0),
|
|
19
|
+
("10m", 600.0),
|
|
20
|
+
("30m", 1800.0),
|
|
21
|
+
("1h", 3600.0),
|
|
22
|
+
("custom", None)
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def parse_duration(text: str) -> float | None:
|
|
27
|
+
"""Parse a human-readable duration string into seconds.
|
|
28
|
+
Examples: '120s', '2h', '1d', '30m', '1w'.
|
|
29
|
+
Returns None if the string cannot be parsed.
|
|
30
|
+
"""
|
|
31
|
+
text = text.strip().lower()
|
|
32
|
+
units = {"s": 1, "m": 60, "h": 3600, "d": 86400, "w": 604800}
|
|
33
|
+
for suffix, multiplier in units.items():
|
|
34
|
+
if text.endswith(suffix):
|
|
35
|
+
try:
|
|
36
|
+
return float(text[:-1]) * multiplier
|
|
37
|
+
except ValueError:
|
|
38
|
+
return None
|
|
39
|
+
# Plain number — treat as seconds
|
|
40
|
+
try:
|
|
41
|
+
return float(text)
|
|
42
|
+
except ValueError:
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Menu(Container):
|
|
47
|
+
|
|
48
|
+
def compose(self):
|
|
49
|
+
with Vertical(id="menu"):
|
|
50
|
+
yield Static("[bold #61afef]M[/bold #61afef]enu", id="menu_title")
|
|
51
|
+
yield Static("[bold #abb2bf]Widgets:[/bold #abb2bf]", classes="menu_label")
|
|
52
|
+
yield SelectionList[str](id="widget_selector")
|
|
53
|
+
yield Static("[bold #abb2bf]Update Interval:[/bold #abb2bf]", classes="menu_label")
|
|
54
|
+
yield Select(
|
|
55
|
+
options=INTERVAL_OPTIONS,
|
|
56
|
+
value=1.0,
|
|
57
|
+
id="interval_select",
|
|
58
|
+
allow_blank=False,
|
|
59
|
+
)
|
|
60
|
+
yield Static("[bold #abb2bf]History Duration:[/bold #abb2bf]", classes="menu_label")
|
|
61
|
+
yield Select(
|
|
62
|
+
options=HISTORY_OPTIONS,
|
|
63
|
+
value=60.0,
|
|
64
|
+
id="history_select",
|
|
65
|
+
allow_blank=False,
|
|
66
|
+
)
|
|
67
|
+
yield Input(
|
|
68
|
+
placeholder="Duration e.g. 120s, 2h, 1d, 1w",
|
|
69
|
+
id="custom_history_input",
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
def on_mount(self) -> None:
|
|
73
|
+
self.query_one("#custom_history_input", Input).display = False
|
|
74
|
+
self.populate_menu()
|
|
75
|
+
|
|
76
|
+
def populate_menu(self) -> None:
|
|
77
|
+
"""Populates selection list with widget options and updates interval select value."""
|
|
78
|
+
try:
|
|
79
|
+
selection_list = self.query_one(SelectionList)
|
|
80
|
+
selection_list.clear_options()
|
|
81
|
+
for name, widget_id in self.get_available_widgets().items():
|
|
82
|
+
widget = self.app.query_one(f"#{widget_id}")
|
|
83
|
+
selection_list.add_option(
|
|
84
|
+
Selection(prompt=name, value=name, initial_state=widget.display)
|
|
85
|
+
)
|
|
86
|
+
except Exception:
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
interval_select = self.query_one("#interval_select", Select)
|
|
91
|
+
current_interval = getattr(self.app, "update_interval", 1.0)
|
|
92
|
+
if interval_select.value != current_interval:
|
|
93
|
+
interval_select.value = current_interval
|
|
94
|
+
except Exception:
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
def on_select_changed(self, event: Select.Changed) -> None:
|
|
98
|
+
"""Handles changes in the update interval and history duration selectors."""
|
|
99
|
+
if event.select.id == "interval_select" and event.value is not Select.BLANK:
|
|
100
|
+
if hasattr(self.app, "set_update_interval"):
|
|
101
|
+
self.app.set_update_interval(float(event.value))
|
|
102
|
+
|
|
103
|
+
elif event.select.id == "history_select":
|
|
104
|
+
custom_input = self.query_one("#custom_history_input", Input)
|
|
105
|
+
is_custom = event.value is None
|
|
106
|
+
custom_input.display = is_custom
|
|
107
|
+
if is_custom:
|
|
108
|
+
custom_input.focus()
|
|
109
|
+
else:
|
|
110
|
+
# Apply the selected preset duration
|
|
111
|
+
if hasattr(self.app, "set_history_duration"):
|
|
112
|
+
self.app.set_history_duration(float(event.value))
|
|
113
|
+
|
|
114
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
115
|
+
"""Parses and applies custom history duration on Enter."""
|
|
116
|
+
if event.input.id != "custom_history_input":
|
|
117
|
+
return
|
|
118
|
+
duration = parse_duration(event.value)
|
|
119
|
+
if duration is not None and duration > 0:
|
|
120
|
+
if hasattr(self.app, "set_history_duration"):
|
|
121
|
+
self.app.set_history_duration(duration)
|
|
122
|
+
# Return focus to the selection list so the user isn't trapped
|
|
123
|
+
try:
|
|
124
|
+
self.query_one(SelectionList).focus()
|
|
125
|
+
except Exception:
|
|
126
|
+
pass
|
|
127
|
+
else:
|
|
128
|
+
event.input.placeholder = "Invalid — try: 120s, 2h, 1d, 1w"
|
|
129
|
+
|
|
130
|
+
def on_selection_list_selected_changed(self, event: SelectionList.SelectedChanged) -> None:
|
|
131
|
+
"""Toggles widget visibility based on selection changes."""
|
|
132
|
+
selected_names = event.selection_list.selected
|
|
133
|
+
for name, widget_id in self.get_available_widgets().items():
|
|
134
|
+
try:
|
|
135
|
+
widget = self.app.query_one(f"#{widget_id}")
|
|
136
|
+
widget.display = name in selected_names
|
|
137
|
+
except Exception:
|
|
138
|
+
pass
|
|
139
|
+
if hasattr(self.app, "update_layout"):
|
|
140
|
+
self.app.update_layout()
|
|
141
|
+
|
|
142
|
+
def get_available_widgets(self) -> dict[str, str]:
|
|
143
|
+
"""Returns a map of {widget_name: widget_id} from main_container."""
|
|
144
|
+
widgets_map = {}
|
|
145
|
+
try:
|
|
146
|
+
main_container = self.app.query_one("#main_container")
|
|
147
|
+
for child in main_container.children:
|
|
148
|
+
raw_title = getattr(child, "BORDER_TITLE", child.__class__.__name__)
|
|
149
|
+
clean_name = raw_title.split()[0] if raw_title else child.__class__.__name__
|
|
150
|
+
widget_id = child.id or child.__class__.__name__.lower()
|
|
151
|
+
widgets_map[clean_name] = widget_id
|
|
152
|
+
except Exception:
|
|
153
|
+
pass
|
|
154
|
+
return widgets_map
|
|
155
|
+
|
|
156
|
+
def select_widget(self, widget_name: str) -> str | None:
|
|
157
|
+
"""Returns the widget ID for a given widget name."""
|
|
158
|
+
widgets_map = self.get_available_widgets()
|
|
159
|
+
for name, widget_id in widgets_map.items():
|
|
160
|
+
if name.upper() == widget_name.upper():
|
|
161
|
+
return widget_id
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
def toggle_widget(self, widget: str) -> None:
|
|
165
|
+
"""Toggles widget visibility by widget name."""
|
|
166
|
+
widget_id = self.select_widget(widget)
|
|
167
|
+
if widget_id:
|
|
168
|
+
if not widget_id.startswith("#"):
|
|
169
|
+
widget_id = f"#{widget_id}"
|
|
170
|
+
try:
|
|
171
|
+
widget_to_toggle = self.app.query_one(widget_id)
|
|
172
|
+
widget_to_toggle.display = not widget_to_toggle.display
|
|
173
|
+
if hasattr(self.app, "update_layout"):
|
|
174
|
+
self.app.update_layout()
|
|
175
|
+
except Exception:
|
|
176
|
+
pass
|
|
177
|
+
|
|
178
|
+
def get_history_duration(self) -> float | None:
|
|
179
|
+
"""Returns the currently selected history duration in seconds."""
|
|
180
|
+
return self.query_one("#history_select").value
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import math
|
|
2
|
+
from textual.containers import Container
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class WidgetManager:
|
|
6
|
+
"""Manages dynamic grid layouts for an arbitrary number of widgets."""
|
|
7
|
+
|
|
8
|
+
def __init__(self, container: Container):
|
|
9
|
+
self.container = container
|
|
10
|
+
|
|
11
|
+
def update_layout(self) -> None:
|
|
12
|
+
"""Arranges visible widgets dynamically without layout gaps."""
|
|
13
|
+
try:
|
|
14
|
+
visible = [child for child in self.container.children if child.display]
|
|
15
|
+
n = len(visible)
|
|
16
|
+
if n == 0:
|
|
17
|
+
return
|
|
18
|
+
|
|
19
|
+
if n == 1:
|
|
20
|
+
visible[0].styles.column_span = 2
|
|
21
|
+
visible[0].styles.row_span = 2
|
|
22
|
+
elif n == 2:
|
|
23
|
+
for w in visible:
|
|
24
|
+
w.styles.column_span = 2
|
|
25
|
+
w.styles.row_span = 1
|
|
26
|
+
else:
|
|
27
|
+
cols = 2
|
|
28
|
+
rows = math.ceil(n / cols)
|
|
29
|
+
self.container.styles.grid_size_columns = cols
|
|
30
|
+
self.container.styles.grid_size_rows = rows
|
|
31
|
+
|
|
32
|
+
if n % 2 != 0:
|
|
33
|
+
visible[0].styles.column_span = 2
|
|
34
|
+
visible[0].styles.row_span = 1
|
|
35
|
+
for w in visible[1:]:
|
|
36
|
+
w.styles.column_span = 1
|
|
37
|
+
w.styles.row_span = 1
|
|
38
|
+
else:
|
|
39
|
+
for w in visible:
|
|
40
|
+
w.styles.column_span = 1
|
|
41
|
+
w.styles.row_span = 1
|
|
42
|
+
except Exception:
|
|
43
|
+
pass
|