messagefoundry 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.
- messagefoundry/__init__.py +108 -0
- messagefoundry/__main__.py +1155 -0
- messagefoundry/api/__init__.py +27 -0
- messagefoundry/api/app.py +1581 -0
- messagefoundry/api/approvals.py +184 -0
- messagefoundry/api/auth_models.py +211 -0
- messagefoundry/api/auth_routes.py +655 -0
- messagefoundry/api/field_authz.py +96 -0
- messagefoundry/api/models.py +374 -0
- messagefoundry/api/security.py +247 -0
- messagefoundry/api/tls.py +47 -0
- messagefoundry/auth/__init__.py +39 -0
- messagefoundry/auth/data/common_passwords.NOTICE +13 -0
- messagefoundry/auth/data/common_passwords.txt +10000 -0
- messagefoundry/auth/identity.py +71 -0
- messagefoundry/auth/ldap.py +264 -0
- messagefoundry/auth/notifications.py +68 -0
- messagefoundry/auth/passwords.py +53 -0
- messagefoundry/auth/permissions.py +120 -0
- messagefoundry/auth/policy.py +153 -0
- messagefoundry/auth/ratelimit.py +55 -0
- messagefoundry/auth/service.py +1323 -0
- messagefoundry/auth/tokens.py +26 -0
- messagefoundry/auth/totp.py +174 -0
- messagefoundry/checks.py +174 -0
- messagefoundry/config/__init__.py +30 -0
- messagefoundry/config/active_environment.py +80 -0
- messagefoundry/config/ai_policy.py +140 -0
- messagefoundry/config/code_sets.py +260 -0
- messagefoundry/config/connections_edit.py +200 -0
- messagefoundry/config/connections_file.py +287 -0
- messagefoundry/config/db_lookup.py +117 -0
- messagefoundry/config/environments.py +116 -0
- messagefoundry/config/ingest_time.py +83 -0
- messagefoundry/config/models.py +240 -0
- messagefoundry/config/reference.py +158 -0
- messagefoundry/config/response.py +83 -0
- messagefoundry/config/run_context.py +153 -0
- messagefoundry/config/settings.py +1311 -0
- messagefoundry/config/state.py +99 -0
- messagefoundry/config/tls_policy.py +110 -0
- messagefoundry/config/wiring.py +1918 -0
- messagefoundry/console/__init__.py +20 -0
- messagefoundry/console/__main__.py +274 -0
- messagefoundry/console/_async.py +107 -0
- messagefoundry/console/change_password.py +111 -0
- messagefoundry/console/client.py +552 -0
- messagefoundry/console/connections.py +324 -0
- messagefoundry/console/login.py +107 -0
- messagefoundry/console/mfa.py +205 -0
- messagefoundry/console/reauth.py +94 -0
- messagefoundry/console/search.py +57 -0
- messagefoundry/console/service_control.py +137 -0
- messagefoundry/console/sessions.py +122 -0
- messagefoundry/console/shell.py +410 -0
- messagefoundry/console/status.py +377 -0
- messagefoundry/console/users_page.py +282 -0
- messagefoundry/console/widgets.py +553 -0
- messagefoundry/generators/README.md +27 -0
- messagefoundry/generators/__init__.py +15 -0
- messagefoundry/generators/_core.py +589 -0
- messagefoundry/generators/_hl7data.py +428 -0
- messagefoundry/generators/adt.py +286 -0
- messagefoundry/generators/all_types.py +24 -0
- messagefoundry/generators/bar.py +28 -0
- messagefoundry/generators/dft.py +20 -0
- messagefoundry/generators/mdm.py +39 -0
- messagefoundry/generators/mfn.py +46 -0
- messagefoundry/generators/oml.py +32 -0
- messagefoundry/generators/orl.py +30 -0
- messagefoundry/generators/orm.py +23 -0
- messagefoundry/generators/oru.py +21 -0
- messagefoundry/generators/ras.py +20 -0
- messagefoundry/generators/rde.py +54 -0
- messagefoundry/generators/siu.py +64 -0
- messagefoundry/generators/vxu.py +20 -0
- messagefoundry/hl7schema.py +75 -0
- messagefoundry/last_resort.py +55 -0
- messagefoundry/logging_setup.py +332 -0
- messagefoundry/parsing/__init__.py +64 -0
- messagefoundry/parsing/consistency.py +166 -0
- messagefoundry/parsing/groups.py +228 -0
- messagefoundry/parsing/message.py +453 -0
- messagefoundry/parsing/peek.py +237 -0
- messagefoundry/parsing/split.py +120 -0
- messagefoundry/parsing/summary.py +46 -0
- messagefoundry/parsing/tree.py +128 -0
- messagefoundry/parsing/validate.py +95 -0
- messagefoundry/parsing/x12/__init__.py +46 -0
- messagefoundry/parsing/x12/delimiters.py +140 -0
- messagefoundry/parsing/x12/errors.py +30 -0
- messagefoundry/parsing/x12/interchange.py +232 -0
- messagefoundry/parsing/x12/message.py +200 -0
- messagefoundry/parsing/x12/peek.py +207 -0
- messagefoundry/pipeline/__init__.py +21 -0
- messagefoundry/pipeline/alert_sinks.py +486 -0
- messagefoundry/pipeline/alerts.py +100 -0
- messagefoundry/pipeline/cert_expiry.py +219 -0
- messagefoundry/pipeline/cluster.py +955 -0
- messagefoundry/pipeline/cluster_sqlserver.py +444 -0
- messagefoundry/pipeline/config_convergence.py +137 -0
- messagefoundry/pipeline/dryrun.py +450 -0
- messagefoundry/pipeline/engine.py +756 -0
- messagefoundry/pipeline/leader_tasks.py +158 -0
- messagefoundry/pipeline/reference_sync.py +369 -0
- messagefoundry/pipeline/retention.py +289 -0
- messagefoundry/pipeline/security_notify.py +168 -0
- messagefoundry/pipeline/state_convergence.py +143 -0
- messagefoundry/pipeline/wiring_runner.py +1722 -0
- messagefoundry/py.typed +0 -0
- messagefoundry/redaction.py +71 -0
- messagefoundry/scaffold.py +321 -0
- messagefoundry/secrets_dpapi.py +129 -0
- messagefoundry/store/__init__.py +46 -0
- messagefoundry/store/audit_tee.py +67 -0
- messagefoundry/store/base.py +758 -0
- messagefoundry/store/crypto.py +166 -0
- messagefoundry/store/keyprovider.py +192 -0
- messagefoundry/store/postgres.py +3447 -0
- messagefoundry/store/sqlserver.py +3014 -0
- messagefoundry/store/store.py +3790 -0
- messagefoundry/timezone.py +207 -0
- messagefoundry/transports/__init__.py +50 -0
- messagefoundry/transports/base.py +269 -0
- messagefoundry/transports/database.py +693 -0
- messagefoundry/transports/file.py +551 -0
- messagefoundry/transports/framing.py +164 -0
- messagefoundry/transports/loopback.py +53 -0
- messagefoundry/transports/mllp.py +644 -0
- messagefoundry/transports/remotefile.py +664 -0
- messagefoundry/transports/rest.py +281 -0
- messagefoundry/transports/signing.py +321 -0
- messagefoundry/transports/soap.py +507 -0
- messagefoundry/transports/tcp.py +307 -0
- messagefoundry/transports/timer.py +146 -0
- messagefoundry/transports/x12.py +323 -0
- messagefoundry-0.1.0.dist-info/METADATA +212 -0
- messagefoundry-0.1.0.dist-info/RECORD +142 -0
- messagefoundry-0.1.0.dist-info/WHEEL +4 -0
- messagefoundry-0.1.0.dist-info/entry_points.txt +2 -0
- messagefoundry-0.1.0.dist-info/licenses/LICENSE +662 -0
- messagefoundry-0.1.0.dist-info/licenses/NOTICE +27 -0
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""Engine Status page: engine-process, database, and active-passive cluster health.
|
|
4
|
+
|
|
5
|
+
All engine reads run **off the Qt main thread** (:class:`~messagefoundry.console._async.AsyncRunner`) and
|
|
6
|
+
apply on the main thread via the result slot, so a slow DB-backed read — e.g. ``/cluster/nodes`` while a
|
|
7
|
+
new primary is recovering during a failover — can't freeze the window. The database integrity check is
|
|
8
|
+
on-demand (``PRAGMA quick_check`` can run for many seconds on a large DB) and also off-thread.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import time
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
|
|
16
|
+
from PySide6.QtCore import Signal
|
|
17
|
+
from PySide6.QtWidgets import (
|
|
18
|
+
QFormLayout,
|
|
19
|
+
QGroupBox,
|
|
20
|
+
QHBoxLayout,
|
|
21
|
+
QInputDialog,
|
|
22
|
+
QLabel,
|
|
23
|
+
QMessageBox,
|
|
24
|
+
QPushButton,
|
|
25
|
+
QTreeWidget,
|
|
26
|
+
QTreeWidgetItem,
|
|
27
|
+
QVBoxLayout,
|
|
28
|
+
QWidget,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
from messagefoundry.api.models import ClusterNodeList, ClusterStatus, IntegrityResult, SystemStatus
|
|
32
|
+
from messagefoundry.console import service_control
|
|
33
|
+
from messagefoundry.console._async import AsyncRunner
|
|
34
|
+
from messagefoundry.console.client import ApiError, EngineClient
|
|
35
|
+
|
|
36
|
+
_ENGINE_ROWS = ["Reachable", "Version", "Uptime", "PID", "Channels", "Queue"]
|
|
37
|
+
_DB_ROWS = ["Path", "Size", "Free disk", "Journal mode", "Messages", "Events", "Audit entries"]
|
|
38
|
+
_NODE_COLS = ["Node", "Host", "PID", "Status", "Last seen", "Leader"]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _human_bytes(n: int) -> str:
|
|
42
|
+
size = float(n)
|
|
43
|
+
for unit in ("B", "KB", "MB", "GB"):
|
|
44
|
+
if size < 1024:
|
|
45
|
+
return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}"
|
|
46
|
+
size /= 1024
|
|
47
|
+
return f"{size:.1f} TB"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _human_uptime(seconds: float) -> str:
|
|
51
|
+
s = int(seconds)
|
|
52
|
+
days, s = divmod(s, 86400)
|
|
53
|
+
hours, s = divmod(s, 3600)
|
|
54
|
+
minutes, _ = divmod(s, 60)
|
|
55
|
+
parts = []
|
|
56
|
+
if days:
|
|
57
|
+
parts.append(f"{days}d")
|
|
58
|
+
if hours or days:
|
|
59
|
+
parts.append(f"{hours}h")
|
|
60
|
+
parts.append(f"{minutes}m")
|
|
61
|
+
return " ".join(parts)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _ago(last_seen: float | None, now: float) -> str:
|
|
65
|
+
if last_seen is None:
|
|
66
|
+
return "—"
|
|
67
|
+
delta = max(0, int(now - last_seen))
|
|
68
|
+
return "just now" if delta < 1 else f"{delta}s ago"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True)
|
|
72
|
+
class _Snapshot:
|
|
73
|
+
"""One off-thread refresh result, applied on the main thread. ``error`` set ⇒ engine unreachable;
|
|
74
|
+
``cluster`` None ⇒ the cluster endpoints aren't available (older engine / not permitted)."""
|
|
75
|
+
|
|
76
|
+
service_state: str
|
|
77
|
+
status: SystemStatus | None
|
|
78
|
+
cluster: tuple[ClusterStatus, ClusterNodeList] | None
|
|
79
|
+
error: str | None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class EngineStatusPage(QWidget):
|
|
83
|
+
"""Read-only engine + database + cluster health, plus an on-demand integrity check."""
|
|
84
|
+
|
|
85
|
+
error = Signal(str)
|
|
86
|
+
|
|
87
|
+
def __init__(self, client: EngineClient, *, service_name: str = "MessageFoundry") -> None:
|
|
88
|
+
super().__init__()
|
|
89
|
+
self._client = client
|
|
90
|
+
self._service_name = service_name
|
|
91
|
+
self._runner = AsyncRunner(self)
|
|
92
|
+
self._loading = False # in-flight refresh guard (don't pile up during a slow call)
|
|
93
|
+
self._engine = {row: QLabel("—") for row in _ENGINE_ROWS}
|
|
94
|
+
self._db = {row: QLabel("—") for row in _DB_ROWS}
|
|
95
|
+
|
|
96
|
+
engine_box = QGroupBox("Engine")
|
|
97
|
+
engine_form = QFormLayout(engine_box)
|
|
98
|
+
for row, label in self._engine.items():
|
|
99
|
+
engine_form.addRow(f"{row}:", label)
|
|
100
|
+
|
|
101
|
+
db_box = QGroupBox("Database")
|
|
102
|
+
db_form = QFormLayout(db_box)
|
|
103
|
+
for row, label in self._db.items():
|
|
104
|
+
db_form.addRow(f"{row}:", label)
|
|
105
|
+
|
|
106
|
+
# Active-passive cluster roster (Workstream G). Hidden until a /cluster read succeeds — a
|
|
107
|
+
# single-node or older engine without the endpoints simply doesn't show it.
|
|
108
|
+
self._cluster_box = QGroupBox("Cluster")
|
|
109
|
+
self._cluster_box.setVisible(False)
|
|
110
|
+
self._cl_mode = QLabel("—")
|
|
111
|
+
self._cl_role = QLabel("—")
|
|
112
|
+
self._cl_leader = QLabel("—")
|
|
113
|
+
self._cl_lease = QLabel("—")
|
|
114
|
+
cluster_form = QFormLayout()
|
|
115
|
+
cluster_form.addRow("Mode:", self._cl_mode)
|
|
116
|
+
cluster_form.addRow("This node:", self._cl_role)
|
|
117
|
+
cluster_form.addRow("Leader:", self._cl_leader)
|
|
118
|
+
cluster_form.addRow("Lease owner:", self._cl_lease)
|
|
119
|
+
self._nodes = QTreeWidget()
|
|
120
|
+
self._nodes.setColumnCount(len(_NODE_COLS))
|
|
121
|
+
self._nodes.setHeaderLabels(_NODE_COLS)
|
|
122
|
+
self._nodes.setRootIsDecorated(False)
|
|
123
|
+
self._nodes.setUniformRowHeights(True)
|
|
124
|
+
cluster_layout = QVBoxLayout(self._cluster_box)
|
|
125
|
+
cluster_layout.addLayout(cluster_form)
|
|
126
|
+
cluster_layout.addWidget(self._nodes)
|
|
127
|
+
|
|
128
|
+
# Windows service control (sc/net via UAC) — same-machine only; disabled in dev/no-service.
|
|
129
|
+
service_box = QGroupBox(f"Service ({service_name})")
|
|
130
|
+
self._service_state = QLabel("—")
|
|
131
|
+
self._svc_start = QPushButton("Start")
|
|
132
|
+
self._svc_stop = QPushButton("Stop")
|
|
133
|
+
self._svc_restart = QPushButton("Restart")
|
|
134
|
+
self._svc_install = QPushButton("Install service…")
|
|
135
|
+
self._svc_install.setVisible(False) # only shown when the service is not installed
|
|
136
|
+
self._svc_start.clicked.connect(lambda: self._control("start"))
|
|
137
|
+
self._svc_stop.clicked.connect(lambda: self._control("stop"))
|
|
138
|
+
self._svc_restart.clicked.connect(lambda: self._control("restart"))
|
|
139
|
+
self._svc_install.clicked.connect(self._install_service)
|
|
140
|
+
self._svc_result = QLabel("")
|
|
141
|
+
service_controls = QHBoxLayout()
|
|
142
|
+
service_controls.addWidget(QLabel("State:"))
|
|
143
|
+
service_controls.addWidget(self._service_state)
|
|
144
|
+
service_controls.addStretch(1)
|
|
145
|
+
service_controls.addWidget(self._svc_install)
|
|
146
|
+
service_controls.addWidget(self._svc_start)
|
|
147
|
+
service_controls.addWidget(self._svc_stop)
|
|
148
|
+
service_controls.addWidget(self._svc_restart)
|
|
149
|
+
service_layout = QVBoxLayout(service_box)
|
|
150
|
+
service_layout.addLayout(service_controls)
|
|
151
|
+
service_layout.addWidget(self._svc_result)
|
|
152
|
+
|
|
153
|
+
self._integrity_btn = QPushButton("Run integrity check")
|
|
154
|
+
self._integrity_btn.clicked.connect(self._run_integrity)
|
|
155
|
+
self._integrity_result = QLabel("")
|
|
156
|
+
integrity = QHBoxLayout()
|
|
157
|
+
integrity.addWidget(self._integrity_btn)
|
|
158
|
+
integrity.addWidget(self._integrity_result, stretch=1)
|
|
159
|
+
|
|
160
|
+
layout = QVBoxLayout(self)
|
|
161
|
+
layout.addWidget(engine_box)
|
|
162
|
+
layout.addWidget(db_box)
|
|
163
|
+
layout.addWidget(self._cluster_box)
|
|
164
|
+
layout.addWidget(service_box)
|
|
165
|
+
layout.addLayout(integrity)
|
|
166
|
+
layout.addStretch(1)
|
|
167
|
+
|
|
168
|
+
# --- refresh (off the main thread) ---------------------------------------
|
|
169
|
+
|
|
170
|
+
def refresh(self) -> None:
|
|
171
|
+
if self._loading:
|
|
172
|
+
return # a fetch is already in flight — don't pile up (e.g. during a slow failover)
|
|
173
|
+
self._loading = True
|
|
174
|
+
self._runner.submit(self._fetch, on_done=self._apply, on_error=self._on_error)
|
|
175
|
+
|
|
176
|
+
def reload(self) -> None:
|
|
177
|
+
self.refresh()
|
|
178
|
+
|
|
179
|
+
def stop(self) -> None:
|
|
180
|
+
"""Stop the background runner (call on window close) so a late result can't touch dead widgets."""
|
|
181
|
+
self._runner.stop()
|
|
182
|
+
|
|
183
|
+
def _on_error(self, exc: BaseException) -> None:
|
|
184
|
+
# Belt-and-suspenders: the reads in _fetch raise only ApiError (handled via the snapshot), but
|
|
185
|
+
# an unexpected error must still clear the in-flight guard or the page wedges forever.
|
|
186
|
+
self._loading = False
|
|
187
|
+
self.error.emit(str(exc))
|
|
188
|
+
|
|
189
|
+
def _fetch(self) -> _Snapshot:
|
|
190
|
+
"""Runs on a worker thread — only blocking I/O, no widget access."""
|
|
191
|
+
svc = service_control.service_state(self._service_name)
|
|
192
|
+
try:
|
|
193
|
+
status = self._client.status()
|
|
194
|
+
except ApiError as exc:
|
|
195
|
+
return _Snapshot(svc, None, None, str(exc))
|
|
196
|
+
cluster: tuple[ClusterStatus, ClusterNodeList] | None
|
|
197
|
+
try:
|
|
198
|
+
cluster = (self._client.cluster_status(), self._client.cluster_nodes())
|
|
199
|
+
except ApiError:
|
|
200
|
+
cluster = None # endpoints not available / not permitted — keep the cluster box hidden
|
|
201
|
+
return _Snapshot(svc, status, cluster, None)
|
|
202
|
+
|
|
203
|
+
def _apply(self, snap: _Snapshot) -> None:
|
|
204
|
+
"""Runs on the main thread (result slot) — safe to touch widgets."""
|
|
205
|
+
self._loading = False
|
|
206
|
+
self._apply_service(snap.service_state)
|
|
207
|
+
if snap.error is not None:
|
|
208
|
+
self._engine["Reachable"].setText("no")
|
|
209
|
+
self._cluster_box.setVisible(False)
|
|
210
|
+
self.error.emit(snap.error)
|
|
211
|
+
return
|
|
212
|
+
assert snap.status is not None
|
|
213
|
+
e = snap.status.engine
|
|
214
|
+
self._engine["Reachable"].setText("yes")
|
|
215
|
+
self._engine["Version"].setText(e.version)
|
|
216
|
+
self._engine["Uptime"].setText(_human_uptime(e.uptime_seconds))
|
|
217
|
+
self._engine["PID"].setText(str(e.pid))
|
|
218
|
+
self._engine["Channels"].setText(f"{e.channels_running} running / {e.channels_total} total")
|
|
219
|
+
queue = ", ".join(f"{k}={v}" for k, v in sorted(e.outbox_by_status.items()))
|
|
220
|
+
self._engine["Queue"].setText(queue or "empty")
|
|
221
|
+
|
|
222
|
+
d = snap.status.db
|
|
223
|
+
self._db["Path"].setText(d.path)
|
|
224
|
+
self._db["Size"].setText(_human_bytes(d.size_bytes))
|
|
225
|
+
self._db["Free disk"].setText(_human_bytes(d.disk_free_bytes))
|
|
226
|
+
self._db["Journal mode"].setText(d.journal_mode)
|
|
227
|
+
self._db["Messages"].setText(str(d.messages))
|
|
228
|
+
self._db["Events"].setText(str(d.events))
|
|
229
|
+
self._db["Audit entries"].setText(str(d.audit))
|
|
230
|
+
|
|
231
|
+
self._apply_cluster(snap.cluster)
|
|
232
|
+
|
|
233
|
+
def _apply_cluster(self, cluster: tuple[ClusterStatus, ClusterNodeList] | None) -> None:
|
|
234
|
+
if cluster is None:
|
|
235
|
+
self._cluster_box.setVisible(False)
|
|
236
|
+
return
|
|
237
|
+
status, nodes = cluster
|
|
238
|
+
self._cluster_box.setVisible(True)
|
|
239
|
+
self._cl_mode.setText("clustered" if status.clustered else "single-node")
|
|
240
|
+
self._cl_role.setText(f"{status.role} ({status.node_id})")
|
|
241
|
+
self._cl_leader.setText(nodes.leader_node_id or "— (no live leader)")
|
|
242
|
+
lease = nodes.lease_owner or "—"
|
|
243
|
+
if nodes.lease_owner and nodes.lease_expires_at is not None:
|
|
244
|
+
remaining = int(nodes.lease_expires_at - time.time())
|
|
245
|
+
lease += f" (lease {'expired' if remaining < 0 else f'~{remaining}s left'})"
|
|
246
|
+
self._cl_lease.setText(lease)
|
|
247
|
+
|
|
248
|
+
now = time.time()
|
|
249
|
+
self._nodes.clear()
|
|
250
|
+
for n in nodes.nodes:
|
|
251
|
+
item = QTreeWidgetItem(
|
|
252
|
+
[
|
|
253
|
+
n.node_id,
|
|
254
|
+
n.host or "—",
|
|
255
|
+
str(n.pid) if n.pid is not None else "—",
|
|
256
|
+
n.status,
|
|
257
|
+
_ago(n.last_seen, now),
|
|
258
|
+
"✓ leader" if n.is_leader else "",
|
|
259
|
+
]
|
|
260
|
+
)
|
|
261
|
+
self._nodes.addTopLevelItem(item)
|
|
262
|
+
for col in range(self._nodes.columnCount()):
|
|
263
|
+
self._nodes.resizeColumnToContents(col)
|
|
264
|
+
|
|
265
|
+
def _apply_service(self, state: str) -> None:
|
|
266
|
+
self._service_state.setText(state)
|
|
267
|
+
# Only enable actions that make sense for the current state; all off in dev/no-service.
|
|
268
|
+
self._svc_start.setEnabled(state == "stopped")
|
|
269
|
+
self._svc_stop.setEnabled(state == "running")
|
|
270
|
+
self._svc_restart.setEnabled(state == "running")
|
|
271
|
+
self._svc_install.setVisible(state == "not installed") # offer install only then
|
|
272
|
+
|
|
273
|
+
# --- service control (unchanged; same-machine sc/net via UAC) ------------
|
|
274
|
+
|
|
275
|
+
def _install_service(self) -> None:
|
|
276
|
+
script = service_control.install_script_path()
|
|
277
|
+
if script is None:
|
|
278
|
+
self._svc_result.setText("Could not find install-service.ps1 — run it manually.")
|
|
279
|
+
return
|
|
280
|
+
env = self._prompt_environment()
|
|
281
|
+
if env is None:
|
|
282
|
+
return # cancelled or invalid — leave the page untouched
|
|
283
|
+
if not self._confirm_install(env):
|
|
284
|
+
return
|
|
285
|
+
if service_control.install_service(str(script), env):
|
|
286
|
+
self._svc_result.setText("Launching installer — approve the UAC prompt.")
|
|
287
|
+
else:
|
|
288
|
+
self._svc_result.setText("Service install is only available on Windows.")
|
|
289
|
+
|
|
290
|
+
def _prompt_environment(self) -> str | None:
|
|
291
|
+
"""Ask which active environment the service should run as (ADR 0017: the operator chooses it
|
|
292
|
+
explicitly — `serve` has no silent default). Returns the validated name, or None if the
|
|
293
|
+
operator cancelled or entered an invalid name."""
|
|
294
|
+
env, ok = QInputDialog.getText(
|
|
295
|
+
self,
|
|
296
|
+
"Active environment",
|
|
297
|
+
"Which environment should the service run as?\n"
|
|
298
|
+
"Selects environments/<name>.toml — e.g. dev, staging, prod, or a custom name.",
|
|
299
|
+
text="prod",
|
|
300
|
+
)
|
|
301
|
+
if not ok:
|
|
302
|
+
return None
|
|
303
|
+
env = env.strip()
|
|
304
|
+
if not service_control.is_safe_environment(env):
|
|
305
|
+
QMessageBox.warning(
|
|
306
|
+
self,
|
|
307
|
+
"Invalid environment",
|
|
308
|
+
"Use a simple name of letters, digits, '.', '_' or '-' "
|
|
309
|
+
"(it selects environments/<name>.toml).",
|
|
310
|
+
)
|
|
311
|
+
return None
|
|
312
|
+
return env
|
|
313
|
+
|
|
314
|
+
def _confirm_install(self, env: str) -> bool:
|
|
315
|
+
reply = QMessageBox.question(
|
|
316
|
+
self,
|
|
317
|
+
"Install MessageFoundry service",
|
|
318
|
+
f"This will install MessageFoundry as a Windows service (environment: {env}):\n\n"
|
|
319
|
+
"• Downloads NSSM (the service wrapper) if it isn't already present\n"
|
|
320
|
+
"• Registers the service to start automatically at boot\n"
|
|
321
|
+
"• Requires administrator rights — Windows will show a UAC prompt\n"
|
|
322
|
+
"• Runs in a PowerShell window so you can read the result\n\n"
|
|
323
|
+
"Stop any console-mode engine first so they don't share ports.\n\nProceed?",
|
|
324
|
+
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
|
325
|
+
QMessageBox.StandardButton.No,
|
|
326
|
+
)
|
|
327
|
+
return reply == QMessageBox.StandardButton.Yes
|
|
328
|
+
|
|
329
|
+
def _control(self, action: str) -> None:
|
|
330
|
+
if not self._confirm_admin(action):
|
|
331
|
+
return
|
|
332
|
+
# The confirm dialog already told the user what's about to happen, and the State readout
|
|
333
|
+
# reflects the result — so don't leave a lingering "requested..." message here.
|
|
334
|
+
if service_control.control_service(action, self._service_name):
|
|
335
|
+
self._svc_result.setText("")
|
|
336
|
+
else:
|
|
337
|
+
self._svc_result.setText("Service control is only available on Windows.")
|
|
338
|
+
|
|
339
|
+
def _confirm_admin(self, action: str) -> bool:
|
|
340
|
+
text = (
|
|
341
|
+
f"This will {action} the '{self._service_name}' service, which requires administrator "
|
|
342
|
+
"rights — Windows will show a UAC prompt."
|
|
343
|
+
)
|
|
344
|
+
if action in ("stop", "restart"):
|
|
345
|
+
text += (
|
|
346
|
+
"\n\nThe engine API drops briefly, so the Engine/Database panels will read "
|
|
347
|
+
"'unreachable' until it's back."
|
|
348
|
+
)
|
|
349
|
+
text += "\n\nProceed?"
|
|
350
|
+
reply = QMessageBox.question(
|
|
351
|
+
self,
|
|
352
|
+
f"{action.capitalize()} service",
|
|
353
|
+
text,
|
|
354
|
+
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
|
355
|
+
QMessageBox.StandardButton.No,
|
|
356
|
+
)
|
|
357
|
+
return reply == QMessageBox.StandardButton.Yes
|
|
358
|
+
|
|
359
|
+
# --- integrity check (off the main thread — quick_check can run for minutes) ---
|
|
360
|
+
|
|
361
|
+
def _run_integrity(self) -> None:
|
|
362
|
+
self._integrity_btn.setEnabled(False)
|
|
363
|
+
self._integrity_result.setText("running…")
|
|
364
|
+
self._runner.submit(
|
|
365
|
+
self._client.integrity_check,
|
|
366
|
+
on_done=self._apply_integrity,
|
|
367
|
+
on_error=self._integrity_error,
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
def _apply_integrity(self, result: IntegrityResult) -> None:
|
|
371
|
+
self._integrity_btn.setEnabled(True)
|
|
372
|
+
self._integrity_result.setText("✓ ok" if result.ok else f"✗ {result.detail}")
|
|
373
|
+
|
|
374
|
+
def _integrity_error(self, exc: BaseException) -> None:
|
|
375
|
+
self._integrity_btn.setEnabled(True)
|
|
376
|
+
self._integrity_result.setText("")
|
|
377
|
+
self.error.emit(str(exc))
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""Users administration page — visible only to users holding ``users:manage``.
|
|
4
|
+
|
|
5
|
+
Lists users with their roles and supports create / set-roles / delete. All operations go through the
|
|
6
|
+
HTTP API (which enforces permissions), so the page is a thin view; the engine is the source of truth.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from PySide6.QtCore import Signal
|
|
12
|
+
from PySide6.QtWidgets import (
|
|
13
|
+
QHBoxLayout,
|
|
14
|
+
QHeaderView,
|
|
15
|
+
QInputDialog,
|
|
16
|
+
QLineEdit,
|
|
17
|
+
QMessageBox,
|
|
18
|
+
QPushButton,
|
|
19
|
+
QTableWidget,
|
|
20
|
+
QTableWidgetItem,
|
|
21
|
+
QVBoxLayout,
|
|
22
|
+
QWidget,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
from messagefoundry.api.auth_models import UserSummary
|
|
26
|
+
from messagefoundry.console._async import AsyncRunner
|
|
27
|
+
from messagefoundry.console.client import ApiError, EngineClient
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _scope_label(channels: list[str] | None) -> str:
|
|
31
|
+
"""Human-readable channel scope: all / none / the listed connections."""
|
|
32
|
+
if channels is None:
|
|
33
|
+
return "all"
|
|
34
|
+
return ", ".join(channels) if channels else "(none)"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class UsersPage(QWidget):
|
|
38
|
+
"""Table of users + create/set-roles/delete actions (everything audited server-side)."""
|
|
39
|
+
|
|
40
|
+
error = Signal(str)
|
|
41
|
+
|
|
42
|
+
def __init__(self, client: EngineClient, *, poll_client: EngineClient | None = None) -> None:
|
|
43
|
+
super().__init__()
|
|
44
|
+
self._client = client # admin actions — main thread, may step-up/MFA
|
|
45
|
+
self._poll = poll_client or client # user-list read — runs off the main thread
|
|
46
|
+
self._runner = AsyncRunner(self)
|
|
47
|
+
self._loading = False # in-flight reload guard (don't pile up during a slow call)
|
|
48
|
+
# A reload requested while one is in flight is latched (not dropped) and re-fired on
|
|
49
|
+
# completion, so a post-action reload (create/delete/set-roles) isn't lost during a tick.
|
|
50
|
+
self._pending = False
|
|
51
|
+
self._row_ids: list[str] = [] # user_id per table row
|
|
52
|
+
self._row_scopes: list[list[str] | None] = [] # channel scope per row (None = all)
|
|
53
|
+
# current roles per row, to prefill the Set-roles dialog (H3)
|
|
54
|
+
self._row_roles: list[list[str]] = []
|
|
55
|
+
|
|
56
|
+
self._table = QTableWidget(0, 5)
|
|
57
|
+
self._table.setHorizontalHeaderLabels(
|
|
58
|
+
["Username", "Provider", "Roles", "Channel scope", "Status"]
|
|
59
|
+
)
|
|
60
|
+
header = self._table.horizontalHeader()
|
|
61
|
+
if header is not None:
|
|
62
|
+
header.setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
|
|
63
|
+
self._table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
|
|
64
|
+
self._table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
|
|
65
|
+
|
|
66
|
+
add_btn = QPushButton("Add user…")
|
|
67
|
+
add_btn.clicked.connect(self._add_user)
|
|
68
|
+
roles_btn = QPushButton("Set roles…")
|
|
69
|
+
roles_btn.clicked.connect(self._set_roles)
|
|
70
|
+
scope_btn = QPushButton("Set scope…")
|
|
71
|
+
scope_btn.clicked.connect(self._set_scope)
|
|
72
|
+
delete_btn = QPushButton("Delete")
|
|
73
|
+
delete_btn.clicked.connect(self._delete)
|
|
74
|
+
sessions_btn = QPushButton("Revoke sessions")
|
|
75
|
+
sessions_btn.clicked.connect(self._revoke_sessions)
|
|
76
|
+
refresh_btn = QPushButton("Refresh")
|
|
77
|
+
refresh_btn.clicked.connect(self.reload)
|
|
78
|
+
|
|
79
|
+
buttons = QHBoxLayout()
|
|
80
|
+
for button in (add_btn, roles_btn, scope_btn, delete_btn, sessions_btn, refresh_btn):
|
|
81
|
+
buttons.addWidget(button)
|
|
82
|
+
buttons.addStretch(1)
|
|
83
|
+
|
|
84
|
+
layout = QVBoxLayout(self)
|
|
85
|
+
layout.addLayout(buttons)
|
|
86
|
+
layout.addWidget(self._table)
|
|
87
|
+
|
|
88
|
+
# --- page interface (auto-refresh timer + nav) ---------------------------
|
|
89
|
+
|
|
90
|
+
def refresh(self) -> None:
|
|
91
|
+
# This page has no separate silent path (no PHI-summary audit or column autosize to skip),
|
|
92
|
+
# so the auto-refresh tick and a user-initiated reload are the same full repopulate.
|
|
93
|
+
self.reload()
|
|
94
|
+
|
|
95
|
+
def reload(self) -> None:
|
|
96
|
+
# Read the user list OFF the main thread; apply on the main thread (a slow/wedged engine
|
|
97
|
+
# would otherwise freeze the GUI for the whole /users call).
|
|
98
|
+
if self._loading:
|
|
99
|
+
self._pending = True # latch — re-fire when the in-flight read completes (don't drop)
|
|
100
|
+
return
|
|
101
|
+
self._pending = False
|
|
102
|
+
self._loading = True
|
|
103
|
+
self._runner.submit(self._fetch, on_done=self._apply, on_error=self._on_error)
|
|
104
|
+
|
|
105
|
+
def stop(self) -> None:
|
|
106
|
+
"""Stop the background runner (call on window close) so a late result can't touch dead widgets."""
|
|
107
|
+
self._runner.stop()
|
|
108
|
+
|
|
109
|
+
def _fetch(self) -> list[UserSummary]:
|
|
110
|
+
"""Runs on a worker thread — only blocking I/O, no widget access."""
|
|
111
|
+
return self._poll.list_users()
|
|
112
|
+
|
|
113
|
+
def _on_error(self, exc: BaseException) -> None:
|
|
114
|
+
self._loading = False
|
|
115
|
+
self.error.emit(str(exc))
|
|
116
|
+
self._drain_pending()
|
|
117
|
+
|
|
118
|
+
def _drain_pending(self) -> bool:
|
|
119
|
+
"""Re-fire a reload that was latched while one was in flight. Returns True if it did."""
|
|
120
|
+
if not self._pending:
|
|
121
|
+
return False
|
|
122
|
+
self._pending = False
|
|
123
|
+
self.reload()
|
|
124
|
+
return True
|
|
125
|
+
|
|
126
|
+
def _apply(self, users: list[UserSummary]) -> None:
|
|
127
|
+
"""Runs on the main thread (result slot) — safe to touch widgets."""
|
|
128
|
+
self._loading = False
|
|
129
|
+
if self._drain_pending():
|
|
130
|
+
return # a reload was requested mid-flight — re-fire it, skip this superseded result
|
|
131
|
+
self._row_ids = [u.id for u in users]
|
|
132
|
+
self._row_scopes = [u.channel_scope for u in users]
|
|
133
|
+
self._row_roles = [list(u.roles) for u in users]
|
|
134
|
+
self._table.setRowCount(len(users))
|
|
135
|
+
for row, user in enumerate(users):
|
|
136
|
+
cells = [
|
|
137
|
+
user.username,
|
|
138
|
+
user.auth_provider,
|
|
139
|
+
", ".join(user.roles),
|
|
140
|
+
_scope_label(user.channel_scope),
|
|
141
|
+
"disabled" if user.disabled else "active",
|
|
142
|
+
]
|
|
143
|
+
for col, text in enumerate(cells):
|
|
144
|
+
self._table.setItem(row, col, QTableWidgetItem(text))
|
|
145
|
+
|
|
146
|
+
# --- actions -------------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
def _selected_user_id(self) -> str | None:
|
|
149
|
+
row = self._table.currentRow()
|
|
150
|
+
return self._row_ids[row] if 0 <= row < len(self._row_ids) else None
|
|
151
|
+
|
|
152
|
+
def _available_roles(self) -> list[str]:
|
|
153
|
+
try:
|
|
154
|
+
return [r.id for r in self._client.list_roles()]
|
|
155
|
+
except ApiError:
|
|
156
|
+
return []
|
|
157
|
+
|
|
158
|
+
def _pick_roles(self, current: list[str]) -> list[str] | None:
|
|
159
|
+
available = ", ".join(self._available_roles())
|
|
160
|
+
text, ok = QInputDialog.getText(
|
|
161
|
+
self,
|
|
162
|
+
"Roles",
|
|
163
|
+
f"Comma-separated roles (available: {available}):",
|
|
164
|
+
QLineEdit.EchoMode.Normal,
|
|
165
|
+
", ".join(current),
|
|
166
|
+
)
|
|
167
|
+
if not ok:
|
|
168
|
+
return None
|
|
169
|
+
return [r.strip() for r in text.split(",") if r.strip()]
|
|
170
|
+
|
|
171
|
+
def _add_user(self) -> None:
|
|
172
|
+
username, ok = QInputDialog.getText(self, "Add user", "Username:")
|
|
173
|
+
if not ok or not username.strip():
|
|
174
|
+
return
|
|
175
|
+
password, ok = QInputDialog.getText(
|
|
176
|
+
self, "Add user", "Password:", QLineEdit.EchoMode.Password
|
|
177
|
+
)
|
|
178
|
+
if not ok or not password:
|
|
179
|
+
return
|
|
180
|
+
roles = self._pick_roles([])
|
|
181
|
+
if roles is None:
|
|
182
|
+
return
|
|
183
|
+
try:
|
|
184
|
+
self._client.create_user(username.strip(), password, roles=roles)
|
|
185
|
+
except ApiError as exc:
|
|
186
|
+
self.error.emit(str(exc))
|
|
187
|
+
self.reload()
|
|
188
|
+
|
|
189
|
+
def _set_roles(self) -> None:
|
|
190
|
+
# Resolve the row (not just the user_id) so we can prefill the dialog with the user's
|
|
191
|
+
# current roles — otherwise it opens blank and submitting would clear/truncate them (H3).
|
|
192
|
+
row = self._table.currentRow()
|
|
193
|
+
if not (0 <= row < len(self._row_ids)):
|
|
194
|
+
return
|
|
195
|
+
user_id = self._row_ids[row]
|
|
196
|
+
# Prefill the user's current roles so the PUT (which replaces the whole set) doesn't strip
|
|
197
|
+
# them when the operator just wanted to tweak one — and confirm an empty submission (low-15).
|
|
198
|
+
roles = self._pick_roles(self._row_roles[row])
|
|
199
|
+
if roles is None:
|
|
200
|
+
return
|
|
201
|
+
if not roles and not self._confirm_strip_roles():
|
|
202
|
+
return
|
|
203
|
+
try:
|
|
204
|
+
self._client.set_user_roles(user_id, roles)
|
|
205
|
+
except ApiError as exc:
|
|
206
|
+
self.error.emit(str(exc))
|
|
207
|
+
self.reload()
|
|
208
|
+
|
|
209
|
+
def _confirm_strip_roles(self) -> bool:
|
|
210
|
+
reply = QMessageBox.question(
|
|
211
|
+
self,
|
|
212
|
+
"Remove all roles",
|
|
213
|
+
"Submitting no roles removes every role from this user, leaving them with no access. "
|
|
214
|
+
"Continue?",
|
|
215
|
+
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
|
216
|
+
QMessageBox.StandardButton.No,
|
|
217
|
+
)
|
|
218
|
+
return reply == QMessageBox.StandardButton.Yes
|
|
219
|
+
|
|
220
|
+
def _inbound_connections(self) -> list[str]:
|
|
221
|
+
try:
|
|
222
|
+
return sorted({c.channel_id for c in self._client.connections() if c.role == "source"})
|
|
223
|
+
except ApiError:
|
|
224
|
+
return []
|
|
225
|
+
|
|
226
|
+
def _set_scope(self) -> None:
|
|
227
|
+
row = self._table.currentRow()
|
|
228
|
+
if not (0 <= row < len(self._row_ids)):
|
|
229
|
+
return
|
|
230
|
+
user_id = self._row_ids[row]
|
|
231
|
+
current = self._row_scopes[row]
|
|
232
|
+
available = ", ".join(self._inbound_connections())
|
|
233
|
+
prefill = "*" if current is None else ", ".join(current)
|
|
234
|
+
text, ok = QInputDialog.getText(
|
|
235
|
+
self,
|
|
236
|
+
"Channel scope",
|
|
237
|
+
# '*' (or blank) = all channels; a comma-separated list restricts to those connections.
|
|
238
|
+
f"Connections ('*' = all; available: {available}):",
|
|
239
|
+
QLineEdit.EchoMode.Normal,
|
|
240
|
+
prefill,
|
|
241
|
+
)
|
|
242
|
+
if not ok:
|
|
243
|
+
return
|
|
244
|
+
entries = [c.strip() for c in text.split(",") if c.strip()]
|
|
245
|
+
channels = None if (not entries or entries == ["*"]) else entries
|
|
246
|
+
try:
|
|
247
|
+
self._client.set_channel_scope(user_id, channels)
|
|
248
|
+
except ApiError as exc:
|
|
249
|
+
self.error.emit(str(exc))
|
|
250
|
+
self.reload()
|
|
251
|
+
|
|
252
|
+
def _delete(self) -> None:
|
|
253
|
+
user_id = self._selected_user_id()
|
|
254
|
+
if user_id is None:
|
|
255
|
+
return
|
|
256
|
+
confirm = QMessageBox.question(self, "Delete user", "Delete the selected user?")
|
|
257
|
+
if confirm != QMessageBox.StandardButton.Yes:
|
|
258
|
+
return
|
|
259
|
+
try:
|
|
260
|
+
self._client.delete_user(user_id)
|
|
261
|
+
except ApiError as exc:
|
|
262
|
+
self.error.emit(str(exc))
|
|
263
|
+
self.reload()
|
|
264
|
+
|
|
265
|
+
def _revoke_sessions(self) -> None:
|
|
266
|
+
"""Admin force-sign-out: revoke all of the selected user's sessions (offboarding/compromise)."""
|
|
267
|
+
user_id = self._selected_user_id()
|
|
268
|
+
if user_id is None:
|
|
269
|
+
return
|
|
270
|
+
confirm = QMessageBox.question(
|
|
271
|
+
self,
|
|
272
|
+
"Revoke sessions",
|
|
273
|
+
"Force-sign-out the selected user by revoking all of their active sessions?",
|
|
274
|
+
)
|
|
275
|
+
if confirm != QMessageBox.StandardButton.Yes:
|
|
276
|
+
return
|
|
277
|
+
try:
|
|
278
|
+
detail = self._client.revoke_user_sessions(user_id)
|
|
279
|
+
except ApiError as exc:
|
|
280
|
+
self.error.emit(str(exc))
|
|
281
|
+
return
|
|
282
|
+
QMessageBox.information(self, "Sessions revoked", detail)
|