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.
Files changed (142) hide show
  1. messagefoundry/__init__.py +108 -0
  2. messagefoundry/__main__.py +1155 -0
  3. messagefoundry/api/__init__.py +27 -0
  4. messagefoundry/api/app.py +1581 -0
  5. messagefoundry/api/approvals.py +184 -0
  6. messagefoundry/api/auth_models.py +211 -0
  7. messagefoundry/api/auth_routes.py +655 -0
  8. messagefoundry/api/field_authz.py +96 -0
  9. messagefoundry/api/models.py +374 -0
  10. messagefoundry/api/security.py +247 -0
  11. messagefoundry/api/tls.py +47 -0
  12. messagefoundry/auth/__init__.py +39 -0
  13. messagefoundry/auth/data/common_passwords.NOTICE +13 -0
  14. messagefoundry/auth/data/common_passwords.txt +10000 -0
  15. messagefoundry/auth/identity.py +71 -0
  16. messagefoundry/auth/ldap.py +264 -0
  17. messagefoundry/auth/notifications.py +68 -0
  18. messagefoundry/auth/passwords.py +53 -0
  19. messagefoundry/auth/permissions.py +120 -0
  20. messagefoundry/auth/policy.py +153 -0
  21. messagefoundry/auth/ratelimit.py +55 -0
  22. messagefoundry/auth/service.py +1323 -0
  23. messagefoundry/auth/tokens.py +26 -0
  24. messagefoundry/auth/totp.py +174 -0
  25. messagefoundry/checks.py +174 -0
  26. messagefoundry/config/__init__.py +30 -0
  27. messagefoundry/config/active_environment.py +80 -0
  28. messagefoundry/config/ai_policy.py +140 -0
  29. messagefoundry/config/code_sets.py +260 -0
  30. messagefoundry/config/connections_edit.py +200 -0
  31. messagefoundry/config/connections_file.py +287 -0
  32. messagefoundry/config/db_lookup.py +117 -0
  33. messagefoundry/config/environments.py +116 -0
  34. messagefoundry/config/ingest_time.py +83 -0
  35. messagefoundry/config/models.py +240 -0
  36. messagefoundry/config/reference.py +158 -0
  37. messagefoundry/config/response.py +83 -0
  38. messagefoundry/config/run_context.py +153 -0
  39. messagefoundry/config/settings.py +1311 -0
  40. messagefoundry/config/state.py +99 -0
  41. messagefoundry/config/tls_policy.py +110 -0
  42. messagefoundry/config/wiring.py +1918 -0
  43. messagefoundry/console/__init__.py +20 -0
  44. messagefoundry/console/__main__.py +274 -0
  45. messagefoundry/console/_async.py +107 -0
  46. messagefoundry/console/change_password.py +111 -0
  47. messagefoundry/console/client.py +552 -0
  48. messagefoundry/console/connections.py +324 -0
  49. messagefoundry/console/login.py +107 -0
  50. messagefoundry/console/mfa.py +205 -0
  51. messagefoundry/console/reauth.py +94 -0
  52. messagefoundry/console/search.py +57 -0
  53. messagefoundry/console/service_control.py +137 -0
  54. messagefoundry/console/sessions.py +122 -0
  55. messagefoundry/console/shell.py +410 -0
  56. messagefoundry/console/status.py +377 -0
  57. messagefoundry/console/users_page.py +282 -0
  58. messagefoundry/console/widgets.py +553 -0
  59. messagefoundry/generators/README.md +27 -0
  60. messagefoundry/generators/__init__.py +15 -0
  61. messagefoundry/generators/_core.py +589 -0
  62. messagefoundry/generators/_hl7data.py +428 -0
  63. messagefoundry/generators/adt.py +286 -0
  64. messagefoundry/generators/all_types.py +24 -0
  65. messagefoundry/generators/bar.py +28 -0
  66. messagefoundry/generators/dft.py +20 -0
  67. messagefoundry/generators/mdm.py +39 -0
  68. messagefoundry/generators/mfn.py +46 -0
  69. messagefoundry/generators/oml.py +32 -0
  70. messagefoundry/generators/orl.py +30 -0
  71. messagefoundry/generators/orm.py +23 -0
  72. messagefoundry/generators/oru.py +21 -0
  73. messagefoundry/generators/ras.py +20 -0
  74. messagefoundry/generators/rde.py +54 -0
  75. messagefoundry/generators/siu.py +64 -0
  76. messagefoundry/generators/vxu.py +20 -0
  77. messagefoundry/hl7schema.py +75 -0
  78. messagefoundry/last_resort.py +55 -0
  79. messagefoundry/logging_setup.py +332 -0
  80. messagefoundry/parsing/__init__.py +64 -0
  81. messagefoundry/parsing/consistency.py +166 -0
  82. messagefoundry/parsing/groups.py +228 -0
  83. messagefoundry/parsing/message.py +453 -0
  84. messagefoundry/parsing/peek.py +237 -0
  85. messagefoundry/parsing/split.py +120 -0
  86. messagefoundry/parsing/summary.py +46 -0
  87. messagefoundry/parsing/tree.py +128 -0
  88. messagefoundry/parsing/validate.py +95 -0
  89. messagefoundry/parsing/x12/__init__.py +46 -0
  90. messagefoundry/parsing/x12/delimiters.py +140 -0
  91. messagefoundry/parsing/x12/errors.py +30 -0
  92. messagefoundry/parsing/x12/interchange.py +232 -0
  93. messagefoundry/parsing/x12/message.py +200 -0
  94. messagefoundry/parsing/x12/peek.py +207 -0
  95. messagefoundry/pipeline/__init__.py +21 -0
  96. messagefoundry/pipeline/alert_sinks.py +486 -0
  97. messagefoundry/pipeline/alerts.py +100 -0
  98. messagefoundry/pipeline/cert_expiry.py +219 -0
  99. messagefoundry/pipeline/cluster.py +955 -0
  100. messagefoundry/pipeline/cluster_sqlserver.py +444 -0
  101. messagefoundry/pipeline/config_convergence.py +137 -0
  102. messagefoundry/pipeline/dryrun.py +450 -0
  103. messagefoundry/pipeline/engine.py +756 -0
  104. messagefoundry/pipeline/leader_tasks.py +158 -0
  105. messagefoundry/pipeline/reference_sync.py +369 -0
  106. messagefoundry/pipeline/retention.py +289 -0
  107. messagefoundry/pipeline/security_notify.py +168 -0
  108. messagefoundry/pipeline/state_convergence.py +143 -0
  109. messagefoundry/pipeline/wiring_runner.py +1722 -0
  110. messagefoundry/py.typed +0 -0
  111. messagefoundry/redaction.py +71 -0
  112. messagefoundry/scaffold.py +321 -0
  113. messagefoundry/secrets_dpapi.py +129 -0
  114. messagefoundry/store/__init__.py +46 -0
  115. messagefoundry/store/audit_tee.py +67 -0
  116. messagefoundry/store/base.py +758 -0
  117. messagefoundry/store/crypto.py +166 -0
  118. messagefoundry/store/keyprovider.py +192 -0
  119. messagefoundry/store/postgres.py +3447 -0
  120. messagefoundry/store/sqlserver.py +3014 -0
  121. messagefoundry/store/store.py +3790 -0
  122. messagefoundry/timezone.py +207 -0
  123. messagefoundry/transports/__init__.py +50 -0
  124. messagefoundry/transports/base.py +269 -0
  125. messagefoundry/transports/database.py +693 -0
  126. messagefoundry/transports/file.py +551 -0
  127. messagefoundry/transports/framing.py +164 -0
  128. messagefoundry/transports/loopback.py +53 -0
  129. messagefoundry/transports/mllp.py +644 -0
  130. messagefoundry/transports/remotefile.py +664 -0
  131. messagefoundry/transports/rest.py +281 -0
  132. messagefoundry/transports/signing.py +321 -0
  133. messagefoundry/transports/soap.py +507 -0
  134. messagefoundry/transports/tcp.py +307 -0
  135. messagefoundry/transports/timer.py +146 -0
  136. messagefoundry/transports/x12.py +323 -0
  137. messagefoundry-0.1.0.dist-info/METADATA +212 -0
  138. messagefoundry-0.1.0.dist-info/RECORD +142 -0
  139. messagefoundry-0.1.0.dist-info/WHEEL +4 -0
  140. messagefoundry-0.1.0.dist-info/entry_points.txt +2 -0
  141. messagefoundry-0.1.0.dist-info/licenses/LICENSE +662 -0
  142. messagefoundry-0.1.0.dist-info/licenses/NOTICE +27 -0
@@ -0,0 +1,410 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """App shell: a persistent left nav over stacked pages, with the auto-refresh timer driving
4
+ whichever page is active. Pages: Connections, Alerts (stub), Log Search, Engine Status.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Protocol, cast
10
+
11
+ from PySide6.QtCore import QEasingCurve, QPropertyAnimation, Qt, QTimer, Signal
12
+ from PySide6.QtGui import QCloseEvent, QKeySequence, QShortcut
13
+ from PySide6.QtWidgets import (
14
+ QApplication,
15
+ QGraphicsOpacityEffect,
16
+ QHBoxLayout,
17
+ QLabel,
18
+ QListWidget,
19
+ QMenu,
20
+ QStackedWidget,
21
+ QToolButton,
22
+ QVBoxLayout,
23
+ QWidget,
24
+ )
25
+
26
+ from messagefoundry.console import service_control
27
+ from messagefoundry.console._async import AsyncRunner
28
+ from messagefoundry.console.change_password import ChangePasswordDialog
29
+ from messagefoundry.console.mfa import manage_mfa
30
+ from messagefoundry.console.sessions import SessionsDialog
31
+ from messagefoundry.console.client import ApiError, EngineClient
32
+ from messagefoundry.console.connections import ConnectionsPage
33
+ from messagefoundry.console.search import LogSearchPage
34
+ from messagefoundry.console.status import EngineStatusPage
35
+ from messagefoundry.console.users_page import UsersPage
36
+ from messagefoundry.console.widgets import ERROR_COLOR, RefreshSettingsDialog
37
+
38
+ _NAV = ["Connections", "Alerts", "Log Search", "Engine Status"]
39
+ _HEALTH_INTERVAL_MS = 5000 # heart polls health on its own timer (independent of auto-refresh)
40
+ _LOW_DISK_BYTES = 1024**3 # < 1 GiB free on the DB drive => "running out of space"
41
+
42
+
43
+ class HeartIndicator(QLabel):
44
+ """A heart glyph that conveys overall health: green steady (healthy), orange pulsing
45
+ 100%↔50% (low space), red pulsing 100%↔75% (engine/DB stopped)."""
46
+
47
+ _COLORS = {"green": "#2e7d32", "orange": "#ef6c00", "red": ERROR_COLOR}
48
+ _PULSE = {"orange": (0.5, 2000), "red": (0.75, 1400)} # (low opacity, cycle ms)
49
+
50
+ def __init__(self) -> None:
51
+ super().__init__("♥") # ♥
52
+ self._state = ""
53
+ self._effect = QGraphicsOpacityEffect(self)
54
+ self.setGraphicsEffect(self._effect)
55
+ self._anim = QPropertyAnimation(self._effect, b"opacity", self)
56
+ self._anim.setLoopCount(-1)
57
+ self._anim.setEasingCurve(QEasingCurve.Type.InOutSine)
58
+ self.set_state("green")
59
+
60
+ def set_state(self, state: str) -> None:
61
+ if state == self._state:
62
+ return
63
+ self._state = state
64
+ self.setStyleSheet(f"color: {self._COLORS.get(state, '#2e7d32')}; font-size: 44px;")
65
+ self._anim.stop()
66
+ if state not in self._PULSE: # green / unknown: steady, fully opaque
67
+ self._effect.setOpacity(1.0)
68
+ return
69
+ low, duration = self._PULSE[state]
70
+ self._anim.setDuration(duration)
71
+ self._anim.setKeyValueAt(0.0, 1.0)
72
+ self._anim.setKeyValueAt(0.5, low)
73
+ self._anim.setKeyValueAt(1.0, 1.0)
74
+ self._anim.start()
75
+
76
+
77
+ class _Refreshable(Protocol):
78
+ def refresh(self) -> None: ... # silent (auto-refresh timer)
79
+ def reload(self) -> None: ... # user-initiated (nav/open): may audit / autosize
80
+
81
+
82
+ class PlaceholderPage(QWidget):
83
+ """A nav destination that isn't built yet."""
84
+
85
+ def __init__(self, title: str) -> None:
86
+ super().__init__()
87
+ label = QLabel(f"{title} — coming soon")
88
+ label.setAlignment(Qt.AlignmentFlag.AlignCenter)
89
+ layout = QVBoxLayout(self)
90
+ layout.addWidget(label)
91
+
92
+ def refresh(self) -> None: # uniform page interface; nothing to refresh
93
+ return
94
+
95
+ def reload(self) -> None:
96
+ return
97
+
98
+
99
+ class AppWindow(QWidget):
100
+ """Top-level window: left nav + stacked pages + an auto-refresh control.
101
+
102
+ The timer refreshes whichever page is active. ``interval_changed`` fires (seconds) when
103
+ the user picks a new interval so the entrypoint can persist it."""
104
+
105
+ interval_changed = Signal(float)
106
+ logout_requested = Signal()
107
+ # Emitted only after a successful in-app password change; the entrypoint re-prompts sign-in
108
+ # because the server revokes the session on change.
109
+ change_password_requested = Signal()
110
+ # Emitted when the health poll sees a 401 (the session expired/was revoked mid-session) so the
111
+ # entrypoint can re-prompt sign-in instead of leaving the user stuck (review M-26).
112
+ session_expired = Signal()
113
+
114
+ def __init__(
115
+ self,
116
+ client: EngineClient,
117
+ *,
118
+ poll_client: EngineClient | None = None,
119
+ poll_seconds: float = 2.0,
120
+ service_name: str = "MessageFoundry",
121
+ ) -> None:
122
+ super().__init__()
123
+ self.setWindowTitle("MessageFoundry Console")
124
+ self._client = client # user actions + modal auth flows — main thread only
125
+ # All BACKGROUND (off-thread) reads — the nav health poll, Engine Status, and the per-page
126
+ # auto-refresh — go through this read-only client, so the handler-bearing primary client is
127
+ # never touched from a worker thread (the cross-thread-shared-client hazard). Defaults to the
128
+ # primary client when not supplied (tests / embedding), which keeps single-client behaviour.
129
+ self._poll_client = poll_client or client
130
+ self._service_name = service_name
131
+ self._interval = max(0.0, poll_seconds)
132
+
133
+ self.connections = ConnectionsPage(client, poll_client=self._poll_client)
134
+ self.log_search = LogSearchPage(client, poll_client=self._poll_client)
135
+ self.engine_status = EngineStatusPage(self._poll_client, service_name=service_name)
136
+ nav_items = list(_NAV)
137
+ self._pages: list[QWidget] = [
138
+ self.connections,
139
+ PlaceholderPage("Alerts"),
140
+ self.log_search,
141
+ self.engine_status,
142
+ ]
143
+ if client.can("users:manage"): # user administration is permission-gated
144
+ self.users = UsersPage(client, poll_client=self._poll_client)
145
+ self.users.error.connect(self._show_error)
146
+ nav_items.append("Users")
147
+ self._pages.append(self.users)
148
+ self._stack = QStackedWidget()
149
+ for page in self._pages:
150
+ self._stack.addWidget(page)
151
+
152
+ self._nav = QListWidget()
153
+ self._nav.addItems(nav_items)
154
+ self._nav.setFixedWidth(160)
155
+ self._nav.currentRowChanged.connect(self._on_nav)
156
+
157
+ self.connections.open_logs.connect(self._open_logs)
158
+ self.connections.error.connect(self._show_error)
159
+ self.log_search.error.connect(self._show_error)
160
+ self.engine_status.error.connect(self._show_error)
161
+
162
+ self._timer = QTimer(self)
163
+ self._timer.timeout.connect(self._tick)
164
+
165
+ self._refresh_link = QLabel()
166
+ self._refresh_link.setTextFormat(Qt.TextFormat.RichText)
167
+ self._refresh_link.setTextInteractionFlags(Qt.TextInteractionFlag.LinksAccessibleByMouse)
168
+ self._refresh_link.linkActivated.connect(self._edit_interval)
169
+
170
+ self._user_label: QLabel | None = None # header username label (None if signed out)
171
+ self._user_menu: QMenu | None = None # account overflow menu (Change password / Sign out)
172
+ topbar = QHBoxLayout()
173
+ topbar.addWidget(QLabel("<b>MessageFoundry</b>"))
174
+ topbar.addStretch(1)
175
+ signed_in = client.current_user
176
+ if signed_in is not None:
177
+ roles = ", ".join(signed_in.roles) or "no roles"
178
+ user_label = QLabel(f"{signed_in.username} ({roles})")
179
+ self._user_label = user_label
180
+ topbar.addWidget(user_label)
181
+
182
+ # Account actions live in a compact "⋯" overflow menu to keep the header uncluttered.
183
+ menu = QMenu(self)
184
+ # Local accounts can rotate their password in-app; AD passwords are managed in Active
185
+ # Directory (the server rejects /me/password for them), so omit it for AD users.
186
+ if signed_in.auth_provider != "ad":
187
+ menu.addAction("Change password…", lambda *_: self._change_password())
188
+ # Native TOTP MFA is for local accounts; AD users get MFA from the directory.
189
+ menu.addAction("Two-factor authentication…", lambda *_: self._manage_mfa())
190
+ # All users (incl. AD) have server-side sessions they can inventory and revoke.
191
+ menu.addAction("Active sessions…", lambda *_: self._active_sessions())
192
+ menu.addAction("Sign out", lambda *_: self.logout_requested.emit())
193
+ self._user_menu = menu
194
+
195
+ menu_btn = QToolButton()
196
+ menu_btn.setText("⋯")
197
+ menu_btn.setToolTip("Account")
198
+ menu_btn.setAutoRaise(True)
199
+ menu_btn.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
200
+ menu_btn.setStyleSheet("QToolButton::menu-indicator { image: none; }") # just the dots
201
+ menu_btn.setMenu(menu)
202
+ topbar.addWidget(menu_btn)
203
+ # Plain-text separator: a bare QLabel has no leading HTML tag, so Qt treats it as plain
204
+ # text and would render an "&nbsp;" entity literally — so use real spaces, not entities.
205
+ topbar.addWidget(QLabel(" | "))
206
+ topbar.addWidget(self._refresh_link)
207
+
208
+ self._heart = HeartIndicator()
209
+ heart_row = QHBoxLayout()
210
+ heart_row.addWidget(self._heart)
211
+ heart_row.addWidget(QLabel("Engine"))
212
+ heart_row.addStretch(1)
213
+
214
+ left = QVBoxLayout()
215
+ left.addWidget(self._nav, stretch=1)
216
+ left.addLayout(heart_row)
217
+
218
+ body = QHBoxLayout()
219
+ body.addLayout(left)
220
+ body.addWidget(self._stack, stretch=1)
221
+
222
+ self._status = QLabel("")
223
+ self._health_error = "" # the reachability error the poll currently owns (low-14)
224
+
225
+ layout = QVBoxLayout(self)
226
+ layout.addLayout(topbar)
227
+ layout.addLayout(body)
228
+ layout.addWidget(self._status)
229
+
230
+ self._nav.setCurrentRow(0)
231
+ self._apply_interval()
232
+
233
+ # Zoom the whole UI with Ctrl +/- (and Ctrl+= for keyboards needing shift), Ctrl+0 reset.
234
+ self._base_point_size = max(1.0, self._app().font().pointSizeF())
235
+ for seq, step in (("Ctrl++", 1), ("Ctrl+=", 1), ("Ctrl+-", -1), ("Ctrl+0", 0)):
236
+ shortcut = QShortcut(QKeySequence(seq), self)
237
+ shortcut.activated.connect(lambda s=step: self._zoom(s))
238
+
239
+ # The nav heart polls health on its own timer so it updates even when auto-refresh is off.
240
+ # The poll reads the engine off the main thread (a /status read can stall for seconds during
241
+ # a failover) and applies the heart on the main thread.
242
+ self._health_runner = AsyncRunner(self)
243
+ self._health_loading = False # in-flight guard — one poll at a time
244
+ self._health_timer = QTimer(self)
245
+ self._health_timer.timeout.connect(self._poll_health)
246
+ self._health_timer.start(_HEALTH_INTERVAL_MS)
247
+ self._poll_health()
248
+
249
+ def refresh_all(self) -> None:
250
+ self._refresh_current()
251
+
252
+ def closeEvent(self, event: QCloseEvent) -> None:
253
+ # Stop the timers before teardown so a queued tick/health-poll can't touch widgets while
254
+ # the window is being destroyed (M3). Then stop the off-thread runners so a late in-flight
255
+ # result (engine read) is dropped rather than delivered to a torn-down widget.
256
+ self._timer.stop()
257
+ self._health_timer.stop()
258
+ self._health_runner.stop()
259
+ # Stop every page that runs a background runner (Connections, Log Search, Engine Status,
260
+ # Users); the PlaceholderPage has no stop() and is skipped.
261
+ for page in self._pages:
262
+ stop = getattr(page, "stop", None)
263
+ if callable(stop):
264
+ stop()
265
+ super().closeEvent(event)
266
+
267
+ def _app(self) -> QApplication:
268
+ app = QApplication.instance()
269
+ assert isinstance(app, QApplication)
270
+ return app
271
+
272
+ def _zoom(self, step: int) -> None:
273
+ """Grow/shrink the application font (step ±1), or reset to the launch size (step 0)."""
274
+ app = self._app()
275
+ font = app.font()
276
+ if step == 0:
277
+ size = self._base_point_size
278
+ else:
279
+ current = font.pointSizeF() if font.pointSizeF() > 0 else self._base_point_size
280
+ size = max(6.0, min(40.0, current + step))
281
+ font.setPointSizeF(size)
282
+ app.setFont(font)
283
+
284
+ def set_interval(self, seconds: float) -> None:
285
+ """Change the auto-refresh interval (seconds; 0 = off) and notify listeners."""
286
+ self._interval = max(0.0, seconds)
287
+ self._apply_interval()
288
+ self.interval_changed.emit(self._interval)
289
+
290
+ # --- internals -----------------------------------------------------------
291
+
292
+ def _refresh_current(self) -> None:
293
+ cast(_Refreshable, self._pages[self._stack.currentIndex()]).refresh()
294
+
295
+ def _on_nav(self, row: int) -> None:
296
+ if 0 <= row < len(self._pages):
297
+ self._stack.setCurrentIndex(row)
298
+ # Nav is user-initiated -> reload (audits PHI / autosizes); the timer uses refresh().
299
+ cast(_Refreshable, self._pages[row]).reload()
300
+
301
+ def _open_logs(self, channel_id: str) -> None:
302
+ # Switch to Log Search without firing _on_nav; set_channel does the single audited load.
303
+ idx = _NAV.index("Log Search")
304
+ self._nav.blockSignals(True)
305
+ self._nav.setCurrentRow(idx)
306
+ self._nav.blockSignals(False)
307
+ self._stack.setCurrentIndex(idx)
308
+ self.log_search.set_channel(channel_id)
309
+
310
+ def _tick(self) -> None:
311
+ self._refresh_current()
312
+
313
+ def _poll_health(self) -> None:
314
+ """Kick off a health poll off the main thread (the apply runs on the main thread)."""
315
+ if self._health_loading:
316
+ return # a poll is already in flight — don't pile up while the engine is slow/down
317
+ self._health_loading = True
318
+ self._health_runner.submit(self._fetch_health, on_done=self._apply_health)
319
+
320
+ def _fetch_health(self) -> tuple[str, float | None, ApiError | None]:
321
+ """Runs on a worker thread — only blocking I/O. Returns (service_state, free_disk, error)."""
322
+ svc = service_control.service_state(self._service_name)
323
+ try:
324
+ status = self._poll_client.status() # read-only poll client (never the main-thread one)
325
+ except ApiError as exc:
326
+ return svc, None, exc
327
+ return svc, float(status.db.disk_free_bytes), None
328
+
329
+ def _apply_health(self, data: tuple[str, float | None, ApiError | None]) -> None:
330
+ """Drive the nav heart and own the engine-reachability status line (main thread).
331
+
332
+ Service-aware: if the Windows service is installed it is the source of truth — a stopped
333
+ service is red even if a terminal happens to answer the API. With no service installed
334
+ (dev), fall back to API reachability + disk. Reachability also governs the status line:
335
+ when the engine answers we clear any stale 'could not reach engine' error; while it's
336
+ down we show it."""
337
+ self._health_loading = False
338
+ svc, free_disk, exc = data
339
+ reachable = False
340
+ low_disk = False
341
+ if exc is not None:
342
+ if exc.status == 401:
343
+ # Session expired/revoked mid-session — distinct from "engine down". Tell the user and
344
+ # let the entrypoint re-prompt sign-in, not a misleading "Engine unreachable" (M-26).
345
+ self._heart.set_state("red")
346
+ self._heart.setToolTip("Session expired — sign in again")
347
+ self.session_expired.emit()
348
+ return
349
+ self._set_health_error(str(exc))
350
+ else:
351
+ reachable = True
352
+ low_disk = free_disk is not None and free_disk < _LOW_DISK_BYTES
353
+ if reachable:
354
+ self._clear_health_error() # engine answered -> clear our reachability error only
355
+
356
+ if svc == "stopped":
357
+ self._heart.set_state("red")
358
+ self._heart.setToolTip(f"Service '{self._service_name}' is installed but stopped")
359
+ elif not reachable:
360
+ self._heart.set_state("red")
361
+ self._heart.setToolTip("Engine unreachable")
362
+ elif low_disk:
363
+ self._heart.set_state("orange")
364
+ self._heart.setToolTip("Low disk space on the database drive")
365
+ else:
366
+ self._heart.set_state("green")
367
+ self._heart.setToolTip("Engine and database healthy")
368
+
369
+ def _apply_interval(self) -> None:
370
+ if self._interval > 0:
371
+ self._timer.setInterval(int(self._interval * 1000))
372
+ self._timer.start()
373
+ shown = f"<b>{self._interval:g}s</b>"
374
+ else:
375
+ self._timer.stop()
376
+ shown = "<b>off</b>"
377
+ self._refresh_link.setText(f'Auto-refresh: {shown} &nbsp; <a href="#change">change</a>')
378
+
379
+ def _edit_interval(self, _href: str = "") -> None:
380
+ dialog = RefreshSettingsDialog(self._interval, self)
381
+ if dialog.exec():
382
+ self.set_interval(dialog.selected_seconds())
383
+
384
+ def _change_password(self) -> None:
385
+ """Open the change-password dialog; on success route back to sign-in (session is revoked)."""
386
+ dialog = ChangePasswordDialog(self._client, parent=self)
387
+ if dialog.exec():
388
+ self.change_password_requested.emit()
389
+
390
+ def _manage_mfa(self) -> None:
391
+ """Open the two-factor flow: enroll a TOTP authenticator if off, or turn it off if on."""
392
+ manage_mfa(self._client, parent=self)
393
+
394
+ def _active_sessions(self) -> None:
395
+ """Open the self-service active-sessions dialog (it never revokes the current session)."""
396
+ SessionsDialog(self._client, parent=self).exec()
397
+
398
+ def _show_error(self, message: str) -> None:
399
+ self._status.setText(message)
400
+
401
+ def _set_health_error(self, message: str) -> None:
402
+ self._health_error = message
403
+ self._show_error(message)
404
+
405
+ def _clear_health_error(self) -> None:
406
+ # Only clear the status line if it still shows OUR reachability error — a page slot may have
407
+ # set its own error since, and the 5s poll must not wipe it (review low-14).
408
+ if self._health_error and self._status.text() == self._health_error:
409
+ self._show_error("")
410
+ self._health_error = ""