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
@@ -0,0 +1,168 @@
1
+ Screen {
2
+ background: transparent;
3
+ padding: 0;
4
+ }
5
+
6
+ #main_container {
7
+ layout: grid;
8
+ grid-size: 2;
9
+ grid-columns: 1fr 1fr;
10
+ height: 100%;
11
+ width: 100%;
12
+ }
13
+
14
+ CpuWidget,
15
+ RamWidget,
16
+ NetworkWidget,
17
+ ProgrammListWidget,
18
+ DiskWidget {
19
+ border: round #5c6370;
20
+ border-title-color: #61afef;
21
+ border-title-align: left;
22
+ padding: 0 1;
23
+ margin: 0;
24
+ height: 100%;
25
+ }
26
+
27
+ NetworkWidget,
28
+ DiskWidget {
29
+ layout: horizontal;
30
+ }
31
+
32
+ /* Dynamic layout handled in Python */
33
+
34
+ CpuWidget>Static,
35
+ RamWidget>Static,
36
+ ProgrammListWidget>Static {
37
+ text-style: bold;
38
+ color: #abb2bf;
39
+ margin-bottom: 1;
40
+ }
41
+
42
+ #programm_list_view,
43
+ .programm_list_view {
44
+ height: 1fr;
45
+ background: transparent;
46
+ border: none;
47
+ }
48
+
49
+ #programm_list_view ListItem,
50
+ .programm_list_view ListItem {
51
+ background: transparent;
52
+ padding: 0 1;
53
+ }
54
+
55
+ #programm_list_view ListItem:hover,
56
+ .programm_list_view ListItem:hover {
57
+ background: #3e4451;
58
+ }
59
+
60
+ #programm_list_view ListItem.--highlight,
61
+ .programm_list_view ListItem.--highlight {
62
+ background: #4b5263;
63
+ color: #61afef;
64
+ }
65
+
66
+ #network_graph,
67
+ #disk_graph {
68
+ width: 1fr;
69
+ height: 100%;
70
+ }
71
+
72
+ #network_info_wrapper,
73
+ #disk_info_wrapper {
74
+ width: 26;
75
+ height: 100%;
76
+ align: center middle;
77
+ margin-left: 1;
78
+ }
79
+
80
+ #network_info,
81
+ #disk_info {
82
+ width: 100%;
83
+ height: auto;
84
+ border: round #5c6370;
85
+ border-title-color: #61afef;
86
+ border-title-align: right;
87
+ border-subtitle-color: #61afef;
88
+ border-subtitle-align: right;
89
+ padding: 0 1;
90
+ }
91
+
92
+ #network_info Static,
93
+ #disk_info Static {
94
+ text-style: bold;
95
+ }
96
+
97
+ .rx_text {
98
+ color: #61afef;
99
+ }
100
+
101
+ .tx_text {
102
+ color: #c678dd;
103
+ }
104
+
105
+ .storage_text {
106
+ color: #98c379;
107
+ }
108
+
109
+ HistorySparkline {
110
+ color: #c678dd;
111
+ }
112
+
113
+ #cpu_sparkline {
114
+ height: 1fr;
115
+ }
116
+
117
+ StatusWidget {
118
+ position: absolute;
119
+ width: 30;
120
+ height: auto;
121
+ border: round #5c6370;
122
+ border-title-color: #61afef;
123
+ border-title-align: left;
124
+ padding: 0 1;
125
+ }
126
+
127
+ Menu {
128
+ display: none;
129
+ position: absolute;
130
+ align: center middle;
131
+ width: 40;
132
+ height: auto;
133
+ border: round #5c6370;
134
+ padding: 1 2;
135
+ }
136
+
137
+ #menu_title {
138
+ text-style: bold;
139
+ color: #61afef;
140
+ margin-bottom: 0;
141
+ }
142
+
143
+ .menu_label {
144
+ text-style: bold;
145
+ color: #abb2bf;
146
+ margin-top: 1;
147
+ margin-bottom: 0;
148
+ }
149
+
150
+ #widget_selector {
151
+ height: 6;
152
+ }
153
+
154
+ #interval_select {
155
+ margin-top: 0;
156
+ margin-bottom: 0;
157
+ }
158
+
159
+ ProcInfoWidget {
160
+ position: absolute;
161
+ width: 30;
162
+ height: auto;
163
+ border: round #5c6370;
164
+ border-title-color: #61afef;
165
+ border-title-align: left;
166
+ padding: 0 1;
167
+ display: none;
168
+ }
@@ -0,0 +1,187 @@
1
+ import time
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ # pyrefly: ignore [missing-import]
6
+ from textual.app import App, ComposeResult
7
+ # pyrefly: ignore [missing-import]
8
+ from textual.widgets import Header, Footer, Label
9
+ from textual.containers import Container, Center, Middle
10
+ from monitorify.ui.tui.widgets.cpuWidget import CpuWidget
11
+ from monitorify.ui.tui.widgets.ramWidget import RamWidget
12
+ from monitorify.ui.tui.widgets.networkWidget import NetworkWidget
13
+ from monitorify.ui.tui.widgets.diskWidget import DiskWidget
14
+ from monitorify.ui.tui.components.menu import Menu
15
+ from monitorify.ui.tui.components.widgetManager import WidgetManager
16
+ from monitorify.ui.tui.widgets.programmListWidget import ProgrammListWidget
17
+ from monitorify.storage import Database
18
+ from monitorify import config
19
+
20
+
21
+ class MonitorifyApp(App):
22
+ CSS_PATH = "css/tui.css"
23
+
24
+ TITLE = "Monitorify"
25
+ BINDINGS = [
26
+ ("q", "quit"),
27
+ ("c", "quit"),
28
+ ("m", "menu")
29
+ ]
30
+
31
+ def __init__(self, **kwargs):
32
+ super().__init__(**kwargs)
33
+ # Read-only DB connection — data is written by the daemon
34
+ self.db = Database(config.DB_PATH)
35
+ self.db.connect()
36
+ # Default history window: 1 minute
37
+ self.history_duration: float = 60.0
38
+ # Tracks when graphs were last refreshed (for long-window throttling)
39
+ self._last_graph_refresh: float = 0.0
40
+
41
+ def compose(self) -> ComposeResult:
42
+ with Container(id="main_container"):
43
+ yield CpuWidget(id="cpu_widget")
44
+ yield RamWidget(id="ram_widget")
45
+ yield ProgrammListWidget(id="programmList_widget")
46
+ yield NetworkWidget(id="network_widget")
47
+ yield DiskWidget(id="disk_widget")
48
+ yield Menu()
49
+ with Center(id="warning_container"):
50
+ with Middle():
51
+ yield Label("Terminal window is too small!\nPlease resize.", id="warning_label")
52
+
53
+ def on_mount(self) -> None:
54
+ self.action_get_window_size()
55
+ self.widget_manager = WidgetManager(self.query_one("#main_container"))
56
+ self.update_layout()
57
+ self.update_interval = 1.0
58
+ # Load history from DB on startup
59
+ self.call_after_refresh(self._load_initial_history)
60
+ self.update_timer = self.set_interval(self.update_interval, self.update_display)
61
+
62
+ def _load_initial_history(self) -> None:
63
+ """Load history from DB after the first render so widgets have their sizes."""
64
+ self.set_history_duration(self.history_duration)
65
+
66
+ def set_update_interval(self, seconds: float) -> None:
67
+ """Updates the graph refresh interval."""
68
+ self.update_interval = seconds
69
+ if hasattr(self, "update_timer") and self.update_timer:
70
+ self.update_timer.stop()
71
+ self.update_timer = self.set_interval(self.update_interval, self.update_display)
72
+
73
+ def set_history_duration(self, seconds: float) -> None:
74
+ """Load historical metrics from DB and push them into all graph widgets."""
75
+ self.history_duration = seconds
76
+ start_time = time.time() - seconds
77
+ snapshots = self.db.get_history(limit=10000, start_time=start_time)
78
+
79
+ try:
80
+ self.query_one(CpuWidget).load_history(snapshots, time_span=seconds)
81
+ except Exception:
82
+ pass
83
+ try:
84
+ self.query_one(RamWidget).load_history(snapshots, time_span=seconds)
85
+ except Exception:
86
+ pass
87
+ try:
88
+ self.query_one(NetworkWidget).load_history(snapshots, time_span=seconds)
89
+ except Exception:
90
+ pass
91
+ try:
92
+ self.query_one(DiskWidget).load_history(snapshots, time_span=seconds)
93
+ except Exception:
94
+ pass
95
+
96
+ def update_layout(self) -> None:
97
+ """Delegates layout updates to WidgetManager."""
98
+ if hasattr(self, "widget_manager"):
99
+ self.widget_manager.update_layout()
100
+
101
+ def update_display(self) -> None:
102
+ """Fetch the latest snapshot and update widgets.
103
+
104
+ Text labels always update every tick for a live feel.
105
+ Graphs are only updated when at least one visual slot worth of time
106
+ has passed — prevents meaningless scrolling on long history windows.
107
+ """
108
+ self.action_get_window_size()
109
+
110
+ if not self.query_one("#main_container").display:
111
+ return
112
+
113
+ snapshot = self.db.get_latest()
114
+ if snapshot is None:
115
+ return
116
+
117
+ now = time.time()
118
+ # Approximate visual width in braille columns
119
+ approx_graph_w = max(1, self.size.width * 2)
120
+ slot_secs = self.history_duration / approx_graph_w
121
+ # Graphs update at most once per visual slot, but at least as often as update_interval
122
+ graph_refresh_due = (now - self._last_graph_refresh) >= max(slot_secs, self.update_interval)
123
+
124
+ try:
125
+ if graph_refresh_due:
126
+ self.query_one(CpuWidget).update_cpu(snapshot)
127
+ else:
128
+ self.query_one(CpuWidget).update_text(snapshot)
129
+ except Exception:
130
+ pass
131
+ try:
132
+ if graph_refresh_due:
133
+ self.query_one(RamWidget).update_ram(snapshot)
134
+ else:
135
+ self.query_one(RamWidget).update_text(snapshot)
136
+ except Exception:
137
+ pass
138
+ try:
139
+ if graph_refresh_due:
140
+ self.query_one(NetworkWidget).update_network(snapshot)
141
+ else:
142
+ self.query_one(NetworkWidget).update_labels(snapshot)
143
+ except Exception:
144
+ pass
145
+ try:
146
+ if graph_refresh_due:
147
+ self.query_one(DiskWidget).update_disk(snapshot)
148
+ else:
149
+ self.query_one(DiskWidget).update_labels(snapshot)
150
+ except Exception:
151
+ pass
152
+ try:
153
+ self.query_one(ProgrammListWidget).update_list()
154
+ except Exception:
155
+ pass
156
+
157
+ if graph_refresh_due:
158
+ self._last_graph_refresh = now
159
+
160
+ def action_get_window_size(self) -> None:
161
+ is_too_small = self.size.width < 80 or self.size.height < 24
162
+
163
+ if is_too_small:
164
+ self.query_one("#main_container").display = False
165
+ self.query_one("#warning_container").display = True
166
+ else:
167
+ self.query_one("#main_container").display = True
168
+ self.query_one("#warning_container").display = False
169
+
170
+ def action_menu(self) -> None:
171
+ menu = self.query_one(Menu)
172
+ menu.display = not menu.display
173
+ if menu.display:
174
+ menu.populate_menu()
175
+ try:
176
+ from textual.widgets import SelectionList
177
+ menu.query_one(SelectionList).focus()
178
+ except Exception:
179
+ pass
180
+
181
+ def on_unmount(self) -> None:
182
+ self.db.disconnect()
183
+
184
+
185
+ if __name__ == "__main__":
186
+ app = MonitorifyApp()
187
+ app.run()
@@ -0,0 +1 @@
1
+ from monitorify.ui.tui.widgets.diskWidget import DiskWidget
@@ -0,0 +1,261 @@
1
+ import time as _time
2
+ from textual.widget import Widget
3
+ from textual.app import RenderResult
4
+ from collections import deque
5
+ from rich.text import Text
6
+ from rich.style import Style
7
+
8
+ _SCALE_W = 7 # chars reserved for Y-axis scale
9
+ _DIM = Style(color="#3e4452")
10
+ _TICK = Style(color="#abb2bf")
11
+
12
+
13
+ def make_time_grid(snapshots, value_fn, time_span: float, width: int) -> list:
14
+ """Map snapshots to a time-positioned list[float | None].
15
+ Slots with no snapshot remain None — the graph draws nothing there.
16
+ This ensures gaps where no daemon was running appear as empty space.
17
+ """
18
+ if not snapshots or time_span <= 0 or width <= 0:
19
+ # Fall back to plain chronological list (no gap info)
20
+ return [value_fn(s) for s in snapshots]
21
+
22
+ now = _time.time()
23
+ start = now - time_span
24
+ grid: list = [None] * width
25
+
26
+ for s in snapshots:
27
+ if s.timestamp < start:
28
+ continue
29
+ pos = int((s.timestamp - start) / time_span * (width - 1))
30
+ pos = max(0, min(width - 1, pos))
31
+ grid[pos] = value_fn(s)
32
+
33
+ return grid
34
+
35
+
36
+ class BrailleGraph(Widget):
37
+ DEFAULT_CSS = """
38
+ BrailleGraph {
39
+ height: 1fr;
40
+ min-height: 4;
41
+ }
42
+ """
43
+
44
+ def __init__(
45
+ self,
46
+ data_lines: int = 1,
47
+ max_points: int = 2000,
48
+ color1: str = "#c678dd",
49
+ color2: str = "#61afef",
50
+ y_max: float | None = None,
51
+ min_dots: int = 1,
52
+ show_scale: bool = False,
53
+ scale_fmt=None,
54
+ show_timeline: bool = False,
55
+ **kwargs,
56
+ ):
57
+ super().__init__(**kwargs)
58
+ self.data_lines = data_lines
59
+ self.max_points = max_points
60
+ self.color1 = Style(color=color1)
61
+ self.color2 = Style(color=color2)
62
+ self.y_max = y_max
63
+ self.min_dots = min_dots
64
+ self.show_scale = show_scale
65
+ self.scale_fmt = scale_fmt or (lambda v: f"{v:.1f}%")
66
+ self.show_timeline = show_timeline
67
+ self.timeline_seconds: float = 0.0
68
+
69
+ self.history1 = deque(maxlen=max_points)
70
+ if self.data_lines > 1:
71
+ self.history2 = deque(maxlen=max_points)
72
+
73
+ # ── helpers ──────────────────────────────────────────────────────────────
74
+
75
+ @staticmethod
76
+ def _fmt_age(secs: float) -> str:
77
+ """Format a number of seconds as a short age label."""
78
+ if secs < 1:
79
+ return "now"
80
+ if secs < 60:
81
+ return f"{int(secs)}s"
82
+ if secs < 3600:
83
+ return f"{int(secs / 60)}m"
84
+ h = secs / 3600
85
+ return f"{h:.0f}h" if h == int(h) else f"{h:.1f}h"
86
+
87
+ def _build_timeline_row(self, width_chars: int) -> Text:
88
+ """Build the timeline row shown below the graph."""
89
+ row = list("─" * width_chars)
90
+
91
+ if self.timeline_seconds > 0 and width_chars >= 8:
92
+ num_ticks = min(5, max(2, width_chars // 8))
93
+ for i in range(num_ticks):
94
+ frac = i / (num_ticks - 1) if num_ticks > 1 else 0
95
+ age = self.timeline_seconds * (1 - frac)
96
+ label = self._fmt_age(age)
97
+ pos = int(frac * (width_chars - 1))
98
+ start = pos - len(label) // 2
99
+ start = max(0, min(start, width_chars - len(label)))
100
+ for j, ch in enumerate(label):
101
+ if 0 <= start + j < width_chars:
102
+ row[start + j] = ch
103
+
104
+ text = Text()
105
+ for ch in row:
106
+ text.append(ch, _TICK if ch != "─" else _DIM)
107
+ return text
108
+
109
+ def _make_scale_label(self, value: float, style: Style) -> Text:
110
+ """Right-aligned, separator-prefixed scale label."""
111
+ label = self.scale_fmt(value)[-(_SCALE_W - 1):]
112
+ text = Text()
113
+ text.append("┤", _DIM)
114
+ text.append(label.rjust(_SCALE_W - 1), style)
115
+ return text
116
+
117
+ # ── public API ────────────────────────────────────────────────────────────
118
+
119
+ def load_history(
120
+ self,
121
+ values1: list,
122
+ values2: list | None = None,
123
+ timeline_seconds: float = 0.0,
124
+ ) -> None:
125
+ """Load historical data (list[float | None]) into the graph.
126
+ None entries represent time slots with no data — nothing is drawn there.
127
+ """
128
+ self.timeline_seconds = timeline_seconds
129
+ self.history1 = deque(values1, maxlen=self.max_points)
130
+ if self.data_lines > 1 and values2 is not None:
131
+ self.history2 = deque(values2, maxlen=self.max_points)
132
+ self.refresh()
133
+
134
+ def update_value(self, val1: float, val2: float = 0):
135
+ self.history1.append(val1)
136
+ if self.data_lines > 1:
137
+ self.history2.append(val2)
138
+ self.refresh()
139
+
140
+ # ── render ────────────────────────────────────────────────────────────────
141
+
142
+ def render(self) -> RenderResult:
143
+ width_chars = self.size.width
144
+ height_chars = self.size.height
145
+ if width_chars <= 0 or height_chars <= 0:
146
+ return ""
147
+
148
+ timeline_rows = 1 if (self.show_timeline and height_chars > 2) else 0
149
+ scale_w = _SCALE_W if self.show_scale else 0
150
+ graph_chars = max(1, width_chars - scale_w)
151
+ graph_rows = max(1, height_chars - timeline_rows)
152
+
153
+ w = graph_chars * 2
154
+ h = graph_rows * 4
155
+
156
+ if w > self.max_points:
157
+ self.max_points = w
158
+ self.history1 = deque(self.history1, maxlen=self.max_points)
159
+ if self.data_lines > 1:
160
+ self.history2 = deque(self.history2, maxlen=self.max_points)
161
+
162
+ grid = [[False] * w for _ in range(h)]
163
+ data1 = list(self.history1)
164
+
165
+ if self.data_lines == 1:
166
+ real_vals = [v for v in data1 if v is not None]
167
+ if self.y_max is not None:
168
+ max_val = self.y_max
169
+ else:
170
+ max_val = max(real_vals) if real_vals else 1
171
+ max_val = max(max_val, 1)
172
+
173
+ num_points = min(w, len(data1))
174
+ start_x = w - num_points
175
+
176
+ for i in range(num_points):
177
+ x = start_x + i
178
+ val = data1[len(data1) - num_points + i]
179
+ if val is None:
180
+ continue # Gap — no daemon data for this slot
181
+ dots = max(self.min_dots, round((val / max_val) * h))
182
+ for y in range(h - dots, h):
183
+ if 0 <= y < h:
184
+ grid[y][x] = True
185
+
186
+ else:
187
+ data2 = list(self.history2)
188
+ if self.y_max is not None:
189
+ max_val = self.y_max
190
+ else:
191
+ real1 = [v for v in data1 if v is not None]
192
+ real2 = [v for v in data2 if v is not None]
193
+ max_val = max(
194
+ max(real1) if real1 else 1,
195
+ max(real2) if real2 else 1,
196
+ 1,
197
+ )
198
+
199
+ center_y = h // 2
200
+ num_points = min(w, len(data1))
201
+ start_x = w - num_points
202
+
203
+ for i in range(num_points):
204
+ x = start_x + i
205
+ idx = len(data1) - num_points + i
206
+ v1 = data1[idx]
207
+ v2 = data2[idx] if idx < len(data2) else None
208
+ if v1 is None and v2 is None:
209
+ continue # Gap — skip entirely
210
+ v1 = v1 or 0.0
211
+ v2 = v2 or 0.0
212
+ dots1 = int((v1 / max_val) * (h / 2))
213
+ if v1 > 0 and dots1 == 0:
214
+ dots1 = self.min_dots
215
+ dots2 = int((v2 / max_val) * (h / 2))
216
+ if v2 > 0 and dots2 == 0:
217
+ dots2 = self.min_dots
218
+ for y in range(center_y - dots1, center_y):
219
+ if 0 <= y < h:
220
+ grid[y][x] = True
221
+ for y in range(center_y, center_y + dots2):
222
+ if 0 <= y < h:
223
+ grid[y][x] = True
224
+
225
+ braille_map = [[0x01, 0x08], [0x02, 0x10], [0x04, 0x20], [0x40, 0x80]]
226
+ lines = []
227
+
228
+ for row_idx, cy in enumerate(range(0, h, 4)):
229
+ line_str = ""
230
+ for cx in range(0, w, 2):
231
+ char_val = 0x2800
232
+ for dy in range(4):
233
+ for dx in range(2):
234
+ if grid[cy + dy][cx + dx]:
235
+ char_val += braille_map[dy][dx]
236
+ line_str += chr(char_val)
237
+
238
+ style = self.color1 if (self.data_lines == 1 or cy < h // 2) else self.color2
239
+ line = Text(line_str, style=style)
240
+
241
+ if self.show_scale:
242
+ is_top = row_idx == 0
243
+ is_bot = row_idx == graph_rows - 1
244
+ is_mid = self.data_lines > 1 and row_idx == graph_rows // 2
245
+ if is_top:
246
+ line.append_text(self._make_scale_label(max_val, self.color1))
247
+ elif is_mid:
248
+ line.append_text(self._make_scale_label(0, Style(color="#5c6370")))
249
+ elif is_bot:
250
+ bot_style = self.color2 if self.data_lines > 1 else self.color1
251
+ bot_val = max_val if self.data_lines > 1 else 0
252
+ line.append_text(self._make_scale_label(bot_val, bot_style))
253
+ else:
254
+ line.append(" " * scale_w)
255
+
256
+ lines.append(line)
257
+
258
+ if timeline_rows:
259
+ lines.append(self._build_timeline_row(width_chars))
260
+
261
+ return Text("\n").join(lines)
@@ -0,0 +1,46 @@
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
+ from monitorify.ui.tui.widgets.statusWidget import StatusWidget
7
+
8
+
9
+ class CpuWidget(Container):
10
+ BORDER_TITLE = "CPU │ [bold #c678dd]M[/bold #c678dd]enu [bold #c678dd]Q[/bold #c678dd]uit"
11
+
12
+ def compose(self):
13
+ self.cpu_usage_static = Static("–%")
14
+ yield self.cpu_usage_static
15
+
16
+ self.sparkline = BrailleGraph(
17
+ data_lines=1, color1="#61afef", y_max=100.0,
18
+ show_timeline=True,
19
+ id="cpu_sparkline"
20
+ )
21
+ yield self.sparkline
22
+
23
+ def on_mount(self):
24
+ self.status_box = StatusWidget(id="status_box")
25
+ self.screen.mount(self.status_box)
26
+
27
+ def on_resize(self):
28
+ if hasattr(self, "status_box"):
29
+ box_width = 30
30
+ self.status_box.styles.offset = (self.region.x + self.size.width - box_width, self.region.y)
31
+
32
+ def update_cpu(self, snapshot) -> None:
33
+ """Update label and append one live data point to the graph."""
34
+ usage = snapshot.cpu
35
+ self.cpu_usage_static.update(f"{usage:.1f}%")
36
+ self.sparkline.update_value(usage)
37
+
38
+ def update_text(self, snapshot) -> None:
39
+ """Update only the label — does not touch the graph."""
40
+ self.cpu_usage_static.update(f"{snapshot.cpu:.1f}%")
41
+
42
+ def load_history(self, snapshots: list, time_span: float = 0.0) -> None:
43
+ """Bulk-load CPU history, time-positioned to show real gaps."""
44
+ w = self.sparkline.size.width * 2 or 400
45
+ values = make_time_grid(snapshots, lambda s: s.cpu, time_span, w)
46
+ self.sparkline.load_history(values, timeline_seconds=time_span)