echoact 0.1.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.
- echoact/__init__.py +3 -0
- echoact/__main__.py +117 -0
- echoact/app.py +315 -0
- echoact/audio/__init__.py +0 -0
- echoact/audio/devices.py +192 -0
- echoact/audio/player.py +611 -0
- echoact/audio/wav.py +854 -0
- echoact/config/__init__.py +0 -0
- echoact/config/budget.py +370 -0
- echoact/config/settings.py +1244 -0
- echoact/db/__init__.py +0 -0
- echoact/db/backup.py +2429 -0
- echoact/db/migrations.py +434 -0
- echoact/db/schema.sql +214 -0
- echoact/db/store.py +2062 -0
- echoact/diagnostics.py +902 -0
- echoact/domain.py +487 -0
- echoact/engine/__init__.py +0 -0
- echoact/engine/container.py +843 -0
- echoact/engine/protocol.py +241 -0
- echoact/engine/runtime.py +324 -0
- echoact/engine/supervisor.py +961 -0
- echoact/engine/worker.py +659 -0
- echoact/errors.py +281 -0
- echoact/instance.py +172 -0
- echoact/jobs/__init__.py +0 -0
- echoact/jobs/engine.py +776 -0
- echoact/jobs/request.py +300 -0
- echoact/mcp/__init__.py +0 -0
- echoact/mcp/__main__.py +50 -0
- echoact/mcp/client.py +202 -0
- echoact/mcp/config.py +112 -0
- echoact/mcp/server.py +340 -0
- echoact/models/__init__.py +0 -0
- echoact/models/catalog.py +273 -0
- echoact/models/manifest.py +278 -0
- echoact/models/registry.py +1551 -0
- echoact/paths.py +93 -0
- echoact/policy.py +189 -0
- echoact/security/__init__.py +0 -0
- echoact/security/credentials.py +930 -0
- echoact/security/ratelimit.py +534 -0
- echoact/service/__init__.py +20 -0
- echoact/service/app.py +182 -0
- echoact/service/deps.py +563 -0
- echoact/service/errors.py +241 -0
- echoact/service/routes.py +1125 -0
- echoact/service/schemas.py +509 -0
- echoact/service/server.py +270 -0
- echoact/text/__init__.py +0 -0
- echoact/text/language.py +44 -0
- echoact/text/loader.py +577 -0
- echoact/text/normalize.py +924 -0
- echoact/text/segment.py +499 -0
- echoact/text/sniff.py +1202 -0
- echoact/ui/__init__.py +0 -0
- echoact/ui/bridge.py +50 -0
- echoact/ui/controls.py +360 -0
- echoact/ui/credential_dialog.py +131 -0
- echoact/ui/fonts.py +94 -0
- echoact/ui/i18n.py +260 -0
- echoact/ui/icons.py +440 -0
- echoact/ui/library.py +1642 -0
- echoact/ui/licence.py +162 -0
- echoact/ui/main_window.py +1202 -0
- echoact/ui/mcp_setup.py +494 -0
- echoact/ui/models_view.py +1142 -0
- echoact/ui/notifications.py +202 -0
- echoact/ui/reading.py +494 -0
- echoact/ui/settings_view.py +2258 -0
- echoact/ui/status_view.py +1193 -0
- echoact/ui/theme.py +579 -0
- echoact/util/__init__.py +0 -0
- echoact/util/ids.py +62 -0
- echoact/util/logging.py +127 -0
- echoact-0.1.0.dist-info/METADATA +162 -0
- echoact-0.1.0.dist-info/RECORD +80 -0
- echoact-0.1.0.dist-info/WHEEL +4 -0
- echoact-0.1.0.dist-info/entry_points.txt +3 -0
- echoact-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1193 @@
|
|
|
1
|
+
"""F-69's job and service status screen, and the one place contention ends.
|
|
2
|
+
|
|
3
|
+
Everything F-69 lists is on this screen at once: the current job's request
|
|
4
|
+
path, owner and progress stage, the loaded model, the budget actually
|
|
5
|
+
applied, live usage, and the state of REST and MCP. The two actions it
|
|
6
|
+
names -- cancel a job, stop an integration -- are here rather than behind a
|
|
7
|
+
menu, because of what A.3 decided and F-50 now says outright: requests carry
|
|
8
|
+
no priority by path, a GUI request does not preempt a client's running job,
|
|
9
|
+
and the owner reclaims the single slot by cancelling. When a client holds
|
|
10
|
+
the slot this screen therefore says so in as many words and names the
|
|
11
|
+
client, in the spirit of F-70's completion notice, instead of leaving the
|
|
12
|
+
owner to infer it from a greyed-out button somewhere else.
|
|
13
|
+
|
|
14
|
+
Three things the screen refuses to simplify:
|
|
15
|
+
|
|
16
|
+
*Three budgets, not one.* F-78 separates the configured value from the one
|
|
17
|
+
in force, and N-03 separates both from what the platform actually enforces.
|
|
18
|
+
|
|
19
|
+
*Two memory numbers.* N-03 permits the figure on screen and the figure
|
|
20
|
+
limit decisions are made against to differ, and the supervisor reports which
|
|
21
|
+
source it read. The screen shows the source rather than implying there is
|
|
22
|
+
only one.
|
|
23
|
+
|
|
24
|
+
*The poll does not stop when the job does.* F-22 requires usage to keep
|
|
25
|
+
updating while idle with a model retained, which is exactly the state a warm
|
|
26
|
+
worker sits in between jobs (F-17).
|
|
27
|
+
|
|
28
|
+
Nothing here blocks the Qt main thread. Cancelling kills a worker and waits
|
|
29
|
+
up to N-22's five seconds, stopping the service joins a server thread, and
|
|
30
|
+
the job list is a database read; all three run off the GUI thread and come
|
|
31
|
+
back as signals. They share *one* worker thread rather than taking a new
|
|
32
|
+
one each (see :class:`_Background`), because ``Store`` holds a connection
|
|
33
|
+
per thread until exit and a five-second poll would otherwise leak one every
|
|
34
|
+
time it fired. ``run_async`` is injectable so a test can run them inline.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import queue
|
|
40
|
+
import threading
|
|
41
|
+
from collections.abc import Callable
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
from typing import TYPE_CHECKING
|
|
44
|
+
|
|
45
|
+
from PySide6.QtCore import QObject, Qt, QTimer, Signal
|
|
46
|
+
from PySide6.QtGui import QHideEvent, QShowEvent
|
|
47
|
+
from PySide6.QtWidgets import (
|
|
48
|
+
QAbstractItemView,
|
|
49
|
+
QDialog,
|
|
50
|
+
QDialogButtonBox,
|
|
51
|
+
QFileDialog,
|
|
52
|
+
QFrame,
|
|
53
|
+
QGridLayout,
|
|
54
|
+
QHBoxLayout,
|
|
55
|
+
QHeaderView,
|
|
56
|
+
QLabel,
|
|
57
|
+
QPlainTextEdit,
|
|
58
|
+
QProgressBar,
|
|
59
|
+
QPushButton,
|
|
60
|
+
QScrollArea,
|
|
61
|
+
QTableWidget,
|
|
62
|
+
QTableWidgetItem,
|
|
63
|
+
QVBoxLayout,
|
|
64
|
+
QWidget,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
from .. import diagnostics
|
|
68
|
+
from ..domain import Budget, Job, JobState, RequestPath
|
|
69
|
+
from ..errors import Code, EchoActError
|
|
70
|
+
from ..policy import REST_HOST
|
|
71
|
+
from ..util.logging import get_logger
|
|
72
|
+
from . import icons
|
|
73
|
+
from .controls import label, separator
|
|
74
|
+
from .i18n import add_korean, count, memory_size, tr
|
|
75
|
+
from .theme import METRICS, Palette, mono_font
|
|
76
|
+
|
|
77
|
+
if TYPE_CHECKING: # pragma: no cover - typing only
|
|
78
|
+
from ..app import Application
|
|
79
|
+
from ..engine.supervisor import WorkerUsage
|
|
80
|
+
|
|
81
|
+
log = get_logger("ui.status")
|
|
82
|
+
|
|
83
|
+
#: How often the screen re-reads usage. A second is slower than the
|
|
84
|
+
#: container's own sampling interval (``RESOURCE_SAMPLE_INTERVAL_S``), so
|
|
85
|
+
#: every tick has something new to show without polling ahead of the data.
|
|
86
|
+
POLL_MS = 1000
|
|
87
|
+
|
|
88
|
+
#: The job list is a database read, so it happens on a slower cadence than
|
|
89
|
+
#: the in-memory figures -- and off the GUI thread either way.
|
|
90
|
+
JOBS_EVERY_TICKS = 5
|
|
91
|
+
|
|
92
|
+
#: How many rows F-50's "review and cancel all jobs" shows before the owner
|
|
93
|
+
#: has to go to the library for the rest.
|
|
94
|
+
JOB_ROWS = 12
|
|
95
|
+
|
|
96
|
+
add_korean(
|
|
97
|
+
{
|
|
98
|
+
"Status": "상태",
|
|
99
|
+
"Current job": "현재 작업",
|
|
100
|
+
"No job is running.": "실행 중인 작업이 없습니다.",
|
|
101
|
+
"Request path": "요청 경로",
|
|
102
|
+
"Owner": "소유자",
|
|
103
|
+
"Stage": "단계",
|
|
104
|
+
"Progress": "진행",
|
|
105
|
+
"Started": "시작",
|
|
106
|
+
"Budget applied": "적용된 예산",
|
|
107
|
+
"This computer (you)": "이 컴퓨터(사용자)",
|
|
108
|
+
"Accepted": "접수됨",
|
|
109
|
+
"Canceling": "취소하는 중",
|
|
110
|
+
"{client} is holding the generation slot.": "{client}이(가) 생성 슬롯을 사용 중입니다.",
|
|
111
|
+
"Requests are not prioritised by path, so this job keeps the slot until it "
|
|
112
|
+
"finishes or you cancel it here.": "요청 경로에 따른 우선순위는 없으므로, 이 작업은 "
|
|
113
|
+
"끝나거나 여기서 취소할 때까지 슬롯을 차지합니다.",
|
|
114
|
+
"Cancel this job": "이 작업 취소",
|
|
115
|
+
"Cancelling…": "취소하는 중…",
|
|
116
|
+
"All jobs": "모든 작업",
|
|
117
|
+
"You can cancel any job here, including one an integration started.":
|
|
118
|
+
"연동 클라이언트가 시작한 작업을 포함해 여기서 모든 작업을 취소할 수 있습니다.",
|
|
119
|
+
"Cancel selected job": "선택한 작업 취소",
|
|
120
|
+
"Requested by": "요청 주체",
|
|
121
|
+
"When": "시각",
|
|
122
|
+
"No jobs yet.": "아직 작업이 없습니다.",
|
|
123
|
+
"Engine": "엔진",
|
|
124
|
+
"Loaded model": "불러온 모델",
|
|
125
|
+
"No model is loaded.": "불러온 모델이 없습니다.",
|
|
126
|
+
"Selected model": "선택한 모델",
|
|
127
|
+
"Sample rate": "샘플레이트",
|
|
128
|
+
"Execution providers": "실행 공급자",
|
|
129
|
+
"Load time": "불러오기 시간",
|
|
130
|
+
"Budget and usage": "자원 예산과 사용량",
|
|
131
|
+
"Configured": "설정값",
|
|
132
|
+
"In force for the worker": "작업자에 적용 중",
|
|
133
|
+
"For the running job": "실행 중인 작업",
|
|
134
|
+
"Enforcement": "적용 방식",
|
|
135
|
+
"Generation job usage": "생성 작업 사용량",
|
|
136
|
+
"Measured by": "측정 출처",
|
|
137
|
+
"{n} s ago": "{n}초 전",
|
|
138
|
+
"Not measured yet.": "아직 측정되지 않았습니다.",
|
|
139
|
+
"These figures are the generation job alone, not the whole app.":
|
|
140
|
+
"이 수치는 앱 전체가 아니라 생성 작업만의 사용량입니다.",
|
|
141
|
+
"Peak {size}.": "최고 {size}.",
|
|
142
|
+
"Shown here is resident memory; limit decisions use {basis}.":
|
|
143
|
+
"여기 표시된 값은 상주 메모리이며, 한도 판단에는 {basis}을(를) 사용합니다.",
|
|
144
|
+
"enforced": "강제 적용",
|
|
145
|
+
"monitored": "감시",
|
|
146
|
+
"unavailable": "적용 안 됨",
|
|
147
|
+
"commit charge": "커밋 메모리",
|
|
148
|
+
"resident memory": "상주 메모리",
|
|
149
|
+
"address space": "주소 공간",
|
|
150
|
+
"no memory limit": "메모리 한도 없음",
|
|
151
|
+
"Integrations": "연동",
|
|
152
|
+
"Stop the service": "서비스 중지",
|
|
153
|
+
"Start the service": "서비스 시작",
|
|
154
|
+
"Disable MCP": "MCP 사용 안 함",
|
|
155
|
+
"Enable MCP": "MCP 사용",
|
|
156
|
+
"listening on {host}:{port}": "{host}:{port} 에서 수신 중",
|
|
157
|
+
"Stopping an integration cancels the jobs it started; the app, generation and "
|
|
158
|
+
"playback are unaffected.": "연동을 중지하면 해당 연동이 시작한 작업이 취소됩니다. "
|
|
159
|
+
"앱과 생성, 재생에는 영향이 없습니다.",
|
|
160
|
+
"MCP is started by the MCP client, so disabling it only refuses the next "
|
|
161
|
+
"connection.": "MCP 서버는 MCP 클라이언트가 실행하므로, 사용 안 함으로 두면 다음 "
|
|
162
|
+
"연결을 거부합니다.",
|
|
163
|
+
"Refresh": "새로 고침",
|
|
164
|
+
"Export diagnostics…": "진단 정보 내보내기…",
|
|
165
|
+
"Diagnostic export": "진단 정보 내보내기",
|
|
166
|
+
"Review this before saving it. It is written to a file you choose and sent "
|
|
167
|
+
"nowhere.": "저장하기 전에 내용을 확인하세요. 선택한 위치의 파일로 저장되며 어디에도 "
|
|
168
|
+
"전송되지 않습니다.",
|
|
169
|
+
"It contains no document text, no audio, no credentials and no home directory "
|
|
170
|
+
"path.": "본문, 오디오, 자격 증명, 사용자 홈 경로는 포함되지 않습니다.",
|
|
171
|
+
"Save as…": "다른 이름으로 저장…",
|
|
172
|
+
"Saved to {name}": "{name}에 저장했습니다",
|
|
173
|
+
"Preparing the export…": "진단 정보를 모으는 중…",
|
|
174
|
+
"Text files (*.txt)": "텍스트 파일 (*.txt)",
|
|
175
|
+
"threads": "스레드",
|
|
176
|
+
"peak": "최고",
|
|
177
|
+
}
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
_STATE_TEXT: dict[JobState, str] = {
|
|
181
|
+
JobState.ACCEPTED: "Accepted",
|
|
182
|
+
JobState.PREPARING_MODEL: "Preparing the model",
|
|
183
|
+
JobState.GENERATING: "Generating",
|
|
184
|
+
JobState.CANCELING: "Canceling",
|
|
185
|
+
JobState.COMPLETE: "Complete",
|
|
186
|
+
JobState.FAILED: "Failed",
|
|
187
|
+
JobState.CANCELED: "Canceled",
|
|
188
|
+
JobState.INTERRUPTED: "Interrupted",
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
_PATH_TEXT: dict[RequestPath, str] = {
|
|
192
|
+
RequestPath.GUI: "This computer (you)",
|
|
193
|
+
RequestPath.REST: "REST",
|
|
194
|
+
RequestPath.MCP: "MCP",
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
_ENFORCEMENT_TEXT = {
|
|
198
|
+
"enforced": "enforced",
|
|
199
|
+
"monitored": "monitored",
|
|
200
|
+
"unavailable": "unavailable",
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
_BASIS_TEXT = {
|
|
204
|
+
"commit": "commit charge",
|
|
205
|
+
"resident": "resident memory",
|
|
206
|
+
"address_space": "address space",
|
|
207
|
+
"none": "no memory limit",
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class _Background:
|
|
212
|
+
"""One long-lived worker thread for everything this screen does off the
|
|
213
|
+
GUI thread.
|
|
214
|
+
|
|
215
|
+
A thread per call would be shorter, and is what this used to be, but the
|
|
216
|
+
threads are not the resource that matters. :class:`~echoact.db.Store`
|
|
217
|
+
keeps one SQLite connection per thread in a ``threading.local`` and
|
|
218
|
+
appends it to a list that only ``Store.close()`` empties, at exit. A new
|
|
219
|
+
thread per poll therefore meant a new connection, a new WAL handle and a
|
|
220
|
+
new permanent entry in that list every ``JOBS_EVERY_TICKS`` ticks -- some
|
|
221
|
+
thousands of them over the soak A-20 asks for, none closed and none
|
|
222
|
+
collectable. One thread means one connection, however long the window
|
|
223
|
+
stays open and whatever the screen is asked to do.
|
|
224
|
+
|
|
225
|
+
Serialising the work is the deliberate second effect. Everything queued
|
|
226
|
+
here is bounded -- a cancel waits out N-22's five seconds, a service stop
|
|
227
|
+
joins the server thread with its own timeout, a job list is one paged
|
|
228
|
+
read -- so a queue cannot grow without limit the way an unbounded fan-out
|
|
229
|
+
of threads can, and the screen's own polling stays behind whatever action
|
|
230
|
+
the owner asked for rather than racing it.
|
|
231
|
+
|
|
232
|
+
The thread is a daemon and is never joined: a reply that arrives after
|
|
233
|
+
the window has gone is emitted on the :class:`_Signals` object the task
|
|
234
|
+
itself holds, and Qt has already disconnected the view's slots by then.
|
|
235
|
+
"""
|
|
236
|
+
|
|
237
|
+
def __init__(self, name: str) -> None:
|
|
238
|
+
self._name = name
|
|
239
|
+
self._queue: queue.SimpleQueue[Callable[[], None]] = queue.SimpleQueue()
|
|
240
|
+
self._lock = threading.Lock()
|
|
241
|
+
self._thread: threading.Thread | None = None
|
|
242
|
+
|
|
243
|
+
def submit(self, fn: Callable[[], None]) -> None:
|
|
244
|
+
with self._lock:
|
|
245
|
+
if self._thread is None or not self._thread.is_alive():
|
|
246
|
+
self._thread = threading.Thread(target=self._run, name=self._name, daemon=True)
|
|
247
|
+
self._thread.start()
|
|
248
|
+
self._queue.put(fn)
|
|
249
|
+
|
|
250
|
+
def _run(self) -> None:
|
|
251
|
+
while True:
|
|
252
|
+
fn = self._queue.get()
|
|
253
|
+
try:
|
|
254
|
+
fn()
|
|
255
|
+
except Exception: # noqa: BLE001 - one bad task never ends the thread
|
|
256
|
+
# Every task here already reports its own failure through a
|
|
257
|
+
# signal; this is the last resort that keeps the queue -- and
|
|
258
|
+
# with it the one database connection -- alive regardless.
|
|
259
|
+
log.exception("status background task failed")
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
#: The screen's single worker. Module level rather than per view so that
|
|
263
|
+
#: rebuilding the screen does not start a second thread holding a second
|
|
264
|
+
#: connection, which is the leak this class exists to prevent.
|
|
265
|
+
_BACKGROUND = _Background("echoact-status")
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _default_run_async(fn: Callable[[], None]) -> None:
|
|
269
|
+
_BACKGROUND.submit(fn)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class _Signals(QObject):
|
|
273
|
+
"""The worker thread's way back onto the GUI thread.
|
|
274
|
+
|
|
275
|
+
A separate emitter rather than signals on the view, because a cancel
|
|
276
|
+
can still be inside N-22's five seconds when the window closes: the
|
|
277
|
+
worker holds its own reference to this object, so the emit is always
|
|
278
|
+
into something alive, and Qt has already disconnected the view's slots
|
|
279
|
+
by then, which turns a late reply into a no-op instead of a crash.
|
|
280
|
+
"""
|
|
281
|
+
|
|
282
|
+
action_done = Signal(object) # EchoActError | None
|
|
283
|
+
jobs_ready = Signal(object) # tuple[_Row, ...]
|
|
284
|
+
export_ready = Signal(object, object) # text | None, EchoActError | None
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
class _Row:
|
|
288
|
+
"""One line of the job table, read off the GUI thread."""
|
|
289
|
+
|
|
290
|
+
__slots__ = ("job_id", "path", "owner", "state", "when", "active")
|
|
291
|
+
|
|
292
|
+
def __init__(
|
|
293
|
+
self, job_id: str, path: str, owner: str, state: JobState, when: float, active: bool
|
|
294
|
+
) -> None:
|
|
295
|
+
self.job_id = job_id
|
|
296
|
+
self.path = path
|
|
297
|
+
self.owner = owner
|
|
298
|
+
self.state = state
|
|
299
|
+
self.when = when
|
|
300
|
+
self.active = active
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
class StatusView(QWidget):
|
|
304
|
+
"""One screen for F-69, wired to :class:`~echoact.app.Application`."""
|
|
305
|
+
|
|
306
|
+
changed = Signal()
|
|
307
|
+
|
|
308
|
+
def __init__(
|
|
309
|
+
self,
|
|
310
|
+
app: Application,
|
|
311
|
+
palette: Palette,
|
|
312
|
+
parent: QWidget | None = None,
|
|
313
|
+
*,
|
|
314
|
+
run_async: Callable[[Callable[[], None]], None] = _default_run_async,
|
|
315
|
+
) -> None:
|
|
316
|
+
super().__init__(parent)
|
|
317
|
+
self.app = app
|
|
318
|
+
self.palette_tokens = palette
|
|
319
|
+
self._run_async = run_async
|
|
320
|
+
self._busy = False
|
|
321
|
+
self._ticks = 0
|
|
322
|
+
self._rows: tuple[_Row, ...] = ()
|
|
323
|
+
self._last_usage: WorkerUsage | None = None
|
|
324
|
+
#: The last text a review dialog was opened on, for a caller that
|
|
325
|
+
#: wants to know what was shown without reassembling it.
|
|
326
|
+
self.last_export: str | None = None
|
|
327
|
+
|
|
328
|
+
self._signals = _Signals()
|
|
329
|
+
self._signals.action_done.connect(self._on_action_done)
|
|
330
|
+
self._signals.jobs_ready.connect(self._on_jobs_ready)
|
|
331
|
+
self._signals.export_ready.connect(self._on_export_ready)
|
|
332
|
+
|
|
333
|
+
self._build()
|
|
334
|
+
self.refresh()
|
|
335
|
+
|
|
336
|
+
self._timer = QTimer(self)
|
|
337
|
+
self._timer.setInterval(POLL_MS)
|
|
338
|
+
self._timer.timeout.connect(self._tick)
|
|
339
|
+
self._timer.start()
|
|
340
|
+
|
|
341
|
+
# ------------------------------------------------------------------
|
|
342
|
+
# Construction
|
|
343
|
+
# ------------------------------------------------------------------
|
|
344
|
+
|
|
345
|
+
def _build(self) -> None:
|
|
346
|
+
m = METRICS
|
|
347
|
+
outer = QVBoxLayout(self)
|
|
348
|
+
outer.setContentsMargins(0, 0, 0, 0)
|
|
349
|
+
outer.setSpacing(m.gap)
|
|
350
|
+
|
|
351
|
+
head = QHBoxLayout()
|
|
352
|
+
head.setSpacing(m.gap)
|
|
353
|
+
head.addWidget(label(tr("Status"), "title"))
|
|
354
|
+
head.addStretch(1)
|
|
355
|
+
self.refresh_button = QPushButton(tr("Refresh"))
|
|
356
|
+
self.refresh_button.setProperty("variant", "quiet")
|
|
357
|
+
self.refresh_button.setAccessibleName(tr("Refresh"))
|
|
358
|
+
self.export_button = QPushButton(tr("Export diagnostics…"))
|
|
359
|
+
self.export_button.setProperty("variant", "quiet")
|
|
360
|
+
self.export_button.setAccessibleName(tr("Export diagnostics…"))
|
|
361
|
+
head.addWidget(self.refresh_button)
|
|
362
|
+
head.addWidget(self.export_button)
|
|
363
|
+
outer.addLayout(head)
|
|
364
|
+
|
|
365
|
+
self.message = label("", "warn")
|
|
366
|
+
self.message.setWordWrap(True)
|
|
367
|
+
self.message.hide()
|
|
368
|
+
outer.addWidget(self.message)
|
|
369
|
+
|
|
370
|
+
body = QWidget()
|
|
371
|
+
column = QVBoxLayout(body)
|
|
372
|
+
column.setContentsMargins(0, 0, m.gap, 0)
|
|
373
|
+
column.setSpacing(m.gap_wide)
|
|
374
|
+
column.addWidget(self._job_card())
|
|
375
|
+
column.addWidget(self._table_card())
|
|
376
|
+
column.addWidget(self._engine_card())
|
|
377
|
+
column.addWidget(self._budget_card())
|
|
378
|
+
column.addWidget(self._service_card())
|
|
379
|
+
column.addStretch(1)
|
|
380
|
+
|
|
381
|
+
# N-30: at 1280x720 with 200% scaling this column is taller than the
|
|
382
|
+
# window, and every part of it has to stay reachable.
|
|
383
|
+
scroll = QScrollArea()
|
|
384
|
+
scroll.setWidget(body)
|
|
385
|
+
scroll.setWidgetResizable(True)
|
|
386
|
+
scroll.setFrameShape(QScrollArea.Shape.NoFrame)
|
|
387
|
+
outer.addWidget(scroll, 1)
|
|
388
|
+
|
|
389
|
+
self.refresh_button.clicked.connect(self.refresh)
|
|
390
|
+
self.export_button.clicked.connect(self.export_diagnostics)
|
|
391
|
+
self.cancel_button.clicked.connect(self._cancel_current)
|
|
392
|
+
self.cancel_selected.clicked.connect(self._cancel_selected)
|
|
393
|
+
self.jobs.itemSelectionChanged.connect(self._update_enabled)
|
|
394
|
+
self.rest_button.clicked.connect(self._toggle_rest)
|
|
395
|
+
self.mcp_button.clicked.connect(self._toggle_mcp)
|
|
396
|
+
|
|
397
|
+
def _card(self, title: str) -> tuple[QFrame, QVBoxLayout]:
|
|
398
|
+
m = METRICS
|
|
399
|
+
card = QFrame()
|
|
400
|
+
card.setObjectName("Panel")
|
|
401
|
+
box = QVBoxLayout(card)
|
|
402
|
+
box.setContentsMargins(m.pad, m.pad, m.pad, m.pad)
|
|
403
|
+
box.setSpacing(m.gap)
|
|
404
|
+
box.addWidget(label(title, "section"))
|
|
405
|
+
return card, box
|
|
406
|
+
|
|
407
|
+
@staticmethod
|
|
408
|
+
def _grid() -> QGridLayout:
|
|
409
|
+
g = QGridLayout()
|
|
410
|
+
g.setContentsMargins(0, 0, 0, 0)
|
|
411
|
+
g.setHorizontalSpacing(METRICS.pad)
|
|
412
|
+
g.setVerticalSpacing(METRICS.gap_tight)
|
|
413
|
+
g.setColumnStretch(1, 1)
|
|
414
|
+
return g
|
|
415
|
+
|
|
416
|
+
def _field(self, grid: QGridLayout, row: int, name: str) -> QLabel:
|
|
417
|
+
"""One ``name: value`` pair. The name is a label, not a placeholder
|
|
418
|
+
inside the value, so a screen reader reads the pair (N-30)."""
|
|
419
|
+
title = label(name, "secondary")
|
|
420
|
+
value = label("—")
|
|
421
|
+
value.setWordWrap(True)
|
|
422
|
+
value.setAccessibleName(name)
|
|
423
|
+
value.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
|
424
|
+
grid.addWidget(title, row, 0, Qt.AlignmentFlag.AlignTop)
|
|
425
|
+
grid.addWidget(value, row, 1)
|
|
426
|
+
return value
|
|
427
|
+
|
|
428
|
+
def _job_card(self) -> QFrame:
|
|
429
|
+
card, box = self._card(tr("Current job"))
|
|
430
|
+
|
|
431
|
+
self.contention = label("", "warn")
|
|
432
|
+
self.contention.setWordWrap(True)
|
|
433
|
+
self.contention.hide()
|
|
434
|
+
box.addWidget(self.contention)
|
|
435
|
+
|
|
436
|
+
grid = self._grid()
|
|
437
|
+
self.job_path = self._field(grid, 0, tr("Request path"))
|
|
438
|
+
self.job_owner = self._field(grid, 1, tr("Owner"))
|
|
439
|
+
self.job_stage = self._field(grid, 2, tr("Stage"))
|
|
440
|
+
self.job_started = self._field(grid, 3, tr("Started"))
|
|
441
|
+
self.job_model = self._field(grid, 4, tr("Model"))
|
|
442
|
+
self.job_budget = self._field(grid, 5, tr("Budget applied"))
|
|
443
|
+
box.addLayout(grid)
|
|
444
|
+
|
|
445
|
+
self.progress = QProgressBar()
|
|
446
|
+
self.progress.setRange(0, 100)
|
|
447
|
+
self.progress.setValue(0)
|
|
448
|
+
self.progress.setAccessibleName(tr("Progress"))
|
|
449
|
+
box.addWidget(self.progress)
|
|
450
|
+
self.progress_text = label("", "muted")
|
|
451
|
+
box.addWidget(self.progress_text)
|
|
452
|
+
|
|
453
|
+
row = QHBoxLayout()
|
|
454
|
+
row.addStretch(1)
|
|
455
|
+
self.cancel_button = QPushButton(tr("Cancel this job"))
|
|
456
|
+
self.cancel_button.setProperty("variant", "danger")
|
|
457
|
+
self.cancel_button.setAccessibleName(tr("Cancel this job"))
|
|
458
|
+
row.addWidget(self.cancel_button)
|
|
459
|
+
box.addLayout(row)
|
|
460
|
+
return card
|
|
461
|
+
|
|
462
|
+
def _table_card(self) -> QFrame:
|
|
463
|
+
card, box = self._card(tr("All jobs"))
|
|
464
|
+
note = label(
|
|
465
|
+
tr("You can cancel any job here, including one an integration started."), "muted"
|
|
466
|
+
)
|
|
467
|
+
note.setWordWrap(True)
|
|
468
|
+
box.addWidget(note)
|
|
469
|
+
|
|
470
|
+
self.jobs = QTableWidget(0, 4)
|
|
471
|
+
self.jobs.setHorizontalHeaderLabels(
|
|
472
|
+
[tr("When"), tr("Requested by"), tr("Owner"), tr("Stage")]
|
|
473
|
+
)
|
|
474
|
+
self.jobs.setAccessibleName(tr("All jobs"))
|
|
475
|
+
self.jobs.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
|
476
|
+
self.jobs.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
|
477
|
+
self.jobs.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
|
|
478
|
+
self.jobs.setAlternatingRowColors(True)
|
|
479
|
+
self.jobs.verticalHeader().setVisible(False)
|
|
480
|
+
self.jobs.horizontalHeader().setSectionResizeMode(
|
|
481
|
+
QHeaderView.ResizeMode.ResizeToContents
|
|
482
|
+
)
|
|
483
|
+
self.jobs.horizontalHeader().setStretchLastSection(True)
|
|
484
|
+
self.jobs.setMinimumHeight(160)
|
|
485
|
+
box.addWidget(self.jobs)
|
|
486
|
+
|
|
487
|
+
row = QHBoxLayout()
|
|
488
|
+
self.jobs_empty = label(tr("No jobs yet."), "muted")
|
|
489
|
+
row.addWidget(self.jobs_empty)
|
|
490
|
+
row.addStretch(1)
|
|
491
|
+
self.cancel_selected = QPushButton(tr("Cancel selected job"))
|
|
492
|
+
self.cancel_selected.setProperty("variant", "danger")
|
|
493
|
+
self.cancel_selected.setAccessibleName(tr("Cancel selected job"))
|
|
494
|
+
row.addWidget(self.cancel_selected)
|
|
495
|
+
box.addLayout(row)
|
|
496
|
+
return card
|
|
497
|
+
|
|
498
|
+
def _engine_card(self) -> QFrame:
|
|
499
|
+
card, box = self._card(tr("Engine"))
|
|
500
|
+
grid = self._grid()
|
|
501
|
+
self.model_name = self._field(grid, 0, tr("Loaded model"))
|
|
502
|
+
self.model_rate = self._field(grid, 1, tr("Sample rate"))
|
|
503
|
+
self.model_providers = self._field(grid, 2, tr("Execution providers"))
|
|
504
|
+
self.model_load = self._field(grid, 3, tr("Load time"))
|
|
505
|
+
self.model_selected = self._field(grid, 4, tr("Selected model"))
|
|
506
|
+
box.addLayout(grid)
|
|
507
|
+
return card
|
|
508
|
+
|
|
509
|
+
def _budget_card(self) -> QFrame:
|
|
510
|
+
card, box = self._card(tr("Budget and usage"))
|
|
511
|
+
grid = self._grid()
|
|
512
|
+
self.budget_configured = self._field(grid, 0, tr("Configured"))
|
|
513
|
+
self.budget_worker = self._field(grid, 1, tr("In force for the worker"))
|
|
514
|
+
self.budget_job = self._field(grid, 2, tr("For the running job"))
|
|
515
|
+
self.budget_enforcement = self._field(grid, 3, tr("Enforcement"))
|
|
516
|
+
box.addLayout(grid)
|
|
517
|
+
box.addWidget(separator())
|
|
518
|
+
usage = self._grid()
|
|
519
|
+
self.usage_value = self._field(usage, 0, tr("Generation job usage"))
|
|
520
|
+
self.usage_source = self._field(usage, 1, tr("Measured by"))
|
|
521
|
+
box.addLayout(usage)
|
|
522
|
+
self.usage_note = label("", "muted")
|
|
523
|
+
self.usage_note.setWordWrap(True)
|
|
524
|
+
box.addWidget(self.usage_note)
|
|
525
|
+
return card
|
|
526
|
+
|
|
527
|
+
def _service_card(self) -> QFrame:
|
|
528
|
+
card, box = self._card(tr("Integrations"))
|
|
529
|
+
grid = self._grid()
|
|
530
|
+
self.rest_state = self._field(grid, 0, tr("Local service"))
|
|
531
|
+
self.mcp_state = self._field(grid, 1, "MCP")
|
|
532
|
+
box.addLayout(grid)
|
|
533
|
+
|
|
534
|
+
row = QHBoxLayout()
|
|
535
|
+
row.setSpacing(METRICS.gap)
|
|
536
|
+
row.addStretch(1)
|
|
537
|
+
self.rest_button = QPushButton(tr("Stop the service"))
|
|
538
|
+
self.rest_button.setProperty("variant", "quiet")
|
|
539
|
+
self.mcp_button = QPushButton(tr("Disable MCP"))
|
|
540
|
+
self.mcp_button.setProperty("variant", "quiet")
|
|
541
|
+
row.addWidget(self.rest_button)
|
|
542
|
+
row.addWidget(self.mcp_button)
|
|
543
|
+
box.addLayout(row)
|
|
544
|
+
|
|
545
|
+
note = label(
|
|
546
|
+
tr(
|
|
547
|
+
"Stopping an integration cancels the jobs it started; the app, generation "
|
|
548
|
+
"and playback are unaffected."
|
|
549
|
+
),
|
|
550
|
+
"muted",
|
|
551
|
+
)
|
|
552
|
+
note.setWordWrap(True)
|
|
553
|
+
box.addWidget(note)
|
|
554
|
+
mcp_note = label(
|
|
555
|
+
tr("MCP is started by the MCP client, so disabling it only refuses the next "
|
|
556
|
+
"connection."),
|
|
557
|
+
"muted",
|
|
558
|
+
)
|
|
559
|
+
mcp_note.setWordWrap(True)
|
|
560
|
+
box.addWidget(mcp_note)
|
|
561
|
+
return card
|
|
562
|
+
|
|
563
|
+
# ------------------------------------------------------------------
|
|
564
|
+
# Refresh
|
|
565
|
+
# ------------------------------------------------------------------
|
|
566
|
+
|
|
567
|
+
def _tick(self) -> None:
|
|
568
|
+
self._ticks += 1
|
|
569
|
+
self._refresh_job()
|
|
570
|
+
self._refresh_usage()
|
|
571
|
+
if self._ticks % JOBS_EVERY_TICKS == 0:
|
|
572
|
+
self._load_jobs()
|
|
573
|
+
|
|
574
|
+
def refresh(self) -> None:
|
|
575
|
+
"""Re-read everything, including the parts that cost a query.
|
|
576
|
+
|
|
577
|
+
``_refresh_usage`` refreshes the budget card as well, because the
|
|
578
|
+
commit peak it reports belongs beside the enforcement basis and
|
|
579
|
+
both have to change in the same paint.
|
|
580
|
+
"""
|
|
581
|
+
self._refresh_job()
|
|
582
|
+
self._refresh_engine()
|
|
583
|
+
self._refresh_usage()
|
|
584
|
+
self._refresh_service()
|
|
585
|
+
self._load_jobs()
|
|
586
|
+
|
|
587
|
+
def _refresh_job(self) -> None:
|
|
588
|
+
job = self._current_job()
|
|
589
|
+
if job is None:
|
|
590
|
+
self.job_path.setText("—")
|
|
591
|
+
self.job_owner.setText("—")
|
|
592
|
+
self.job_stage.setText(tr("No job is running."))
|
|
593
|
+
self.job_started.setText("—")
|
|
594
|
+
self.job_model.setText("—")
|
|
595
|
+
self.job_budget.setText("—")
|
|
596
|
+
self.progress.setValue(0)
|
|
597
|
+
self.progress_text.setText("")
|
|
598
|
+
self.contention.hide()
|
|
599
|
+
self._update_enabled()
|
|
600
|
+
return
|
|
601
|
+
|
|
602
|
+
self.job_path.setText(tr(_PATH_TEXT.get(job.request_path, str(job.request_path))))
|
|
603
|
+
self.job_owner.setText(self._owner_text(job))
|
|
604
|
+
self.job_stage.setText(tr(_STATE_TEXT.get(job.state, str(job.state))))
|
|
605
|
+
self.job_started.setText(_clock(job.started_at or job.created_at))
|
|
606
|
+
self.job_model.setText(job.settings.model_id)
|
|
607
|
+
self.job_budget.setText(_budget_text(job.budget))
|
|
608
|
+
self.progress.setValue(int(round(job.progress * 100)))
|
|
609
|
+
self.progress_text.setText(
|
|
610
|
+
f"{count(job.generated_segments)} / {count(job.total_segments)}"
|
|
611
|
+
if job.total_segments
|
|
612
|
+
else ""
|
|
613
|
+
)
|
|
614
|
+
self._show_contention(job)
|
|
615
|
+
self._update_enabled()
|
|
616
|
+
|
|
617
|
+
def _show_contention(self, job: Job) -> None:
|
|
618
|
+
"""A.3 and F-50, said plainly rather than implied.
|
|
619
|
+
|
|
620
|
+
The owner cannot preempt a client's job and there is one slot, so
|
|
621
|
+
the only useful thing this screen can tell them is who has it and
|
|
622
|
+
that cancelling is the way to take it back.
|
|
623
|
+
"""
|
|
624
|
+
if job.request_path is RequestPath.GUI or job.state.is_terminal:
|
|
625
|
+
self.contention.hide()
|
|
626
|
+
return
|
|
627
|
+
who = job.client_label or job.owner_client_id or tr(_PATH_TEXT[job.request_path])
|
|
628
|
+
self.contention.setText(
|
|
629
|
+
tr("{client} is holding the generation slot.").format(client=who)
|
|
630
|
+
+ " "
|
|
631
|
+
+ tr(
|
|
632
|
+
"Requests are not prioritised by path, so this job keeps the slot until it "
|
|
633
|
+
"finishes or you cancel it here."
|
|
634
|
+
)
|
|
635
|
+
)
|
|
636
|
+
self.contention.show()
|
|
637
|
+
|
|
638
|
+
def _refresh_engine(self) -> None:
|
|
639
|
+
loaded = None
|
|
640
|
+
try:
|
|
641
|
+
loaded = self.app.supervisor.loaded_model
|
|
642
|
+
except Exception: # noqa: BLE001 - a status read never breaks the screen
|
|
643
|
+
loaded = None
|
|
644
|
+
if loaded is None:
|
|
645
|
+
self.model_name.setText(tr("No model is loaded."))
|
|
646
|
+
self.model_rate.setText("—")
|
|
647
|
+
self.model_providers.setText("—")
|
|
648
|
+
self.model_load.setText("—")
|
|
649
|
+
else:
|
|
650
|
+
self.model_name.setText(loaded.model_id)
|
|
651
|
+
self.model_rate.setText(f"{count(loaded.sample_rate)} Hz")
|
|
652
|
+
self.model_providers.setText(", ".join(loaded.providers) or "—")
|
|
653
|
+
self.model_load.setText(f"{loaded.load_seconds:.2f} s")
|
|
654
|
+
self.model_selected.setText(self.app.settings.voice.model_id)
|
|
655
|
+
|
|
656
|
+
def _refresh_budget(self) -> None:
|
|
657
|
+
state = diagnostics.collect_budget(self.app)
|
|
658
|
+
self.budget_configured.setText(state.configured_error or _budget_text(state.configured))
|
|
659
|
+
self.budget_worker.setText(_budget_text(state.in_force))
|
|
660
|
+
self.budget_job.setText(_budget_text(state.running_job))
|
|
661
|
+
memory = tr(_ENFORCEMENT_TEXT.get(state.memory_enforcement, state.memory_enforcement))
|
|
662
|
+
cpu = tr(_ENFORCEMENT_TEXT.get(state.cpu_enforcement, state.cpu_enforcement))
|
|
663
|
+
facility = state.facility or "—"
|
|
664
|
+
self.budget_enforcement.setText(
|
|
665
|
+
f"{tr('Memory')} {memory} · CPU {cpu} · {facility}"
|
|
666
|
+
)
|
|
667
|
+
basis = tr(_BASIS_TEXT.get(state.memory_basis, state.memory_basis))
|
|
668
|
+
note = tr(
|
|
669
|
+
"Shown here is resident memory; limit decisions use {basis}."
|
|
670
|
+
).format(basis=basis)
|
|
671
|
+
usage = self._last_usage
|
|
672
|
+
if usage is not None and usage.peak_commit_bytes:
|
|
673
|
+
note += " " + tr("Peak {size}.").format(size=memory_size(usage.peak_commit_bytes))
|
|
674
|
+
self.usage_note.setText(
|
|
675
|
+
tr("These figures are the generation job alone, not the whole app.")
|
|
676
|
+
+ " "
|
|
677
|
+
+ note
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
def _refresh_usage(self) -> None:
|
|
681
|
+
"""F-22, N-21 and N-03 in two lines.
|
|
682
|
+
|
|
683
|
+
``sample_usage`` rather than ``usage``: the supervisor offers it for
|
|
684
|
+
exactly this caller, and forcing the sample means the figure on
|
|
685
|
+
screen is this second's rather than the monitor thread's last one.
|
|
686
|
+
|
|
687
|
+
The peak beside the current figure is the peak of the *same*
|
|
688
|
+
quantity. Putting a peak commit charge next to a resident-set
|
|
689
|
+
figure would read as one number's history when it is another's,
|
|
690
|
+
which is the conflation N-03 exists to prevent; the commit peak
|
|
691
|
+
goes in the note, next to the sentence that says what it is for.
|
|
692
|
+
"""
|
|
693
|
+
usage = None
|
|
694
|
+
try:
|
|
695
|
+
usage = self.app.supervisor.sample_usage()
|
|
696
|
+
except Exception: # noqa: BLE001
|
|
697
|
+
usage = None
|
|
698
|
+
self._last_usage = usage
|
|
699
|
+
if usage is None:
|
|
700
|
+
self.usage_value.setText(tr("Not measured yet."))
|
|
701
|
+
self.usage_source.setText("—")
|
|
702
|
+
self._refresh_budget()
|
|
703
|
+
return
|
|
704
|
+
self.usage_value.setText(
|
|
705
|
+
f"CPU {usage.cpu_percent:.0f}% · {tr('Memory')} {memory_size(usage.rss_bytes)}"
|
|
706
|
+
f" · {tr('peak')} {memory_size(usage.peak_rss_bytes)}"
|
|
707
|
+
)
|
|
708
|
+
self.usage_source.setText(
|
|
709
|
+
f"{usage.source} · " + tr("{n} s ago").format(n=f"{usage.age_s:.0f}")
|
|
710
|
+
)
|
|
711
|
+
self._refresh_budget()
|
|
712
|
+
|
|
713
|
+
def _service_running(self) -> bool:
|
|
714
|
+
"""Whether a listener is actually up, read once for both users.
|
|
715
|
+
|
|
716
|
+
The label and the button's action have to come from the same
|
|
717
|
+
reading, or N-09's "the current state is displayed identifiably"
|
|
718
|
+
fails in the worst way: a control that says one thing and does
|
|
719
|
+
another.
|
|
720
|
+
"""
|
|
721
|
+
try:
|
|
722
|
+
return bool(self.app.service_running)
|
|
723
|
+
except Exception: # noqa: BLE001
|
|
724
|
+
return False
|
|
725
|
+
|
|
726
|
+
def _refresh_service(self) -> None:
|
|
727
|
+
s = self.app.settings
|
|
728
|
+
running = self._service_running()
|
|
729
|
+
if running:
|
|
730
|
+
self.rest_state.setText(
|
|
731
|
+
tr("listening on {host}:{port}").format(host=REST_HOST, port=s.rest_port)
|
|
732
|
+
)
|
|
733
|
+
elif s.rest_enabled:
|
|
734
|
+
# F-79: enabled but not listening is the port-conflict case, and
|
|
735
|
+
# it is a notice about the integrations, never about the app.
|
|
736
|
+
self.rest_state.setText(tr("Integrations unavailable"))
|
|
737
|
+
else:
|
|
738
|
+
self.rest_state.setText(tr("off"))
|
|
739
|
+
# The button follows the listener, not the setting. In F-79's state
|
|
740
|
+
# -- enabled, nothing listening -- the useful action is another go at
|
|
741
|
+
# the port, so it offers to start; offering to stop there would turn
|
|
742
|
+
# the integration off on the way to a retry the owner has to ask for
|
|
743
|
+
# twice.
|
|
744
|
+
self.rest_button.setText(
|
|
745
|
+
tr("Stop the service") if running else tr("Start the service")
|
|
746
|
+
)
|
|
747
|
+
self.rest_button.setAccessibleName(self.rest_button.text())
|
|
748
|
+
|
|
749
|
+
self.mcp_state.setText(tr("on") if s.mcp_enabled else tr("off"))
|
|
750
|
+
self.mcp_button.setText(tr("Disable MCP") if s.mcp_enabled else tr("Enable MCP"))
|
|
751
|
+
self.mcp_button.setAccessibleName(self.mcp_button.text())
|
|
752
|
+
|
|
753
|
+
def _update_enabled(self) -> None:
|
|
754
|
+
"""N-09: an action that cannot be taken looks like it cannot."""
|
|
755
|
+
job = self._current_job()
|
|
756
|
+
self.cancel_button.setEnabled(
|
|
757
|
+
not self._busy and job is not None and not job.state.is_terminal
|
|
758
|
+
)
|
|
759
|
+
row = self._selected_row()
|
|
760
|
+
self.cancel_selected.setEnabled(not self._busy and row is not None and row.active)
|
|
761
|
+
self.rest_button.setEnabled(not self._busy)
|
|
762
|
+
self.mcp_button.setEnabled(not self._busy)
|
|
763
|
+
self.export_button.setEnabled(not self._busy)
|
|
764
|
+
|
|
765
|
+
def _current_job(self) -> Job | None:
|
|
766
|
+
try:
|
|
767
|
+
return self.app.engine.current()
|
|
768
|
+
except Exception: # noqa: BLE001
|
|
769
|
+
return None
|
|
770
|
+
|
|
771
|
+
def _owner_text(self, job: Job) -> str:
|
|
772
|
+
if job.request_path is RequestPath.GUI:
|
|
773
|
+
return tr("This computer (you)")
|
|
774
|
+
return job.client_label or job.owner_client_id or "—"
|
|
775
|
+
|
|
776
|
+
# ------------------------------------------------------------------
|
|
777
|
+
# The job table (F-50)
|
|
778
|
+
# ------------------------------------------------------------------
|
|
779
|
+
|
|
780
|
+
def _load_jobs(self) -> None:
|
|
781
|
+
signals = self._signals
|
|
782
|
+
|
|
783
|
+
def work() -> None:
|
|
784
|
+
rows: list[_Row] = []
|
|
785
|
+
try:
|
|
786
|
+
page = self.app.store.list_jobs(limit=JOB_ROWS)
|
|
787
|
+
for item in page.items:
|
|
788
|
+
rows.append(
|
|
789
|
+
_Row(
|
|
790
|
+
job_id=item.job_id,
|
|
791
|
+
path=str(item.request_path),
|
|
792
|
+
owner=item.client_label or item.owner_client_id,
|
|
793
|
+
state=item.state,
|
|
794
|
+
when=item.ended_at or item.started_at or item.created_at,
|
|
795
|
+
active=item.state.is_active,
|
|
796
|
+
)
|
|
797
|
+
)
|
|
798
|
+
except EchoActError:
|
|
799
|
+
rows = []
|
|
800
|
+
except Exception: # noqa: BLE001 - the screen survives a bad read
|
|
801
|
+
rows = []
|
|
802
|
+
signals.jobs_ready.emit(tuple(rows))
|
|
803
|
+
|
|
804
|
+
self._run_async(work)
|
|
805
|
+
|
|
806
|
+
def _on_jobs_ready(self, rows: tuple[_Row, ...]) -> None:
|
|
807
|
+
self._rows = rows
|
|
808
|
+
selected = self._selected_row()
|
|
809
|
+
keep = selected.job_id if selected else None
|
|
810
|
+
self.jobs.setRowCount(len(rows))
|
|
811
|
+
for i, row in enumerate(rows):
|
|
812
|
+
path = tr(_PATH_TEXT.get(RequestPath(row.path), row.path)) if row.path else "—"
|
|
813
|
+
values = (
|
|
814
|
+
_clock(row.when),
|
|
815
|
+
path,
|
|
816
|
+
row.owner or "—",
|
|
817
|
+
tr(_STATE_TEXT.get(row.state, str(row.state))),
|
|
818
|
+
)
|
|
819
|
+
for column, text in enumerate(values):
|
|
820
|
+
item = QTableWidgetItem(text)
|
|
821
|
+
item.setData(Qt.ItemDataRole.UserRole, row.job_id)
|
|
822
|
+
self.jobs.setItem(i, column, item)
|
|
823
|
+
if row.job_id == keep:
|
|
824
|
+
self.jobs.selectRow(i)
|
|
825
|
+
self.jobs_empty.setVisible(not rows)
|
|
826
|
+
self._update_enabled()
|
|
827
|
+
|
|
828
|
+
def _selected_row(self) -> _Row | None:
|
|
829
|
+
items = self.jobs.selectedItems()
|
|
830
|
+
if not items:
|
|
831
|
+
return None
|
|
832
|
+
job_id = items[0].data(Qt.ItemDataRole.UserRole)
|
|
833
|
+
for row in self._rows:
|
|
834
|
+
if row.job_id == job_id:
|
|
835
|
+
return row
|
|
836
|
+
return None
|
|
837
|
+
|
|
838
|
+
# ------------------------------------------------------------------
|
|
839
|
+
# Actions
|
|
840
|
+
# ------------------------------------------------------------------
|
|
841
|
+
|
|
842
|
+
def _cancel_current(self) -> None:
|
|
843
|
+
job = self._current_job()
|
|
844
|
+
if job is not None:
|
|
845
|
+
self.cancel_job(job.job_id)
|
|
846
|
+
|
|
847
|
+
def _cancel_selected(self) -> None:
|
|
848
|
+
row = self._selected_row()
|
|
849
|
+
if row is not None:
|
|
850
|
+
self.cancel_job(row.job_id)
|
|
851
|
+
|
|
852
|
+
def cancel_job(self, job_id: str) -> None:
|
|
853
|
+
"""F-50 and A.3: the owner cancels any job, whoever asked for it.
|
|
854
|
+
|
|
855
|
+
Off the GUI thread because :meth:`JobEngine.cancel` kills the worker
|
|
856
|
+
and then waits for N-22's five-second release; on the main thread
|
|
857
|
+
that would freeze the window for the whole of it.
|
|
858
|
+
"""
|
|
859
|
+
if self._busy:
|
|
860
|
+
return
|
|
861
|
+
self._begin(tr("Cancelling…"))
|
|
862
|
+
signals = self._signals
|
|
863
|
+
engine = self.app.engine
|
|
864
|
+
|
|
865
|
+
def work() -> None:
|
|
866
|
+
error: EchoActError | None = None
|
|
867
|
+
try:
|
|
868
|
+
engine.cancel(job_id)
|
|
869
|
+
except EchoActError as exc:
|
|
870
|
+
error = exc
|
|
871
|
+
except Exception as exc: # noqa: BLE001
|
|
872
|
+
error = EchoActError(Code.INTERNAL, f"Cancelling failed ({type(exc).__name__}).")
|
|
873
|
+
signals.action_done.emit(error)
|
|
874
|
+
|
|
875
|
+
self._run_async(work)
|
|
876
|
+
|
|
877
|
+
def _toggle_rest(self) -> None:
|
|
878
|
+
"""F-69's "stop integrations", and F-79's retry.
|
|
879
|
+
|
|
880
|
+
What the button does follows the listener, not ``rest_enabled``:
|
|
881
|
+
with the setting on and nothing listening -- F-79's bind failure --
|
|
882
|
+
this starts the service again and reports the code if the port is
|
|
883
|
+
still taken, which is the one action worth offering in that state.
|
|
884
|
+
|
|
885
|
+
Stopping is ``stop_service`` and nothing besides. F-52's three
|
|
886
|
+
steps are :meth:`ServiceRunner.stop`'s own, in the order the
|
|
887
|
+
requirement gives them, and the step that blocks new requests is the
|
|
888
|
+
``drain`` it begins with -- ``rest_enabled`` is a startup setting
|
|
889
|
+
that no request path reads. Cancelling the integration's job from
|
|
890
|
+
here first would therefore run the cancellation *outside* the drain
|
|
891
|
+
and free the slot while the service was still admitting requests,
|
|
892
|
+
which is precisely the window F-52's ordering exists to close, and
|
|
893
|
+
would charge the owner two five-second cancellations for one click.
|
|
894
|
+
|
|
895
|
+
The setting is written before the stop because it is about the next
|
|
896
|
+
launch rather than this listener, and a settings write that is going
|
|
897
|
+
to fail should fail before anything has been torn down.
|
|
898
|
+
"""
|
|
899
|
+
turning_off = self._service_running()
|
|
900
|
+
self._begin("")
|
|
901
|
+
app = self.app
|
|
902
|
+
signals = self._signals
|
|
903
|
+
|
|
904
|
+
def work() -> None:
|
|
905
|
+
error: EchoActError | None = None
|
|
906
|
+
try:
|
|
907
|
+
if turning_off:
|
|
908
|
+
app.update_settings(rest_enabled=False)
|
|
909
|
+
app.stop_service()
|
|
910
|
+
else:
|
|
911
|
+
app.update_settings(rest_enabled=True)
|
|
912
|
+
problem = app.start_service()
|
|
913
|
+
if problem is not None:
|
|
914
|
+
error = EchoActError(problem.code, problem.message)
|
|
915
|
+
except EchoActError as exc:
|
|
916
|
+
error = exc
|
|
917
|
+
except Exception as exc: # noqa: BLE001
|
|
918
|
+
error = EchoActError(
|
|
919
|
+
Code.INTERNAL, f"The service did not respond ({type(exc).__name__})."
|
|
920
|
+
)
|
|
921
|
+
signals.action_done.emit(error)
|
|
922
|
+
|
|
923
|
+
self._run_async(work)
|
|
924
|
+
|
|
925
|
+
def _toggle_mcp(self) -> None:
|
|
926
|
+
"""MCP has no listener of ours to stop (F-52: the client starts it),
|
|
927
|
+
so turning it off is the setting plus the cancellation F-52 requires
|
|
928
|
+
of the jobs that integration already started."""
|
|
929
|
+
enable = not self.app.settings.mcp_enabled
|
|
930
|
+
self._begin("")
|
|
931
|
+
app = self.app
|
|
932
|
+
signals = self._signals
|
|
933
|
+
|
|
934
|
+
def work() -> None:
|
|
935
|
+
error: EchoActError | None = None
|
|
936
|
+
try:
|
|
937
|
+
app.update_settings(mcp_enabled=enable)
|
|
938
|
+
if not enable:
|
|
939
|
+
_cancel_path(app, RequestPath.MCP)
|
|
940
|
+
except EchoActError as exc:
|
|
941
|
+
error = exc
|
|
942
|
+
except Exception as exc: # noqa: BLE001
|
|
943
|
+
error = EchoActError(
|
|
944
|
+
Code.INTERNAL, f"The setting was not saved ({type(exc).__name__})."
|
|
945
|
+
)
|
|
946
|
+
signals.action_done.emit(error)
|
|
947
|
+
|
|
948
|
+
self._run_async(work)
|
|
949
|
+
|
|
950
|
+
def _begin(self, note: str) -> None:
|
|
951
|
+
self._busy = True
|
|
952
|
+
self.message.setVisible(bool(note))
|
|
953
|
+
self.message.setText(note)
|
|
954
|
+
self._update_enabled()
|
|
955
|
+
|
|
956
|
+
def _on_action_done(self, error: EchoActError | None) -> None:
|
|
957
|
+
self._busy = False
|
|
958
|
+
if error is None:
|
|
959
|
+
self.message.hide()
|
|
960
|
+
self.message.setText("")
|
|
961
|
+
else:
|
|
962
|
+
self.message.setText(f"{error.code.value} — {error.message}")
|
|
963
|
+
self.message.show()
|
|
964
|
+
self.refresh()
|
|
965
|
+
self.changed.emit()
|
|
966
|
+
|
|
967
|
+
# ------------------------------------------------------------------
|
|
968
|
+
# Diagnostics (F-72)
|
|
969
|
+
# ------------------------------------------------------------------
|
|
970
|
+
|
|
971
|
+
def export_diagnostics(self) -> None:
|
|
972
|
+
"""Collect off the GUI thread, then show it for review.
|
|
973
|
+
|
|
974
|
+
F-72 exports only after the user has reviewed the contents, so this
|
|
975
|
+
opens a dialog and writes nothing; :func:`echoact.diagnostics.save`
|
|
976
|
+
runs from the dialog's own button.
|
|
977
|
+
"""
|
|
978
|
+
if self._busy:
|
|
979
|
+
return
|
|
980
|
+
self._begin(tr("Preparing the export…"))
|
|
981
|
+
app = self.app
|
|
982
|
+
signals = self._signals
|
|
983
|
+
|
|
984
|
+
def work() -> None:
|
|
985
|
+
try:
|
|
986
|
+
text = diagnostics.export_text(app)
|
|
987
|
+
except EchoActError as exc:
|
|
988
|
+
signals.export_ready.emit(None, exc)
|
|
989
|
+
return
|
|
990
|
+
except Exception as exc: # noqa: BLE001
|
|
991
|
+
signals.export_ready.emit(
|
|
992
|
+
None,
|
|
993
|
+
EchoActError(
|
|
994
|
+
Code.INTERNAL,
|
|
995
|
+
f"The report could not be assembled ({type(exc).__name__}).",
|
|
996
|
+
),
|
|
997
|
+
)
|
|
998
|
+
return
|
|
999
|
+
signals.export_ready.emit(text, None)
|
|
1000
|
+
|
|
1001
|
+
self._run_async(work)
|
|
1002
|
+
|
|
1003
|
+
def _on_export_ready(self, text: str | None, error: EchoActError | None) -> None:
|
|
1004
|
+
self._busy = False
|
|
1005
|
+
self.message.hide()
|
|
1006
|
+
self._update_enabled()
|
|
1007
|
+
if error is not None:
|
|
1008
|
+
self.message.setText(f"{error.code.value} — {error.message}")
|
|
1009
|
+
self.message.show()
|
|
1010
|
+
return
|
|
1011
|
+
if text is None:
|
|
1012
|
+
return
|
|
1013
|
+
self.last_export = text
|
|
1014
|
+
dialog = DiagnosticsDialog(text, self.palette_tokens, self)
|
|
1015
|
+
dialog.exec()
|
|
1016
|
+
|
|
1017
|
+
# ------------------------------------------------------------------
|
|
1018
|
+
# Qt lifecycle
|
|
1019
|
+
# ------------------------------------------------------------------
|
|
1020
|
+
|
|
1021
|
+
def showEvent(self, event: QShowEvent) -> None: # noqa: N802 - Qt override
|
|
1022
|
+
super().showEvent(event)
|
|
1023
|
+
self.refresh()
|
|
1024
|
+
self._timer.start()
|
|
1025
|
+
|
|
1026
|
+
def hideEvent(self, event: QHideEvent) -> None: # noqa: N802 - Qt override
|
|
1027
|
+
# Nothing is on screen to update, and F-22's requirement is about
|
|
1028
|
+
# what the user can see. The container keeps sampling regardless,
|
|
1029
|
+
# so the next show is current within one interval.
|
|
1030
|
+
self._timer.stop()
|
|
1031
|
+
super().hideEvent(event)
|
|
1032
|
+
|
|
1033
|
+
|
|
1034
|
+
class DiagnosticsDialog(QDialog):
|
|
1035
|
+
"""F-72's review step: the whole export, before anything is written.
|
|
1036
|
+
|
|
1037
|
+
The text is shown in full rather than summarised. A summary would mean
|
|
1038
|
+
the user reviewed one document and saved a different one, which is not
|
|
1039
|
+
the review the requirement asks for.
|
|
1040
|
+
"""
|
|
1041
|
+
|
|
1042
|
+
saved = Signal(str)
|
|
1043
|
+
|
|
1044
|
+
def __init__(
|
|
1045
|
+
self,
|
|
1046
|
+
text: str,
|
|
1047
|
+
palette: Palette,
|
|
1048
|
+
parent: QWidget | None = None,
|
|
1049
|
+
*,
|
|
1050
|
+
choose_path: Callable[[], str | None] | None = None,
|
|
1051
|
+
) -> None:
|
|
1052
|
+
super().__init__(parent)
|
|
1053
|
+
self._text = text
|
|
1054
|
+
self._palette = palette
|
|
1055
|
+
self._choose_path = choose_path or self._ask_for_path
|
|
1056
|
+
m = METRICS
|
|
1057
|
+
|
|
1058
|
+
self.setWindowTitle(tr("Diagnostic export"))
|
|
1059
|
+
self.setModal(True)
|
|
1060
|
+
self.setMinimumSize(760, 560)
|
|
1061
|
+
|
|
1062
|
+
box = QVBoxLayout(self)
|
|
1063
|
+
box.setContentsMargins(m.pad_wide, m.pad_wide, m.pad_wide, m.pad_wide)
|
|
1064
|
+
box.setSpacing(m.gap)
|
|
1065
|
+
|
|
1066
|
+
head = QHBoxLayout()
|
|
1067
|
+
head.setSpacing(m.gap)
|
|
1068
|
+
mark = QLabel()
|
|
1069
|
+
mark.setPixmap(icons.pixmap("info", palette.accent, 20, self.devicePixelRatioF()))
|
|
1070
|
+
mark.setAlignment(Qt.AlignmentFlag.AlignTop)
|
|
1071
|
+
head.addWidget(mark)
|
|
1072
|
+
intro = QLabel(
|
|
1073
|
+
tr("Review this before saving it. It is written to a file you choose and sent "
|
|
1074
|
+
"nowhere.")
|
|
1075
|
+
)
|
|
1076
|
+
intro.setWordWrap(True)
|
|
1077
|
+
head.addWidget(intro, 1)
|
|
1078
|
+
box.addLayout(head)
|
|
1079
|
+
excluded = QLabel(
|
|
1080
|
+
tr("It contains no document text, no audio, no credentials and no home directory "
|
|
1081
|
+
"path.")
|
|
1082
|
+
)
|
|
1083
|
+
excluded.setWordWrap(True)
|
|
1084
|
+
excluded.setProperty("role", "muted")
|
|
1085
|
+
box.addWidget(excluded)
|
|
1086
|
+
|
|
1087
|
+
self.view = QPlainTextEdit()
|
|
1088
|
+
self.view.setPlainText(text)
|
|
1089
|
+
# The report is column-aligned; a proportional face turns its
|
|
1090
|
+
# tables into ragged prose the reader has to decode twice.
|
|
1091
|
+
self.view.setFont(mono_font())
|
|
1092
|
+
self.view.setReadOnly(True)
|
|
1093
|
+
self.view.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
|
|
1094
|
+
self.view.setAccessibleName(tr("Diagnostic export"))
|
|
1095
|
+
box.addWidget(self.view, 1)
|
|
1096
|
+
|
|
1097
|
+
self.status = QLabel("")
|
|
1098
|
+
self.status.setWordWrap(True)
|
|
1099
|
+
self.status.setProperty("role", "muted")
|
|
1100
|
+
box.addWidget(self.status)
|
|
1101
|
+
|
|
1102
|
+
buttons = QDialogButtonBox()
|
|
1103
|
+
self.save_button = buttons.addButton(
|
|
1104
|
+
tr("Save as…"), QDialogButtonBox.ButtonRole.ActionRole
|
|
1105
|
+
)
|
|
1106
|
+
self.save_button.setProperty("variant", "primary")
|
|
1107
|
+
self.close_button = buttons.addButton(tr("Close"), QDialogButtonBox.ButtonRole.RejectRole)
|
|
1108
|
+
buttons.rejected.connect(self.reject)
|
|
1109
|
+
self.save_button.clicked.connect(self.save)
|
|
1110
|
+
box.addWidget(buttons)
|
|
1111
|
+
|
|
1112
|
+
def text(self) -> str:
|
|
1113
|
+
return self._text
|
|
1114
|
+
|
|
1115
|
+
def _ask_for_path(self) -> str | None:
|
|
1116
|
+
"""Offer a destination the user will find again.
|
|
1117
|
+
|
|
1118
|
+
The suggestion starts in the user's own directory rather than the
|
|
1119
|
+
app's data tree: F-72 exports so that the file can be sent to
|
|
1120
|
+
someone, and a file saved into ``<data>`` is one the sender has to
|
|
1121
|
+
be told how to find.
|
|
1122
|
+
"""
|
|
1123
|
+
try:
|
|
1124
|
+
start = Path.home() / diagnostics.default_filename()
|
|
1125
|
+
except (OSError, RuntimeError):
|
|
1126
|
+
start = Path(diagnostics.default_filename())
|
|
1127
|
+
chosen, _filter = QFileDialog.getSaveFileName(
|
|
1128
|
+
self, tr("Save as…"), str(start), tr("Text files (*.txt)")
|
|
1129
|
+
)
|
|
1130
|
+
return chosen or None
|
|
1131
|
+
|
|
1132
|
+
def save(self) -> None:
|
|
1133
|
+
"""Write what is on screen, to a location the user picks.
|
|
1134
|
+
|
|
1135
|
+
The destination is the user's, not the app's: F-72 exports for the
|
|
1136
|
+
user to send on, and writing into the data directory by default
|
|
1137
|
+
would hide the file in the one place they are least likely to look.
|
|
1138
|
+
"""
|
|
1139
|
+
chosen = self._choose_path()
|
|
1140
|
+
if not chosen:
|
|
1141
|
+
return
|
|
1142
|
+
try:
|
|
1143
|
+
path = diagnostics.save(self._text, chosen)
|
|
1144
|
+
except EchoActError as exc:
|
|
1145
|
+
self.status.setProperty("role", "danger")
|
|
1146
|
+
self.status.setText(f"{exc.code.value} — {exc.message}")
|
|
1147
|
+
self.status.style().unpolish(self.status)
|
|
1148
|
+
self.status.style().polish(self.status)
|
|
1149
|
+
return
|
|
1150
|
+
self.status.setText(tr("Saved to {name}").format(name=path.name))
|
|
1151
|
+
self.saved.emit(str(path))
|
|
1152
|
+
|
|
1153
|
+
|
|
1154
|
+
# ======================================================================
|
|
1155
|
+
# Helpers
|
|
1156
|
+
# ======================================================================
|
|
1157
|
+
|
|
1158
|
+
|
|
1159
|
+
def _cancel_path(app: Application, path: RequestPath) -> None:
|
|
1160
|
+
"""Cancel the running job if that integration owns it (F-52).
|
|
1161
|
+
|
|
1162
|
+
Only the running job: with one slot (F-47) there is at most one, and a
|
|
1163
|
+
job that has already finished is not something an integration is still
|
|
1164
|
+
doing.
|
|
1165
|
+
"""
|
|
1166
|
+
job = app.engine.current()
|
|
1167
|
+
if job is not None and job.request_path is path and not job.state.is_terminal:
|
|
1168
|
+
app.engine.cancel(job.job_id)
|
|
1169
|
+
|
|
1170
|
+
|
|
1171
|
+
def _budget_text(budget: Budget | None) -> str:
|
|
1172
|
+
if budget is None:
|
|
1173
|
+
return "—"
|
|
1174
|
+
return (
|
|
1175
|
+
f"CPU {budget.cpu_percent}% · {memory_size(budget.memory_bytes)}"
|
|
1176
|
+
f" · {budget.intra_op_threads}+{budget.inter_op_threads} {tr('threads')}"
|
|
1177
|
+
)
|
|
1178
|
+
|
|
1179
|
+
|
|
1180
|
+
def _clock(value: float | None) -> str:
|
|
1181
|
+
"""Wall-clock time of day. A date is noise on a screen showing the
|
|
1182
|
+
last few minutes of activity; the export carries the full stamp."""
|
|
1183
|
+
if not value:
|
|
1184
|
+
return "—"
|
|
1185
|
+
from datetime import datetime
|
|
1186
|
+
|
|
1187
|
+
try:
|
|
1188
|
+
return datetime.fromtimestamp(value).strftime("%H:%M:%S")
|
|
1189
|
+
except (OSError, OverflowError, ValueError):
|
|
1190
|
+
return "—"
|
|
1191
|
+
|
|
1192
|
+
|
|
1193
|
+
__all__ = ["DiagnosticsDialog", "StatusView"]
|