odoo-activity 0.2.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.
File without changes
odoo_activity/main.py ADDED
@@ -0,0 +1,16 @@
1
+ import typer
2
+
3
+ from odoo_activity.tui import OdooActivity
4
+
5
+ app = typer.Typer(add_completion=False)
6
+
7
+
8
+ @app.callback(invoke_without_command=True)
9
+ def main(ctx: typer.Context) -> None:
10
+ """odoo-activity — TUI for local Odoo instances."""
11
+ if ctx.invoked_subcommand is None:
12
+ OdooActivity().run()
13
+
14
+
15
+ if __name__ == "__main__":
16
+ app()
File without changes
@@ -0,0 +1,569 @@
1
+ """ActivityPane — Tabbed view for the selected row.
2
+
3
+ Switches dynamically based on selection: Instance mode shows
4
+ Processes/Logs/Config, while Database mode shows db-specific tabs. Mode-switched
5
+ inline, no popups.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import json
12
+ import subprocess
13
+ import time
14
+ from collections.abc import Awaitable, Callable
15
+ from pathlib import Path
16
+ from typing import TYPE_CHECKING, ClassVar
17
+
18
+ from rich.syntax import Syntax
19
+ from textual import events
20
+ from textual.app import ComposeResult
21
+ from textual.containers import Horizontal, Vertical, VerticalScroll
22
+ from textual.widgets import DataTable, Input, Log, Static
23
+
24
+ from odoo_activity.probes import (
25
+ CLK_TCK,
26
+ configfile_of,
27
+ db_port_of,
28
+ instance_version,
29
+ logfile_of,
30
+ parse_odoo_db_output,
31
+ proc_cpu_ticks,
32
+ procs_of,
33
+ render_config,
34
+ start_odoo_db,
35
+ stringify,
36
+ table_columns,
37
+ tail,
38
+ )
39
+
40
+ if TYPE_CHECKING:
41
+ from odoo_activity.tui import OdooActivity
42
+
43
+
44
+ def _inst_key(inst: dict | None) -> str | None:
45
+ return f"{inst['manager']}:{inst['name']}" if inst else None
46
+
47
+
48
+ class _DbTab:
49
+ """The pane's one db-tab fetch/result — one pane shows one db tab at a
50
+ time, so there's never more than one to track."""
51
+
52
+ def __init__(self) -> None:
53
+ self.ident: tuple[str, str] | None = None # (category, db) most recently requested
54
+ self.proc: subprocess.Popen[str] | None = None # its still-running odoo-db, if any
55
+ self.rows: list[dict] = [] # raw (untruncated) rows behind #actable, outlives the fetch
56
+
57
+ def abandon(self) -> None:
58
+ """Kill a still-running fetch and forget it. Its process (and the
59
+ query on Postgres's side) may keep running for a while regardless —
60
+ see start_odoo_db — this only stops us from waiting on it."""
61
+ if self.proc is not None:
62
+ self.proc.kill()
63
+ self.proc = None
64
+
65
+ # Clearing `ident` forces `_fetch_db_tab` to reject stale results,
66
+ # preventing them from overwriting the active view.
67
+ self.ident = None
68
+
69
+
70
+ class ActivityTab(Static):
71
+ """A clickable tab in ActivityPane's header."""
72
+
73
+ def __init__(self, label: str, index: int, active: bool) -> None:
74
+ super().__init__(label)
75
+ self.index = index
76
+ self.set_class(active, "-active")
77
+
78
+ def on_click(self) -> None:
79
+ self.app.query_one(ActivityPane).select_tab(self.index)
80
+
81
+
82
+ class ActivityPane(Vertical):
83
+ """Mode-switched tab view: the instance-mode tabs for the highlighted
84
+ instance, or the db-mode tabs for the highlighted database."""
85
+
86
+ DEFAULT_CSS = """
87
+ ActivityPane { border: round $accent; background: transparent; }
88
+ ActivityPane:focus-within { border: round $primary; }
89
+ #actabs { height: 1; margin-bottom: 1; padding: 0 1; }
90
+ ActivityTab { width: auto; padding: 0 1; color: $text-muted; }
91
+ ActivityTab:hover { color: $text; }
92
+ ActivityTab.-active { background: $primary; color: $text; text-style: bold; }
93
+ #acbody { background: transparent; }
94
+ #actable { background: transparent; display: none; }
95
+ #acsearch { margin-bottom: 1; }
96
+ #acraw { background: transparent; display: none; }
97
+ """
98
+
99
+ CONFIG_MODES: ClassVar = ["compact", "explain", "expand", "clean"]
100
+
101
+ TABS: ClassVar = {
102
+ "instance": ["Processes", "Logs", "Config"],
103
+ "database": ["Users", "Locks", "Jobs", "Crons", "Modules", "Stats"],
104
+ }
105
+ MODE_TITLE: ClassVar = {"instance": "Instance", "database": "Database"}
106
+
107
+ if TYPE_CHECKING:
108
+
109
+ @property
110
+ def app(self) -> OdooActivity: ...
111
+
112
+ def compose(self) -> ComposeResult:
113
+ yield Horizontal(id="actabs")
114
+ yield Input(
115
+ id="acsearch",
116
+ placeholder="search logs, enter to apply, empty clears",
117
+ compact=True,
118
+ )
119
+ yield Log(id="acbody", highlight=False)
120
+ yield DataTable(id="actable", zebra_stripes=True, cursor_type="row")
121
+ with VerticalScroll(id="acraw"):
122
+ yield Static(id="acraw-body")
123
+
124
+ def on_mount(self) -> None:
125
+ self._mode = "instance"
126
+ self._tabs_mode: str | None = None # tab set currently built in #actabs
127
+ self._tab = 0
128
+ self._instance: dict | None = None
129
+ self._db: tuple[dict, str] | None = None # (instance, db name) in database mode
130
+ self._log_path: Path | None = None
131
+ self._log_pos = 0
132
+ self._log_text = "" # full text currently loaded/followed, for re-filtering
133
+ self._log_query: str | None = None
134
+ self._top_prev: dict[str, tuple[int, float]] = {} # pid -> (ticks, monotonic)
135
+ self._proc_rows: list[dict] = [] # rows behind #actable in the Processes tab
136
+ self._config_mode = self.CONFIG_MODES[0] # which odoo-config view the Config tab shows
137
+ self._inflight: dict[str, object] = {} # key -> ident of the run in progress
138
+ self._pending: dict[str, tuple[object, Callable[[], Awaitable[None]]]] = {}
139
+ self._dbtab = _DbTab()
140
+ self._showing_raw = False # viewing one row's raw json in #acbody
141
+ self._render_mode()
142
+
143
+ def _coalesce(self, key: str, ident: object, factory: Callable[[], Awaitable[None]]) -> None:
144
+ """Run at most one task per `key` at a time, identified by `ident`.
145
+
146
+ `asyncio.to_thread` can't interrupt a subprocess/file read that's
147
+ already started, so a retrigger while one is still running doesn't
148
+ stop it — it just leaves it running unseen while a duplicate starts
149
+ alongside it. Queuing the latest call instead (dropping any earlier
150
+ one still waiting) means only one `key` is ever actually in flight.
151
+
152
+ If the request coming in matches the one already running (e.g. the
153
+ user tabs away and back before it finishes), drop any queued
154
+ follow-up rather than re-running it once the in-flight call
155
+ finishes — its result already answers this request.
156
+ """
157
+ if key in self._inflight:
158
+ if self._inflight[key] == ident:
159
+ self._pending.pop(key, None)
160
+ else:
161
+ self._pending[key] = (ident, factory)
162
+ return
163
+
164
+ self._inflight[key] = ident
165
+ self.run_worker(self._run_coalesced(key, factory), group=key, exclusive=True)
166
+
167
+ async def _run_coalesced(self, key: str, factory: Callable[[], Awaitable[None]]) -> None:
168
+ try:
169
+ await factory()
170
+ finally:
171
+ del self._inflight[key]
172
+ nxt = self._pending.pop(key, None)
173
+ if nxt is not None:
174
+ ident, next_factory = nxt
175
+ self._coalesce(key, ident, next_factory)
176
+
177
+ def is_logs_active(self) -> bool:
178
+ return self._mode == "instance" and self.TABS["instance"][self._tab] == "Logs"
179
+
180
+ def is_config_active(self) -> bool:
181
+ return self._mode == "instance" and self.TABS["instance"][self._tab] == "Config"
182
+
183
+ def is_processes_active(self) -> bool:
184
+ return self._mode == "instance" and self.TABS["instance"][self._tab] == "Processes"
185
+
186
+ def has_search(self) -> bool:
187
+ """Logs and Config both render plain text into #acbody with the same
188
+ substring filter (see _render_log)."""
189
+ return self.is_logs_active() or self.is_config_active()
190
+
191
+ def selected_process(self) -> dict | None:
192
+ """The process under the Processes tab's table cursor, if any."""
193
+ if not self.is_processes_active() or not self._proc_rows:
194
+ return None
195
+
196
+ idx = self.query_one("#actable", DataTable).cursor_row
197
+ return self._proc_rows[idx] if 0 <= idx < len(self._proc_rows) else None
198
+
199
+ def open_search(self) -> None:
200
+ if not self.has_search():
201
+ return
202
+
203
+ box = self.query_one("#acsearch", Input)
204
+ box.value = "" # blank each time: enter alone clears an existing filter
205
+ box.display = True
206
+ box.focus()
207
+
208
+ def on_key(self, event: events.Key) -> None:
209
+ box = self.query_one("#acsearch", Input)
210
+ if event.key == "escape" and box.has_focus:
211
+ box.display = False
212
+ self.app.query_one("#instances").focus()
213
+ event.stop()
214
+ return
215
+
216
+ if event.key == "escape" and self._showing_raw:
217
+ self._showing_raw = False
218
+ self._use("table")
219
+ event.stop()
220
+
221
+ def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
222
+ # only db-mode tabs drill into raw json; the Processes tab reuses
223
+ # #actable too, but a re-click there just re-confirms the selection
224
+ if self._mode != "database" or event.row_key.value is None:
225
+ return
226
+
227
+ idx = int(event.row_key.value)
228
+ if idx < len(self._dbtab.rows):
229
+ self._show_raw(self._dbtab.rows[idx])
230
+
231
+ def _show_raw(self, row: dict) -> None:
232
+ self._showing_raw = True
233
+ self._use("raw")
234
+ text = json.dumps(row, indent=2, default=str)
235
+ theme = "ansi_dark" if self.app.current_theme.dark else "ansi_light"
236
+ syntax = Syntax(text, "json", theme=theme, background_color="default", word_wrap=True)
237
+ self.query_one("#acraw-body", Static).update(syntax)
238
+
239
+ def on_input_submitted(self, event: Input.Submitted) -> None:
240
+ if event.input.id != "acsearch":
241
+ return
242
+
243
+ self._log_query = event.value.strip() or None
244
+ event.input.display = False
245
+ self.app.query_one("#instances").focus()
246
+ self._render_log()
247
+
248
+ def show_instance(self, inst: dict | None) -> None:
249
+ """Switch to instance mode for `inst`."""
250
+ self._dbtab.abandon() # leaving database mode; don't leave a fetch running unseen
251
+ self._mode = "instance"
252
+ self._instance = inst
253
+ self._render_mode()
254
+
255
+ def show_database(self, inst: dict, db: str) -> None:
256
+ """Switch to database mode for `db`."""
257
+ self._mode = "database"
258
+ self._db = (inst, db)
259
+ self._render_mode()
260
+
261
+ def select_tab(self, index: int) -> None:
262
+ self._tab = index
263
+ self._render_active()
264
+
265
+ def select_tab_by_name(self, name: str) -> None:
266
+ tabs = self.TABS[self._mode]
267
+ if name in tabs:
268
+ self.select_tab(tabs.index(name))
269
+
270
+ def has_tab(self, name: str) -> bool:
271
+ """True if `name` is one of the current mode's tabs."""
272
+ return name in self.TABS[self._mode]
273
+
274
+ def prev_tab(self) -> None:
275
+ self.select_tab(self._tab - 1)
276
+
277
+ def next_tab(self) -> None:
278
+ self.select_tab(self._tab + 1)
279
+
280
+ def tick(self) -> None:
281
+ """Keep Processes live while it's the active tab. Called on the host
282
+ refresh timer."""
283
+ if self.is_processes_active():
284
+ self._render_processes()
285
+
286
+ def poll(self) -> None:
287
+ """Append newly-written log lines while Logs is the active tab. Called
288
+ on a timer; a no-op whenever another tab is active (``_log_path`` is
289
+ None then)."""
290
+ if self._log_path is None:
291
+ return
292
+
293
+ try:
294
+ size = self._log_path.stat().st_size
295
+ except OSError:
296
+ return
297
+
298
+ if size < self._log_pos: # rotated/truncated
299
+ self._log_pos = 0
300
+ if size == self._log_pos:
301
+ return
302
+
303
+ with self._log_path.open() as f:
304
+ f.seek(self._log_pos)
305
+ data = f.read()
306
+ self._log_pos = f.tell()
307
+
308
+ if not data:
309
+ return
310
+
311
+ self._log_text += data
312
+ if self._log_query:
313
+ self._render_log()
314
+ else:
315
+ self.query_one("#acbody", Log).write(data)
316
+
317
+ def _render_mode(self) -> None:
318
+ tabs = self.TABS[self._mode]
319
+ if self._mode != self._tabs_mode:
320
+ self._tabs_mode = self._mode
321
+ self._tab = 0
322
+ self._build_tabs(tabs)
323
+
324
+ self._render_active()
325
+
326
+ def _title(self) -> str:
327
+ title = self.MODE_TITLE[self._mode]
328
+ if self.is_config_active():
329
+ return f"{title} — Config ({self._config_mode})"
330
+ return title
331
+
332
+ def _build_tabs(self, names: list[str]) -> None:
333
+ strip = self.query_one("#actabs", Horizontal)
334
+ strip.remove_children()
335
+ strip.mount_all(ActivityTab(name, i, active=(i == self._tab)) for i, name in enumerate(names))
336
+
337
+ def _render_active(self) -> None:
338
+ tabs = self.TABS[self._mode]
339
+
340
+ self._tab %= len(tabs)
341
+ for tab in self.query(ActivityTab):
342
+ tab.set_class(tab.index == self._tab, "-active")
343
+ self.border_title = self._title()
344
+
345
+ active = tabs[self._tab]
346
+ self._log_path = None
347
+ self.query_one("#acsearch", Input).display = False
348
+
349
+ if self._mode == "instance":
350
+ if active == "Logs":
351
+ self._use("log")
352
+ self._load_log(self._instance)
353
+ elif active == "Config":
354
+ self._use("log")
355
+ self._render_config()
356
+ else: # Processes
357
+ self._use("table")
358
+ self._top_prev = {}
359
+ self._render_processes()
360
+ else:
361
+ self._load_db_tab(active)
362
+
363
+ def _load_log(self, inst: dict | None) -> None:
364
+ self._coalesce("log", _inst_key(inst), lambda: self._do_load_log(inst))
365
+
366
+ async def _do_load_log(self, inst: dict | None) -> None:
367
+ path = await asyncio.to_thread(logfile_of, inst) if inst else None
368
+ text = await asyncio.to_thread(tail, path) if path is not None else None
369
+ self._follow_log(path, text)
370
+
371
+ def _follow_log(self, path: Path | None, text: str | None) -> None:
372
+ self._log_path = path
373
+ self._log_query = None
374
+
375
+ if path is None:
376
+ self._log_pos = 0
377
+ self._log_text = "(no logfile configured)"
378
+ self._render_log()
379
+ return
380
+
381
+ self._log_text = text or ""
382
+ self._render_log()
383
+
384
+ try:
385
+ self._log_pos = path.stat().st_size
386
+ except OSError:
387
+ self._log_pos = 0
388
+
389
+ def _render_log(self) -> None:
390
+ body = self.query_one("#acbody", Log)
391
+ body.clear()
392
+
393
+ if not self._log_query:
394
+ text = self._log_text
395
+ else:
396
+ needle = self._log_query.lower()
397
+ lines = [ln for ln in self._log_text.splitlines() if needle in ln.lower()]
398
+ text = "\n".join(lines) if lines else f"(no match: {self._log_query})"
399
+
400
+ # Tail logs at the bottom; open the static config at the top
401
+ body.write(text, scroll_end=self.is_logs_active())
402
+ if not self.is_logs_active():
403
+ body.scroll_home(animate=False)
404
+
405
+ def toggle_config_mode(self) -> None:
406
+ """Cycle the Config tab through odoo-config's views: compact (only
407
+ non-default keys), explain (those same keys plus help + default,
408
+ the "exhaustive" one), expand (every valid option filled in) and
409
+ clean (drops anything unknown to the schema or invalid for the
410
+ version/edition)."""
411
+ if not self.is_config_active():
412
+ return
413
+
414
+ idx = (self.CONFIG_MODES.index(self._config_mode) + 1) % len(self.CONFIG_MODES)
415
+ self._config_mode = self.CONFIG_MODES[idx]
416
+ self.border_title = self._title()
417
+ self.app.notify(f"Config mode: {self._config_mode}", timeout=2)
418
+ self._render_config()
419
+
420
+ def _render_config(self) -> None:
421
+ self._log_body(f"Loading {self._config_mode}…") # else the prior tab/mode's text lingers until the fetch lands
422
+ self._coalesce("config", (_inst_key(self._instance), self._config_mode), self._do_render_config)
423
+
424
+ async def _do_render_config(self) -> None:
425
+ inst = self._instance
426
+ if inst is None:
427
+ self._show_config_text("(no instance)")
428
+ return
429
+
430
+ config = await asyncio.to_thread(configfile_of, inst)
431
+ if config is None:
432
+ self._show_config_text("(no config file found)")
433
+ return
434
+
435
+ version = await asyncio.to_thread(instance_version, inst)
436
+ text = await asyncio.to_thread(render_config, config, version, self._config_mode)
437
+ self._show_config_text(text)
438
+
439
+ def _show_config_text(self, text: str) -> None:
440
+ # not a tailed file, so #log_path stays None — poll() then leaves it alone
441
+ self._log_path = None
442
+ self._log_query = None
443
+ self._log_text = text
444
+ self._render_log()
445
+
446
+ def _render_processes(self) -> None:
447
+ self._coalesce("processes", _inst_key(self._instance), self._do_render_processes)
448
+
449
+ async def _do_render_processes(self) -> None:
450
+ inst = self._instance
451
+ if inst is None:
452
+ self._proc_rows = []
453
+ self._show_process_table([])
454
+ return
455
+
456
+ procs = await asyncio.to_thread(procs_of, inst)
457
+ now = time.monotonic()
458
+ prev, self._top_prev = self._top_prev, {}
459
+ rows = []
460
+
461
+ for p in procs:
462
+ pid = p["pid"]
463
+ ticks = proc_cpu_ticks(pid)
464
+ cpu = 0.0
465
+ time_str = "-"
466
+
467
+ if ticks is not None:
468
+ self._top_prev[pid] = (ticks, now)
469
+ secs = int(ticks / CLK_TCK) # cumulative CPU time (top's TIME+)
470
+ time_str = f"{secs // 3600}:{secs % 3600 // 60:02d}:{secs % 60:02d}"
471
+
472
+ if pid in prev and (dt := now - prev[pid][1]) > 0:
473
+ cpu = max(0.0, ticks - prev[pid][0]) / CLK_TCK / dt * 100
474
+
475
+ rows.append({**p, "time": time_str, "cpu": f"{cpu:.1f}"})
476
+
477
+ self._proc_rows = rows
478
+ self._show_process_table(rows)
479
+
480
+ def _show_process_table(self, rows: list[dict]) -> None:
481
+ """Populate the DataTable and preserve the user's selected PID across refreshes."""
482
+ table = self.query_one("#actable", DataTable)
483
+
484
+ # Track selected PID before clearing, read directly from the UI row 0
485
+ keep_pid = table.get_row_at(table.cursor_row)[0] if table.row_count else None
486
+
487
+ table.clear(columns=True)
488
+ table.add_columns("PID", "PPID", "USER", "TIME", "CPU%", "MEM%", "COMMAND")
489
+ for i, p in enumerate(rows):
490
+ table.add_row(p["pid"], p["ppid"], p["user"], p["time"], p["cpu"], p["mem"], p["cmd"], key=str(i))
491
+
492
+ if not rows:
493
+ return
494
+
495
+ # Find where the old PID moved to, default to row 0 if missing
496
+ restore = next((i for i, p in enumerate(rows) if p["pid"] == keep_pid), 0)
497
+ table.move_cursor(row=restore)
498
+
499
+ def _load_db_tab(self, category: str) -> None:
500
+ self._showing_raw = False
501
+ self._log_body(f"Loading {category.lower()}…") # clear any prior tab's table while this one loads
502
+
503
+ if self._db is None:
504
+ self._log_body("(no database)")
505
+ return
506
+
507
+ _inst, db = self._db
508
+ ident = (category, db)
509
+ if ident == self._dbtab.ident and self._dbtab.proc is not None:
510
+ return # already fetching this exact tab; let it finish rather than restart
511
+
512
+ self._dbtab.abandon() # drop the previous tab's client (see start_odoo_db)
513
+ self._dbtab.ident = ident
514
+ self.run_worker(self._fetch_db_tab(category, db, ident), group="dbtab")
515
+
516
+ async def _fetch_db_tab(self, category: str, db: str, ident: tuple[str, str]) -> None:
517
+ port = db_port_of(self._db[0]) if self._db else None
518
+ proc = await asyncio.to_thread(start_odoo_db, category.lower(), db, port)
519
+ self._dbtab.proc = proc
520
+
521
+ def _wait() -> tuple[str, str] | None:
522
+ try:
523
+ return proc.communicate(timeout=90)
524
+ except subprocess.TimeoutExpired: # backstop for a genuinely stuck call
525
+ proc.kill()
526
+ return None
527
+
528
+ result = await asyncio.to_thread(_wait)
529
+
530
+ if ident != self._dbtab.ident:
531
+ return # superseded by a newer tab selection; this result is stale
532
+
533
+ self._dbtab.proc = None
534
+
535
+ if result is None:
536
+ self._log_body("(odoo-db timed out after 90s)")
537
+ return
538
+
539
+ rows, raw = parse_odoo_db_output(*result)
540
+ if rows is None:
541
+ self._log_body(raw or "(no output)")
542
+ elif not rows:
543
+ self._log_body("(empty)")
544
+ else:
545
+ self._dbtab.rows = rows
546
+ self._show_datatable(rows)
547
+
548
+ def _use(self, which: str) -> None:
549
+ """Show the Log, the DataTable, or the raw-json view in the pane body."""
550
+ self.query_one("#acbody", Log).display = which == "log"
551
+ self.query_one("#actable", DataTable).display = which == "table"
552
+ self.query_one("#acraw", VerticalScroll).display = which == "raw"
553
+
554
+ def _show_datatable(self, rows: list[dict]) -> None:
555
+ table = self.query_one("#actable", DataTable)
556
+ table.clear(columns=True)
557
+ columns = table_columns(rows)
558
+ table.add_columns(*(c.upper() for c in columns))
559
+
560
+ for i, row in enumerate(rows):
561
+ table.add_row(*(stringify(row.get(c, "")) for c in columns), key=str(i))
562
+
563
+ self._use("table")
564
+
565
+ def _log_body(self, text: str) -> None:
566
+ self._use("log")
567
+ body = self.query_one("#acbody", Log)
568
+ body.clear()
569
+ body.write(text)