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,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'.")
@@ -0,0 +1,3 @@
1
+ from .db import Database
2
+
3
+ __all__ = ["Database"]
@@ -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