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,207 @@
|
|
|
1
|
+
# pyrefly: ignore [missing-import]
|
|
2
|
+
from textual.widgets import Static
|
|
3
|
+
# pyrefly: ignore [missing-import]
|
|
4
|
+
from textual.containers import Container, Vertical
|
|
5
|
+
from monitorify.ui.tui.widgets.brailleGraph import BrailleGraph, make_time_grid
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def format_bps(bps: float) -> str:
|
|
9
|
+
if bps >= 1024 * 1024 * 1024:
|
|
10
|
+
return f"{bps / (1024 ** 3):.1f} GB/s"
|
|
11
|
+
elif bps >= 1024 * 1024:
|
|
12
|
+
return f"{bps / (1024 * 1024):.1f} MB/s"
|
|
13
|
+
elif bps >= 1024:
|
|
14
|
+
return f"{bps / 1024:.1f} KB/s"
|
|
15
|
+
else:
|
|
16
|
+
return f"{bps:.0f} B/s"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def format_bytes(b: float) -> str:
|
|
20
|
+
if b >= 1024 ** 4:
|
|
21
|
+
return f"{b / (1024 ** 4):.1f} TB"
|
|
22
|
+
elif b >= 1024 ** 3:
|
|
23
|
+
return f"{b / (1024 ** 3):.1f} GB"
|
|
24
|
+
elif b >= 1024 ** 2:
|
|
25
|
+
return f"{b / (1024 ** 2):.1f} MB"
|
|
26
|
+
elif b >= 1024:
|
|
27
|
+
return f"{b / 1024:.1f} KB"
|
|
28
|
+
else:
|
|
29
|
+
return f"{b:.0f} B"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def format_storage_summary(disk_storage: dict | None) -> str:
|
|
33
|
+
if not disk_storage or not isinstance(disk_storage, dict):
|
|
34
|
+
return "Storage: –"
|
|
35
|
+
|
|
36
|
+
total = sum(d.get("total", 0) for d in disk_storage.values() if isinstance(d, dict))
|
|
37
|
+
used = sum(d.get("used", 0) for d in disk_storage.values() if isinstance(d, dict))
|
|
38
|
+
pct = (used / total * 100) if total > 0 else 0
|
|
39
|
+
return f"{format_bytes(used)}/{format_bytes(total)} ({pct:.0f}%)"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class DiskRatePoint:
|
|
43
|
+
def __init__(self, timestamp: float, read_bps: float, write_bps: float):
|
|
44
|
+
self.timestamp = timestamp
|
|
45
|
+
self.read_bps = read_bps
|
|
46
|
+
self.write_bps = write_bps
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def compute_disk_rates(snapshots: list) -> list[DiskRatePoint]:
|
|
50
|
+
points = []
|
|
51
|
+
prev_ts = None
|
|
52
|
+
prev_read = None
|
|
53
|
+
prev_write = None
|
|
54
|
+
|
|
55
|
+
for s in snapshots:
|
|
56
|
+
if s.disk_io is None or not isinstance(s.disk_io, dict):
|
|
57
|
+
prev_ts = None
|
|
58
|
+
continue
|
|
59
|
+
|
|
60
|
+
curr_read = sum(d.get("read_bytes", 0) for d in s.disk_io.values() if isinstance(d, dict))
|
|
61
|
+
curr_write = sum(d.get("write_bytes", 0) for d in s.disk_io.values() if isinstance(d, dict))
|
|
62
|
+
|
|
63
|
+
if prev_ts is not None:
|
|
64
|
+
dt = s.timestamp - prev_ts
|
|
65
|
+
if 0 < dt <= 10.0:
|
|
66
|
+
r_bps = max(0.0, curr_read - prev_read) / dt
|
|
67
|
+
w_bps = max(0.0, curr_write - prev_write) / dt
|
|
68
|
+
points.append(DiskRatePoint(s.timestamp, r_bps, w_bps))
|
|
69
|
+
else:
|
|
70
|
+
points.append(DiskRatePoint(s.timestamp, 0.0, 0.0))
|
|
71
|
+
else:
|
|
72
|
+
points.append(DiskRatePoint(s.timestamp, 0.0, 0.0))
|
|
73
|
+
|
|
74
|
+
prev_ts = s.timestamp
|
|
75
|
+
prev_read = curr_read
|
|
76
|
+
prev_write = curr_write
|
|
77
|
+
|
|
78
|
+
return points
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class DiskInfo(Container):
|
|
82
|
+
def compose(self):
|
|
83
|
+
self.write_current = Static("▲ 0 B/s", classes="tx_text")
|
|
84
|
+
self.write_top = Static("▲ Top: 0 B/s", classes="tx_text")
|
|
85
|
+
|
|
86
|
+
self.read_current = Static("▼ 0 B/s", classes="rx_text")
|
|
87
|
+
self.read_top = Static("▼ Top: 0 B/s", classes="rx_text")
|
|
88
|
+
|
|
89
|
+
self.storage_label = Static("Storage: –", classes="storage_text")
|
|
90
|
+
|
|
91
|
+
yield self.write_current
|
|
92
|
+
yield self.write_top
|
|
93
|
+
yield Static("") # Spacer
|
|
94
|
+
yield self.read_current
|
|
95
|
+
yield self.read_top
|
|
96
|
+
yield Static("") # Spacer
|
|
97
|
+
yield self.storage_label
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class DiskWidget(Container):
|
|
101
|
+
BORDER_TITLE = "DISK"
|
|
102
|
+
|
|
103
|
+
def __init__(self, **kwargs):
|
|
104
|
+
super().__init__(**kwargs)
|
|
105
|
+
self.top_read: float = 0.0
|
|
106
|
+
self.top_write: float = 0.0
|
|
107
|
+
self.current_read: float = 0.0
|
|
108
|
+
self.current_write: float = 0.0
|
|
109
|
+
self.last_timestamp: float | None = None
|
|
110
|
+
self.last_read_bytes: float = 0.0
|
|
111
|
+
self.last_write_bytes: float = 0.0
|
|
112
|
+
|
|
113
|
+
def compose(self):
|
|
114
|
+
self.disk_graph = BrailleGraph(
|
|
115
|
+
data_lines=2,
|
|
116
|
+
color1="#c678dd",
|
|
117
|
+
color2="#61afef",
|
|
118
|
+
show_scale=True,
|
|
119
|
+
scale_fmt=format_bps,
|
|
120
|
+
show_timeline=True,
|
|
121
|
+
id="disk_graph",
|
|
122
|
+
)
|
|
123
|
+
yield self.disk_graph
|
|
124
|
+
|
|
125
|
+
with Vertical(id="disk_info_wrapper"):
|
|
126
|
+
self.disk_info = DiskInfo(id="disk_info")
|
|
127
|
+
self.disk_info.border_title = "write"
|
|
128
|
+
self.disk_info.border_subtitle = "read"
|
|
129
|
+
yield self.disk_info
|
|
130
|
+
|
|
131
|
+
def _calculate_rates(self, snapshot) -> tuple[float, float]:
|
|
132
|
+
if snapshot.disk_io is None or not isinstance(snapshot.disk_io, dict):
|
|
133
|
+
return 0.0, 0.0
|
|
134
|
+
|
|
135
|
+
total_read = sum(d.get("read_bytes", 0) for d in snapshot.disk_io.values() if isinstance(d, dict))
|
|
136
|
+
total_write = sum(d.get("write_bytes", 0) for d in snapshot.disk_io.values() if isinstance(d, dict))
|
|
137
|
+
|
|
138
|
+
if self.last_timestamp is None:
|
|
139
|
+
self.last_timestamp = snapshot.timestamp
|
|
140
|
+
self.last_read_bytes = total_read
|
|
141
|
+
self.last_write_bytes = total_write
|
|
142
|
+
return 0.0, 0.0
|
|
143
|
+
|
|
144
|
+
if snapshot.timestamp <= self.last_timestamp:
|
|
145
|
+
return self.current_read, self.current_write
|
|
146
|
+
|
|
147
|
+
dt = snapshot.timestamp - self.last_timestamp
|
|
148
|
+
if 0 < dt <= 10.0:
|
|
149
|
+
read_bps = max(0.0, total_read - self.last_read_bytes) / dt
|
|
150
|
+
write_bps = max(0.0, total_write - self.last_write_bytes) / dt
|
|
151
|
+
else:
|
|
152
|
+
read_bps = 0.0
|
|
153
|
+
write_bps = 0.0
|
|
154
|
+
|
|
155
|
+
self.last_timestamp = snapshot.timestamp
|
|
156
|
+
self.last_read_bytes = total_read
|
|
157
|
+
self.last_write_bytes = total_write
|
|
158
|
+
self.current_read = read_bps
|
|
159
|
+
self.current_write = write_bps
|
|
160
|
+
|
|
161
|
+
return read_bps, write_bps
|
|
162
|
+
|
|
163
|
+
def update_disk(self, snapshot) -> None:
|
|
164
|
+
"""Update labels and append one live data point to the graph."""
|
|
165
|
+
read_bps, write_bps = self._calculate_rates(snapshot)
|
|
166
|
+
self.top_read = max(self.top_read, read_bps)
|
|
167
|
+
self.top_write = max(self.top_write, write_bps)
|
|
168
|
+
self._refresh_labels(read_bps, write_bps, snapshot.disk_storage)
|
|
169
|
+
self.disk_graph.update_value(write_bps, read_bps)
|
|
170
|
+
|
|
171
|
+
def update_labels(self, snapshot) -> None:
|
|
172
|
+
"""Update only the text labels — does not touch the graph."""
|
|
173
|
+
read_bps, write_bps = self._calculate_rates(snapshot)
|
|
174
|
+
self.top_read = max(self.top_read, read_bps)
|
|
175
|
+
self.top_write = max(self.top_write, write_bps)
|
|
176
|
+
self._refresh_labels(read_bps, write_bps, snapshot.disk_storage)
|
|
177
|
+
|
|
178
|
+
def _refresh_labels(self, read_bps: float, write_bps: float, disk_storage: dict | None) -> None:
|
|
179
|
+
info = self.disk_info
|
|
180
|
+
info.write_current.update(f"▲ {format_bps(write_bps)}")
|
|
181
|
+
info.write_top.update(f"▲ Top: {format_bps(self.top_write)}")
|
|
182
|
+
info.read_current.update(f"▼ {format_bps(read_bps)}")
|
|
183
|
+
info.read_top.update(f"▼ Top: {format_bps(self.top_read)}")
|
|
184
|
+
info.storage_label.update(format_storage_summary(disk_storage))
|
|
185
|
+
|
|
186
|
+
def load_history(self, snapshots: list, time_span: float = 0.0) -> None:
|
|
187
|
+
"""Bulk-load disk I/O history, time-positioned to show real gaps."""
|
|
188
|
+
w = self.disk_graph.size.width * 2 or 400
|
|
189
|
+
rate_points = compute_disk_rates(snapshots)
|
|
190
|
+
read_vals = make_time_grid(rate_points, lambda p: p.read_bps, time_span, w)
|
|
191
|
+
write_vals = make_time_grid(rate_points, lambda p: p.write_bps, time_span, w)
|
|
192
|
+
|
|
193
|
+
real_read = [v for v in read_vals if v is not None]
|
|
194
|
+
real_write = [v for v in write_vals if v is not None]
|
|
195
|
+
if real_read:
|
|
196
|
+
self.top_read = max(real_read)
|
|
197
|
+
if real_write:
|
|
198
|
+
self.top_write = max(real_write)
|
|
199
|
+
|
|
200
|
+
if snapshots:
|
|
201
|
+
last = snapshots[-1]
|
|
202
|
+
if last.disk_io and isinstance(last.disk_io, dict):
|
|
203
|
+
self.last_timestamp = last.timestamp
|
|
204
|
+
self.last_read_bytes = sum(d.get("read_bytes", 0) for d in last.disk_io.values() if isinstance(d, dict))
|
|
205
|
+
self.last_write_bytes = sum(d.get("write_bytes", 0) for d in last.disk_io.values() if isinstance(d, dict))
|
|
206
|
+
|
|
207
|
+
self.disk_graph.load_history(write_vals, read_vals, timeline_seconds=time_span)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# pyrefly: ignore [missing-import]
|
|
2
|
+
from textual.widgets import Static
|
|
3
|
+
# pyrefly: ignore [missing-import]
|
|
4
|
+
from textual.containers import Container, Vertical
|
|
5
|
+
from monitorify.ui.tui.widgets.brailleGraph import BrailleGraph, make_time_grid
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def format_bps(bps):
|
|
9
|
+
if bps >= 1024 * 1024:
|
|
10
|
+
return f"{bps / (1024 * 1024):.1f} MB/s"
|
|
11
|
+
elif bps >= 1024:
|
|
12
|
+
return f"{bps / 1024:.1f} KB/s"
|
|
13
|
+
else:
|
|
14
|
+
return f"{bps:.0f} B/s"
|
|
15
|
+
|
|
16
|
+
def format_bytes(b):
|
|
17
|
+
if b >= 1024 ** 3:
|
|
18
|
+
return f"{b / (1024 ** 3):.1f} GB"
|
|
19
|
+
elif b >= 1024 ** 2:
|
|
20
|
+
return f"{b / (1024 ** 2):.1f} MB"
|
|
21
|
+
elif b >= 1024:
|
|
22
|
+
return f"{b / 1024:.1f} KB"
|
|
23
|
+
else:
|
|
24
|
+
return f"{b:.0f} B"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class NetworkInfo(Container):
|
|
28
|
+
def compose(self):
|
|
29
|
+
self.rx_current = Static("▼ 0 B/s", classes="rx_text")
|
|
30
|
+
self.rx_top = Static("▼ Top: 0 B/s", classes="rx_text")
|
|
31
|
+
|
|
32
|
+
self.tx_current = Static("▲ 0 B/s", classes="tx_text")
|
|
33
|
+
self.tx_top = Static("▲ Top: 0 B/s", classes="tx_text")
|
|
34
|
+
|
|
35
|
+
yield self.rx_current
|
|
36
|
+
yield self.rx_top
|
|
37
|
+
yield Static("") # Spacer
|
|
38
|
+
yield self.tx_current
|
|
39
|
+
yield self.tx_top
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class NetworkWidget(Container):
|
|
43
|
+
BORDER_TITLE = "NET"
|
|
44
|
+
|
|
45
|
+
def __init__(self, **kwargs):
|
|
46
|
+
super().__init__(**kwargs)
|
|
47
|
+
self.top_rx = 0
|
|
48
|
+
self.top_tx = 0
|
|
49
|
+
|
|
50
|
+
def compose(self):
|
|
51
|
+
self.network_graph = BrailleGraph(
|
|
52
|
+
data_lines=2, color1="#61afef", color2="#c678dd",
|
|
53
|
+
show_scale=True, scale_fmt=format_bps,
|
|
54
|
+
show_timeline=True,
|
|
55
|
+
id="network_graph"
|
|
56
|
+
)
|
|
57
|
+
yield self.network_graph
|
|
58
|
+
|
|
59
|
+
with Vertical(id="network_info_wrapper"):
|
|
60
|
+
self.network_info = NetworkInfo(id="network_info")
|
|
61
|
+
self.network_info.border_title = "download"
|
|
62
|
+
self.network_info.border_subtitle = "upload"
|
|
63
|
+
yield self.network_info
|
|
64
|
+
|
|
65
|
+
def update_network(self, snapshot) -> None:
|
|
66
|
+
"""Update labels and append one live data point to the graph."""
|
|
67
|
+
rx_bps, tx_bps = snapshot.network
|
|
68
|
+
self.top_rx = max(self.top_rx, rx_bps)
|
|
69
|
+
self.top_tx = max(self.top_tx, tx_bps)
|
|
70
|
+
self._refresh_labels(rx_bps, tx_bps)
|
|
71
|
+
self.network_graph.update_value(rx_bps, tx_bps)
|
|
72
|
+
|
|
73
|
+
def update_labels(self, snapshot) -> None:
|
|
74
|
+
"""Update only the text labels — does not touch the graph."""
|
|
75
|
+
rx_bps, tx_bps = snapshot.network
|
|
76
|
+
self.top_rx = max(self.top_rx, rx_bps)
|
|
77
|
+
self.top_tx = max(self.top_tx, tx_bps)
|
|
78
|
+
self._refresh_labels(rx_bps, tx_bps)
|
|
79
|
+
|
|
80
|
+
def _refresh_labels(self, rx_bps: float, tx_bps: float) -> None:
|
|
81
|
+
info = self.network_info
|
|
82
|
+
info.rx_current.update(f"▼ {format_bps(rx_bps)}")
|
|
83
|
+
info.rx_top.update(f"▼ Top: {format_bps(self.top_rx)}")
|
|
84
|
+
info.tx_current.update(f"▲ {format_bps(tx_bps)}")
|
|
85
|
+
info.tx_top.update(f"▲ Top: {format_bps(self.top_tx)}")
|
|
86
|
+
|
|
87
|
+
def load_history(self, snapshots: list, time_span: float = 0.0) -> None:
|
|
88
|
+
"""Bulk-load network history, time-positioned to show real gaps."""
|
|
89
|
+
w = self.network_graph.size.width * 2 or 400
|
|
90
|
+
rx_vals = make_time_grid(snapshots, lambda s: s.network[0], time_span, w)
|
|
91
|
+
tx_vals = make_time_grid(snapshots, lambda s: s.network[1], time_span, w)
|
|
92
|
+
real_rx = [v for v in rx_vals if v is not None]
|
|
93
|
+
real_tx = [v for v in tx_vals if v is not None]
|
|
94
|
+
if real_rx:
|
|
95
|
+
self.top_rx = max(real_rx)
|
|
96
|
+
if real_tx:
|
|
97
|
+
self.top_tx = max(real_tx)
|
|
98
|
+
self.network_graph.load_history(rx_vals, tx_vals, timeline_seconds=time_span)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# pyrefly: ignore [missing-import]
|
|
2
|
+
from textual.containers import Container
|
|
3
|
+
# pyrefly: ignore [missing-import]
|
|
4
|
+
from textual.widgets import Static
|
|
5
|
+
from monitorify.stats.programmList import get_process_state, get_process_running_time
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ProcInfoWidget(Container):
|
|
9
|
+
BORDER_TITLE = "Process"
|
|
10
|
+
|
|
11
|
+
def compose(self):
|
|
12
|
+
self.pid_label = Static("PID: -")
|
|
13
|
+
yield self.pid_label
|
|
14
|
+
|
|
15
|
+
self.name_label = Static("Name: -")
|
|
16
|
+
yield self.name_label
|
|
17
|
+
|
|
18
|
+
self.state_label = Static("State: -")
|
|
19
|
+
yield self.state_label
|
|
20
|
+
|
|
21
|
+
self.cpu_label = Static("CPU: -")
|
|
22
|
+
yield self.cpu_label
|
|
23
|
+
|
|
24
|
+
self.ram_label = Static("RAM: -")
|
|
25
|
+
yield self.ram_label
|
|
26
|
+
|
|
27
|
+
self.uptime_label = Static("Uptime: -")
|
|
28
|
+
yield self.uptime_label
|
|
29
|
+
|
|
30
|
+
def update_process(self, d: dict):
|
|
31
|
+
pid = d.get("pid", 0)
|
|
32
|
+
name = d.get("name", "Unknown")
|
|
33
|
+
state = d.get("state") or (get_process_state(pid) if pid else "Unknown")
|
|
34
|
+
uptime = d.get("running_time") or (get_process_running_time(pid) if pid else "Unknown")
|
|
35
|
+
cpu = d.get("cpu_ussage", 0.0)
|
|
36
|
+
ram = d.get("ram_ussage", 0.0)
|
|
37
|
+
|
|
38
|
+
self.border_title = f"PID: {pid}"
|
|
39
|
+
self.pid_label.update(f"PID: {pid}")
|
|
40
|
+
self.name_label.update(f"Name: {name}")
|
|
41
|
+
self.state_label.update(f"State: {state}")
|
|
42
|
+
self.cpu_label.update(f"CPU: {cpu:.1f}%")
|
|
43
|
+
self.ram_label.update(f"RAM: {ram:.1f} MB")
|
|
44
|
+
self.uptime_label.update(f"Uptime: {uptime}")
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# pyrefly: ignore [missing-import]
|
|
2
|
+
from textual.containers import Container
|
|
3
|
+
# pyrefly: ignore [missing-import]
|
|
4
|
+
from textual.widgets import ListItem, ListView, Static
|
|
5
|
+
from textual import on
|
|
6
|
+
from textual.events import Click
|
|
7
|
+
from monitorify.stats.programmList import (
|
|
8
|
+
get_latest_process_items_data,
|
|
9
|
+
start_process_cache_thread,
|
|
10
|
+
_format_process_item,
|
|
11
|
+
)
|
|
12
|
+
from monitorify.ui.tui.widgets.procInfoWidget import ProcInfoWidget
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FastListView(ListView):
|
|
16
|
+
"""ListView subclass with instant reactive callbacks on scroll or index changes."""
|
|
17
|
+
|
|
18
|
+
def watch_scroll_y(self, old_value: float, new_value: float) -> None:
|
|
19
|
+
super().watch_scroll_y(old_value, new_value)
|
|
20
|
+
if hasattr(self, "on_scroll_cb") and self.on_scroll_cb:
|
|
21
|
+
self.on_scroll_cb()
|
|
22
|
+
|
|
23
|
+
def watch_index(self, old_value: int | None, new_value: int | None) -> None:
|
|
24
|
+
super().watch_index(old_value, new_value)
|
|
25
|
+
if hasattr(self, "on_scroll_cb") and self.on_scroll_cb:
|
|
26
|
+
self.on_scroll_cb()
|
|
27
|
+
if hasattr(self, "on_highlight_cb") and self.on_highlight_cb:
|
|
28
|
+
self.on_highlight_cb()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ProgrammListWidget(Container):
|
|
32
|
+
BORDER_TITLE = "PROC"
|
|
33
|
+
|
|
34
|
+
def __init__(self, **kwargs):
|
|
35
|
+
super().__init__(**kwargs)
|
|
36
|
+
self.last_cache_version = -1
|
|
37
|
+
self.rendered_limit = 0
|
|
38
|
+
self.sort_by = "cpu_ussage"
|
|
39
|
+
self.sort_reverse = True
|
|
40
|
+
self.current_sorted_data = []
|
|
41
|
+
self.total_count = 0
|
|
42
|
+
self.active_info_pid = None
|
|
43
|
+
|
|
44
|
+
def compose(self):
|
|
45
|
+
self.info_static = Static("Processes: 0", id="programm_list_info")
|
|
46
|
+
yield self.info_static
|
|
47
|
+
|
|
48
|
+
header_str = f"[bold underline]{'PID':>6}[/bold underline] [bold underline]{'NAME':<25}[/bold underline] [bold underline]{'RAM(MB)':>10}[/bold underline] [bold underline]{'CPU(%)':>7}[/bold underline]"
|
|
49
|
+
self.header_static = Static(header_str, id="programm_list_header")
|
|
50
|
+
yield self.header_static
|
|
51
|
+
|
|
52
|
+
self.list_view = FastListView(classes="programm_list_view")
|
|
53
|
+
self.list_view.on_scroll_cb = self.check_expand
|
|
54
|
+
self.list_view.on_highlight_cb = self.update_details
|
|
55
|
+
yield self.list_view
|
|
56
|
+
|
|
57
|
+
def on_mount(self):
|
|
58
|
+
start_process_cache_thread(interval=1.5)
|
|
59
|
+
self.proc_info_box = ProcInfoWidget(id="proc_info_box")
|
|
60
|
+
self.screen.mount(self.proc_info_box)
|
|
61
|
+
self.update_list()
|
|
62
|
+
|
|
63
|
+
def update_info_box_position(self):
|
|
64
|
+
if hasattr(self, "proc_info_box") and self.proc_info_box:
|
|
65
|
+
box_width = 30 # matches StatusWidget width
|
|
66
|
+
self.proc_info_box.styles.offset = (
|
|
67
|
+
self.region.x + self.size.width - box_width,
|
|
68
|
+
self.region.y,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
def on_resize(self):
|
|
72
|
+
self.update_info_box_position()
|
|
73
|
+
|
|
74
|
+
@on(ListView.Selected)
|
|
75
|
+
def on_list_view_selected(self, event: ListView.Selected) -> None:
|
|
76
|
+
idx = self.list_view.index
|
|
77
|
+
if idx is not None and 0 <= idx < len(self.current_sorted_data):
|
|
78
|
+
d = self.current_sorted_data[idx]
|
|
79
|
+
pid = d.get("pid")
|
|
80
|
+
if pid is not None:
|
|
81
|
+
if self.active_info_pid == pid:
|
|
82
|
+
self.active_info_pid = None
|
|
83
|
+
else:
|
|
84
|
+
self.active_info_pid = pid
|
|
85
|
+
self.update_details()
|
|
86
|
+
|
|
87
|
+
@on(ListView.Highlighted)
|
|
88
|
+
def on_list_view_highlighted(self, event: ListView.Highlighted) -> None:
|
|
89
|
+
if self.active_info_pid is not None:
|
|
90
|
+
idx = self.list_view.index
|
|
91
|
+
if idx is not None and 0 <= idx < len(self.current_sorted_data):
|
|
92
|
+
d = self.current_sorted_data[idx]
|
|
93
|
+
pid = d.get("pid")
|
|
94
|
+
if pid is not None:
|
|
95
|
+
self.active_info_pid = pid
|
|
96
|
+
self.update_details()
|
|
97
|
+
|
|
98
|
+
def update_details(self) -> None:
|
|
99
|
+
if not hasattr(self, "info_static") or not hasattr(self, "list_view"):
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
self.info_static.update(f"Processes: {self.total_count}")
|
|
103
|
+
|
|
104
|
+
if not self.display or self.active_info_pid is None:
|
|
105
|
+
if hasattr(self, "proc_info_box") and self.proc_info_box:
|
|
106
|
+
self.proc_info_box.display = False
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
self.update_info_box_position()
|
|
110
|
+
|
|
111
|
+
active_data = None
|
|
112
|
+
for d in self.current_sorted_data:
|
|
113
|
+
if d.get("pid") == self.active_info_pid:
|
|
114
|
+
active_data = d
|
|
115
|
+
break
|
|
116
|
+
|
|
117
|
+
if active_data and hasattr(self, "proc_info_box") and self.proc_info_box:
|
|
118
|
+
self.proc_info_box.update_process(active_data)
|
|
119
|
+
self.proc_info_box.display = True
|
|
120
|
+
else:
|
|
121
|
+
if hasattr(self, "proc_info_box") and self.proc_info_box:
|
|
122
|
+
self.proc_info_box.display = False
|
|
123
|
+
|
|
124
|
+
def check_expand(self):
|
|
125
|
+
if not hasattr(self, "list_view"):
|
|
126
|
+
return
|
|
127
|
+
idx = self.list_view.index or 0
|
|
128
|
+
scroll_y = int(self.list_view.scroll_y)
|
|
129
|
+
height = self.size.height or 20
|
|
130
|
+
needed = max(idx, scroll_y + height) + 30
|
|
131
|
+
if needed > self.rendered_limit:
|
|
132
|
+
self.rendered_limit = max(self.rendered_limit, needed + int(height * 4))
|
|
133
|
+
self.update_list(force=True)
|
|
134
|
+
|
|
135
|
+
@on(Click, "#programm_list_header")
|
|
136
|
+
def on_header_click(self, event: Click) -> None:
|
|
137
|
+
x = event.x
|
|
138
|
+
new_sort = self.sort_by
|
|
139
|
+
if x <= 7:
|
|
140
|
+
new_sort = "pid"
|
|
141
|
+
elif x <= 34:
|
|
142
|
+
new_sort = "name"
|
|
143
|
+
elif x <= 46:
|
|
144
|
+
new_sort = "ram_ussage"
|
|
145
|
+
else:
|
|
146
|
+
new_sort = "cpu_ussage"
|
|
147
|
+
|
|
148
|
+
if self.sort_by == new_sort:
|
|
149
|
+
self.sort_reverse = not self.sort_reverse
|
|
150
|
+
else:
|
|
151
|
+
self.sort_by = new_sort
|
|
152
|
+
self.sort_reverse = True if new_sort in ["ram_ussage", "cpu_ussage"] else False
|
|
153
|
+
|
|
154
|
+
self.update_list(force=True)
|
|
155
|
+
|
|
156
|
+
def update_list(self, force: bool = False):
|
|
157
|
+
if not hasattr(self, "info_static") or not hasattr(self, "list_view"):
|
|
158
|
+
return
|
|
159
|
+
|
|
160
|
+
version, count, items_data = get_latest_process_items_data()
|
|
161
|
+
self.total_count = count
|
|
162
|
+
|
|
163
|
+
if not force and version == self.last_cache_version:
|
|
164
|
+
self.update_details()
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
self.last_cache_version = version
|
|
168
|
+
|
|
169
|
+
# Sort items_data based on user selection
|
|
170
|
+
if self.sort_by in ["pid", "ram_ussage", "cpu_ussage"]:
|
|
171
|
+
items_data.sort(key=lambda d: d.get(self.sort_by, 0), reverse=self.sort_reverse)
|
|
172
|
+
else:
|
|
173
|
+
items_data.sort(key=lambda d: str(d.get(self.sort_by, "")).lower(), reverse=self.sort_reverse)
|
|
174
|
+
|
|
175
|
+
height = self.size.height or 20
|
|
176
|
+
max_visible = max(20, int(height * 2.5))
|
|
177
|
+
current_index = self.list_view.index or 0
|
|
178
|
+
scroll_y = int(self.list_view.scroll_y)
|
|
179
|
+
|
|
180
|
+
needed = max(current_index, scroll_y + height) + 30
|
|
181
|
+
self.rendered_limit = max(self.rendered_limit, max_visible, needed)
|
|
182
|
+
|
|
183
|
+
sliced_data = items_data[:self.rendered_limit]
|
|
184
|
+
self.current_sorted_data = sliced_data
|
|
185
|
+
existing_items = list(self.list_view.query(ListItem))
|
|
186
|
+
new_count = len(sliced_data)
|
|
187
|
+
existing_count = len(existing_items)
|
|
188
|
+
|
|
189
|
+
# In-place update
|
|
190
|
+
update_limit = min(existing_count, new_count)
|
|
191
|
+
for i in range(update_limit):
|
|
192
|
+
formatted_text = _format_process_item(sliced_data[i])
|
|
193
|
+
existing_items[i].query_one(Static).update(formatted_text)
|
|
194
|
+
|
|
195
|
+
if new_count > existing_count:
|
|
196
|
+
new_items = [ListItem(Static(_format_process_item(d))) for d in sliced_data[existing_count:]]
|
|
197
|
+
self.list_view.extend(new_items)
|
|
198
|
+
elif existing_count > new_count:
|
|
199
|
+
for item in existing_items[new_count:]:
|
|
200
|
+
item.remove()
|
|
201
|
+
|
|
202
|
+
self.update_details()
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# pyrefly: ignore [missing-import]
|
|
2
|
+
from textual.widgets import Static
|
|
3
|
+
# pyrefly: ignore [missing-import]
|
|
4
|
+
from textual.containers import Container
|
|
5
|
+
from monitorify.ui.tui.widgets.brailleGraph import BrailleGraph, make_time_grid
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class RamWidget(Container):
|
|
9
|
+
BORDER_TITLE = "MEM"
|
|
10
|
+
|
|
11
|
+
def compose(self):
|
|
12
|
+
self.ram_usage_static = Static("–%")
|
|
13
|
+
yield self.ram_usage_static
|
|
14
|
+
|
|
15
|
+
self.sparkline = BrailleGraph(
|
|
16
|
+
data_lines=1, color1="#c678dd", y_max=100.0,
|
|
17
|
+
show_timeline=True,
|
|
18
|
+
id="ram_sparkline"
|
|
19
|
+
)
|
|
20
|
+
yield self.sparkline
|
|
21
|
+
|
|
22
|
+
def update_ram(self, snapshot) -> None:
|
|
23
|
+
"""Update label and append one live data point to the graph."""
|
|
24
|
+
usage = snapshot.ram
|
|
25
|
+
self.ram_usage_static.update(f"{usage:.1f}%")
|
|
26
|
+
self.sparkline.update_value(usage)
|
|
27
|
+
|
|
28
|
+
def update_text(self, snapshot) -> None:
|
|
29
|
+
"""Update only the label — does not touch the graph."""
|
|
30
|
+
self.ram_usage_static.update(f"{snapshot.ram:.1f}%")
|
|
31
|
+
|
|
32
|
+
def load_history(self, snapshots: list, time_span: float = 0.0) -> None:
|
|
33
|
+
"""Bulk-load RAM history, time-positioned to show real gaps."""
|
|
34
|
+
w = self.sparkline.size.width * 2 or 400
|
|
35
|
+
values = make_time_grid(snapshots, lambda s: s.ram, time_span, w)
|
|
36
|
+
self.sparkline.load_history(values, timeline_seconds=time_span)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from textual.containers import Container
|
|
2
|
+
from textual.widgets import Static
|
|
3
|
+
import socket
|
|
4
|
+
|
|
5
|
+
class StatusWidget(Container):
|
|
6
|
+
BORDER_TITLE = "Status"
|
|
7
|
+
|
|
8
|
+
def compose(self):
|
|
9
|
+
self.system_info = Static(f"System: {get_kernel_version()}")
|
|
10
|
+
yield self.system_info
|
|
11
|
+
|
|
12
|
+
self.hostname = Static(f"Hostname: {get_hostname()}")
|
|
13
|
+
yield self.hostname
|
|
14
|
+
|
|
15
|
+
self.uptime = Static(f"Uptime: {get_uptime()}")
|
|
16
|
+
yield self.uptime
|
|
17
|
+
|
|
18
|
+
def on_mount(self):
|
|
19
|
+
self.set_interval(1, self.update_uptime)
|
|
20
|
+
|
|
21
|
+
def update_uptime(self):
|
|
22
|
+
self.uptime.update(f"Uptime: {get_uptime()}")
|
|
23
|
+
|
|
24
|
+
def get_uptime():
|
|
25
|
+
with open("/proc/uptime", "r") as f:
|
|
26
|
+
uptime = float(f.read().split()[0])
|
|
27
|
+
hours = int(uptime // 3600)
|
|
28
|
+
minutes = int((uptime % 3600) // 60)
|
|
29
|
+
seconds = int(uptime % 60)
|
|
30
|
+
return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
|
|
31
|
+
|
|
32
|
+
def get_hostname():
|
|
33
|
+
return socket.gethostname()
|
|
34
|
+
|
|
35
|
+
def get_OS_name():
|
|
36
|
+
with open("/etc/os-release", "r") as f:
|
|
37
|
+
for line in f:
|
|
38
|
+
if line.startswith("NAME="):
|
|
39
|
+
name = line.split("=")[1].strip().strip('"')
|
|
40
|
+
return name.replace("Linux", "").strip()
|
|
41
|
+
return "Unknown"
|
|
42
|
+
|
|
43
|
+
def get_kernel_version():
|
|
44
|
+
with open("/proc/version", "r") as f:
|
|
45
|
+
return f.read().split()[2]
|