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.
- odoo_activity/__init__.py +0 -0
- odoo_activity/main.py +16 -0
- odoo_activity/panes/__init__.py +0 -0
- odoo_activity/panes/detail.py +569 -0
- odoo_activity/probes.py +629 -0
- odoo_activity/tui.py +514 -0
- odoo_activity-0.2.0.dist-info/METADATA +116 -0
- odoo_activity-0.2.0.dist-info/RECORD +11 -0
- odoo_activity-0.2.0.dist-info/WHEEL +4 -0
- odoo_activity-0.2.0.dist-info/entry_points.txt +4 -0
- odoo_activity-0.2.0.dist-info/licenses/LICENSE +661 -0
|
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)
|