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,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
|
+
}
|
monitorify/ui/tui/tui.py
ADDED
|
@@ -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)
|