messagefoundry-webconsole 0.2.15__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_webconsole/__init__.py +128 -0
- messagefoundry_webconsole/_auth.py +841 -0
- messagefoundry_webconsole/_html.py +539 -0
- messagefoundry_webconsole/_security.py +253 -0
- messagefoundry_webconsole/_service.py +22 -0
- messagefoundry_webconsole/_static.py +78 -0
- messagefoundry_webconsole/mount.py +103 -0
- messagefoundry_webconsole/pages/__init__.py +25 -0
- messagefoundry_webconsole/pages/_common.py +21 -0
- messagefoundry_webconsole/pages/account.py +771 -0
- messagefoundry_webconsole/pages/admin.py +480 -0
- messagefoundry_webconsole/pages/audit.py +66 -0
- messagefoundry_webconsole/pages/config.py +113 -0
- messagefoundry_webconsole/pages/connections.py +519 -0
- messagefoundry_webconsole/pages/messages.py +689 -0
- messagefoundry_webconsole/pages/monitoring.py +853 -0
- messagefoundry_webconsole/pages/uploaded_logs.py +252 -0
- messagefoundry_webconsole/routes/__init__.py +6 -0
- messagefoundry_webconsole/routes/_common.py +45 -0
- messagefoundry_webconsole/routes/account.py +451 -0
- messagefoundry_webconsole/routes/admin.py +505 -0
- messagefoundry_webconsole/routes/audit.py +39 -0
- messagefoundry_webconsole/routes/config.py +67 -0
- messagefoundry_webconsole/routes/connection_writes.py +248 -0
- messagefoundry_webconsole/routes/core.py +1077 -0
- messagefoundry_webconsole/routes/monitoring.py +94 -0
- messagefoundry_webconsole/routes/monitoring_writes.py +205 -0
- messagefoundry_webconsole/routes/oidc.py +172 -0
- messagefoundry_webconsole/routes/search.py +204 -0
- messagefoundry_webconsole/routes/sso.py +88 -0
- messagefoundry_webconsole/routes/status.py +178 -0
- messagefoundry_webconsole/routes/uploaded_logs.py +181 -0
- messagefoundry_webconsole/static/app.css +345 -0
- messagefoundry_webconsole/static/app.js +1506 -0
- messagefoundry_webconsole/static/csp-probe.js +9 -0
- messagefoundry_webconsole-0.2.15.dist-info/METADATA +63 -0
- messagefoundry_webconsole-0.2.15.dist-info/RECORD +40 -0
- messagefoundry_webconsole-0.2.15.dist-info/WHEEL +4 -0
- messagefoundry_webconsole-0.2.15.dist-info/licenses/LICENSE +662 -0
- messagefoundry_webconsole-0.2.15.dist-info/licenses/NOTICE +31 -0
|
@@ -0,0 +1,853 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""Monitoring-area page builders for the /ui ops dashboard (ADR 0065).
|
|
4
|
+
|
|
5
|
+
Read-only monitoring surfaces (BACKLOG #75 phase 1). Each builder returns escaped :class:`.._html.Markup`
|
|
6
|
+
and reuses the metadata-only JSON handlers (no PHI). A lane adding a page here appends its builder + the
|
|
7
|
+
name in ``__all__`` and registers its nav entry via :func:`.._html.register_nav` co-located below.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import datetime
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
from messagefoundry.api.models import (
|
|
16
|
+
AlertInstanceInfo,
|
|
17
|
+
AlertInstanceList,
|
|
18
|
+
AlertsConfig,
|
|
19
|
+
ClusterNodeList,
|
|
20
|
+
ClusterStatus,
|
|
21
|
+
ConnectionEventInfo,
|
|
22
|
+
DrStatus,
|
|
23
|
+
GraphResponse,
|
|
24
|
+
IntegrityResult,
|
|
25
|
+
MetricsHistoryResponse,
|
|
26
|
+
SecurityPosture,
|
|
27
|
+
ServiceStatusInfo,
|
|
28
|
+
SystemStatus,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
from .._html import Markup, el, page, register_nav, rows_table
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"alerts",
|
|
35
|
+
"events",
|
|
36
|
+
"flow_and_trends",
|
|
37
|
+
"flow_and_trends_fragment",
|
|
38
|
+
"integrity_result",
|
|
39
|
+
"status",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _post_button(action: str, label: str) -> Markup:
|
|
44
|
+
"""A tiny same-origin POST form for one state-changing /ui action (redirect-back on success)."""
|
|
45
|
+
return el(
|
|
46
|
+
"form",
|
|
47
|
+
el("button", label, type="submit"),
|
|
48
|
+
method="post",
|
|
49
|
+
action=action,
|
|
50
|
+
class_="ctl",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _ts(value: float) -> str:
|
|
55
|
+
"""Render an epoch-seconds timestamp as a compact UTC string."""
|
|
56
|
+
return datetime.datetime.fromtimestamp(value, tz=datetime.UTC).strftime("%Y-%m-%d %H:%M:%SZ")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _opt(value: object) -> str:
|
|
60
|
+
"""Render an optional scalar/None as text ('—' for None)."""
|
|
61
|
+
return "—" if value is None else str(value)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _yn(value: bool | None) -> str:
|
|
65
|
+
"""Render a tri-state flag: '—' for None, else 'yes'/'no'."""
|
|
66
|
+
return "—" if value is None else ("yes" if value else "no")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _fips(value: bool | None) -> str:
|
|
70
|
+
"""Render the report-only FIPS-provider attestation (#73). Deliberately worded 'reported', never
|
|
71
|
+
'certified': the boolean attests the interpreter's ssl/_hashlib OpenSSL provider state, not a FIPS-140
|
|
72
|
+
certification (ADR 0120). None = undeterminable (a non-OpenSSL build with no get_fips_mode)."""
|
|
73
|
+
if value is None:
|
|
74
|
+
return "undeterminable"
|
|
75
|
+
return "reported active" if value else "reported inactive"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _memenc(value: bool | None) -> str:
|
|
79
|
+
"""Render one leg of the platform memory-encryption read-out (ADR 0152 Phase 1).
|
|
80
|
+
|
|
81
|
+
Worded "self-reported" in every state, never "enabled"/"compliant"/"verified": the value comes
|
|
82
|
+
from what the host OS says about itself (/proc/cpuinfo flags, guest device presence), and ASVS
|
|
83
|
+
11.7.1 exists because that host may be the adversary. None = undeterminable, which is what
|
|
84
|
+
Windows reports today (the in-guest attestation path is an ADR 0152 spike that has not landed)."""
|
|
85
|
+
if value is None:
|
|
86
|
+
return "undeterminable"
|
|
87
|
+
return "self-reported yes" if value else "self-reported no"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _contradiction(value: bool | None) -> str:
|
|
91
|
+
"""Render the tri-state read-out-vs-declaration check (ADR 0152 rung 2).
|
|
92
|
+
|
|
93
|
+
The three states must never render alike. A bare "no" beside a "yes" declaration reads as
|
|
94
|
+
CORROBORATED, which is exactly the compliance-questionnaire screenshot this row layout exists to
|
|
95
|
+
prevent — and on Windows nothing is ever measured, so a two-state rendering would print that
|
|
96
|
+
reassuring "no" on the primary deployment platform by vacuity. Hence an explicit
|
|
97
|
+
"nothing measured" for None, and wording that names which side did the contradicting."""
|
|
98
|
+
if value is None:
|
|
99
|
+
return "nothing measured that could contradict it"
|
|
100
|
+
return "YES — the read-out contradicts it" if value else "no — the read-out agrees"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _bytes(n: int) -> str:
|
|
104
|
+
"""Render a byte count in a compact binary unit (KiB/MiB/GiB)."""
|
|
105
|
+
size = float(n)
|
|
106
|
+
for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
|
|
107
|
+
if size < 1024 or unit == "TiB":
|
|
108
|
+
return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}"
|
|
109
|
+
size /= 1024
|
|
110
|
+
return f"{n} B" # pragma: no cover - unreachable (loop always returns)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _suspend_form(a: AlertInstanceInfo) -> Markup:
|
|
114
|
+
"""A small same-origin POST form to suspend an alert's NOTIFICATIONS for a chosen window (#143).
|
|
115
|
+
|
|
116
|
+
Notification-only: suspending mutes re-alerts for the window while the instance stays open/counted.
|
|
117
|
+
"""
|
|
118
|
+
return el(
|
|
119
|
+
"form",
|
|
120
|
+
el(
|
|
121
|
+
"select",
|
|
122
|
+
el("option", "15 min", value="15"),
|
|
123
|
+
el("option", "1 hour", value="60", selected=True),
|
|
124
|
+
el("option", "4 hours", value="240"),
|
|
125
|
+
el("option", "24 hours", value="1440"),
|
|
126
|
+
name="minutes",
|
|
127
|
+
),
|
|
128
|
+
el("button", "Suspend", type="submit"),
|
|
129
|
+
method="post",
|
|
130
|
+
action=f"/ui/alerts/{a.id}/suspend",
|
|
131
|
+
class_="ctl",
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _alert_controls(a: AlertInstanceInfo, *, now: float) -> Markup:
|
|
136
|
+
"""Ack (open only) + Resolve + windowed Suspend/Resume (#143) POST forms for one alert instance (L3a).
|
|
137
|
+
|
|
138
|
+
Shown to every viewer; the JSON handlers enforce ``monitoring:diagnose`` on submit (a read-only
|
|
139
|
+
viewer gets a 403), matching the connection-control button convention. Suspend gates NOTIFICATION
|
|
140
|
+
only — a suspended instance stays open/counted/visible; only re-alerts are muted for the window.
|
|
141
|
+
"""
|
|
142
|
+
forms: list[object] = []
|
|
143
|
+
suspended = a.suspended_until is not None and a.suspended_until > now
|
|
144
|
+
if a.status == "open":
|
|
145
|
+
forms.append(_post_button(f"/ui/alerts/{a.id}/ack", "Ack"))
|
|
146
|
+
if a.status in ("open", "acknowledged"):
|
|
147
|
+
forms.append(_post_button(f"/ui/alerts/{a.id}/resolve", "Resolve"))
|
|
148
|
+
if suspended:
|
|
149
|
+
forms.append(_post_button(f"/ui/alerts/{a.id}/resume", "Resume"))
|
|
150
|
+
else:
|
|
151
|
+
forms.append(_suspend_form(a))
|
|
152
|
+
return el("div", *forms, class_="ctls") if forms else Markup("")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def alerts(instances: AlertInstanceList, config: AlertsConfig) -> Markup:
|
|
156
|
+
"""The operator-alerts page: active (open + acknowledged) instances + the loaded rules (ADR 0044/0014).
|
|
157
|
+
|
|
158
|
+
Metadata only — no PHI, no secrets (transports are reported present-or-not by the JSON handler).
|
|
159
|
+
"""
|
|
160
|
+
now = time.time()
|
|
161
|
+
inst_rows = [
|
|
162
|
+
[
|
|
163
|
+
el("span", a.severity, class_=f"sev sev-{a.severity}"),
|
|
164
|
+
el("span", a.status, class_=f"status status-{a.status}"),
|
|
165
|
+
a.event_type,
|
|
166
|
+
a.connection,
|
|
167
|
+
a.count,
|
|
168
|
+
_ts(a.first_seen),
|
|
169
|
+
_ts(a.last_seen),
|
|
170
|
+
a.reason,
|
|
171
|
+
_opt(a.acked_by),
|
|
172
|
+
# #143: show the active NOTIFICATION-mute window end (— when not suspended / already elapsed).
|
|
173
|
+
_ts(a.suspended_until)
|
|
174
|
+
if a.suspended_until is not None and a.suspended_until > now
|
|
175
|
+
else "—",
|
|
176
|
+
_alert_controls(a, now=now),
|
|
177
|
+
]
|
|
178
|
+
for a in instances.alerts
|
|
179
|
+
]
|
|
180
|
+
inst_table = rows_table(
|
|
181
|
+
[
|
|
182
|
+
"Severity",
|
|
183
|
+
"Status",
|
|
184
|
+
"Type",
|
|
185
|
+
"Connection",
|
|
186
|
+
"Count",
|
|
187
|
+
"First seen",
|
|
188
|
+
"Last seen",
|
|
189
|
+
"Reason",
|
|
190
|
+
"Acked by",
|
|
191
|
+
"Suspended",
|
|
192
|
+
"Actions",
|
|
193
|
+
],
|
|
194
|
+
inst_rows,
|
|
195
|
+
)
|
|
196
|
+
empty = el("p", "No active alerts.", class_="muted") if not instances.alerts else Markup("")
|
|
197
|
+
|
|
198
|
+
transports = ", ".join(
|
|
199
|
+
[
|
|
200
|
+
t
|
|
201
|
+
for t, on in (
|
|
202
|
+
("webhook", config.webhook_configured),
|
|
203
|
+
("email", config.email_configured),
|
|
204
|
+
)
|
|
205
|
+
if on
|
|
206
|
+
]
|
|
207
|
+
)
|
|
208
|
+
summary = rows_table(
|
|
209
|
+
["Setting", "Value"],
|
|
210
|
+
[
|
|
211
|
+
["Transports configured", transports or "none"],
|
|
212
|
+
["Email recipients", config.email_recipient_count],
|
|
213
|
+
["Re-alert after", f"{config.realert_seconds:.0f}s"],
|
|
214
|
+
],
|
|
215
|
+
adjustable=False,
|
|
216
|
+
)
|
|
217
|
+
rule_rows = [
|
|
218
|
+
[
|
|
219
|
+
r.event_type,
|
|
220
|
+
r.connection,
|
|
221
|
+
el("span", r.severity, class_=f"sev sev-{r.severity}"),
|
|
222
|
+
_opt(r.min_depth),
|
|
223
|
+
_opt(None if r.min_oldest_seconds is None else f"{r.min_oldest_seconds:.0f}s"),
|
|
224
|
+
_opt(None if not r.transports else ", ".join(r.transports)),
|
|
225
|
+
_opt(None if r.cooldown_seconds is None else f"{r.cooldown_seconds:.0f}s"),
|
|
226
|
+
]
|
|
227
|
+
for r in config.rules
|
|
228
|
+
]
|
|
229
|
+
rules_table = rows_table(
|
|
230
|
+
[
|
|
231
|
+
"Event type",
|
|
232
|
+
"Connection",
|
|
233
|
+
"Severity",
|
|
234
|
+
"Min depth",
|
|
235
|
+
"Min oldest",
|
|
236
|
+
"Transports",
|
|
237
|
+
"Cooldown",
|
|
238
|
+
],
|
|
239
|
+
rule_rows,
|
|
240
|
+
)
|
|
241
|
+
return page(
|
|
242
|
+
"Alerts",
|
|
243
|
+
el("h1", "Alerts"),
|
|
244
|
+
el("h2", "Active"),
|
|
245
|
+
empty,
|
|
246
|
+
inst_table,
|
|
247
|
+
el("h2", "Rules"),
|
|
248
|
+
summary,
|
|
249
|
+
rules_table,
|
|
250
|
+
active="alerts",
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
#: The bounded connection-event vocabulary (mirrors the engine's emit kinds + the desktop
|
|
255
|
+
#: event_log_page.py filter) for the /ui event-log kind dropdown (L6b, #75 parity).
|
|
256
|
+
_EVENT_KINDS = (
|
|
257
|
+
"established",
|
|
258
|
+
"closed",
|
|
259
|
+
"idle_timeout",
|
|
260
|
+
"peer_not_allowlisted",
|
|
261
|
+
"at_capacity",
|
|
262
|
+
"frame_oversize",
|
|
263
|
+
"peer_reset",
|
|
264
|
+
"framing_error",
|
|
265
|
+
"connection_lost",
|
|
266
|
+
"connection_restored",
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _event_filter(connection: str, kind: str = "") -> Markup:
|
|
271
|
+
"""A GET filter form for the event log (reuses the /events connection + kind query params)."""
|
|
272
|
+
options = [el("option", "All kinds", value="")] + [
|
|
273
|
+
el("option", k, value=k, selected=(k == kind) or None) for k in _EVENT_KINDS
|
|
274
|
+
]
|
|
275
|
+
return el(
|
|
276
|
+
"form",
|
|
277
|
+
el("input", name="connection", value=connection or None, placeholder="connection"),
|
|
278
|
+
el("select", *options, name="kind"),
|
|
279
|
+
el("button", "Filter", type="submit"),
|
|
280
|
+
method="get",
|
|
281
|
+
action="/ui/events",
|
|
282
|
+
class_="filters",
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def events(rows: list[ConnectionEventInfo], *, connection: str = "", kind: str = "") -> Markup:
|
|
287
|
+
"""The connection/transport event log (Corepoint-style, #46) — metadata only, newest first."""
|
|
288
|
+
headers = ["When", "Connection", "Transport", "Dir", "Kind", "Peer", "Reason"]
|
|
289
|
+
body = [
|
|
290
|
+
[
|
|
291
|
+
_ts(e.ts),
|
|
292
|
+
e.connection,
|
|
293
|
+
e.transport,
|
|
294
|
+
e.direction,
|
|
295
|
+
el("span", e.kind, class_=f"evt evt-{e.kind}"),
|
|
296
|
+
_opt(e.peer_host),
|
|
297
|
+
e.reason,
|
|
298
|
+
]
|
|
299
|
+
for e in rows
|
|
300
|
+
]
|
|
301
|
+
empty = el("p", "No events.", class_="muted") if not rows else Markup("")
|
|
302
|
+
return page(
|
|
303
|
+
"Events",
|
|
304
|
+
el("h1", "Events"),
|
|
305
|
+
_event_filter(connection, kind),
|
|
306
|
+
empty,
|
|
307
|
+
rows_table(headers, body),
|
|
308
|
+
active="events",
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def status(
|
|
313
|
+
sys: SystemStatus,
|
|
314
|
+
posture: SecurityPosture,
|
|
315
|
+
cluster: ClusterStatus,
|
|
316
|
+
nodes: ClusterNodeList,
|
|
317
|
+
dr: DrStatus,
|
|
318
|
+
service: ServiceStatusInfo,
|
|
319
|
+
) -> Markup:
|
|
320
|
+
"""The engine status page: engine + store metrics, effective security posture, cluster + DR state.
|
|
321
|
+
|
|
322
|
+
Metadata only — no PHI. The security posture carries NO secret material (``key_id`` is a one-way
|
|
323
|
+
fingerprint, ``key_source`` a provider name), so it renders as-is.
|
|
324
|
+
"""
|
|
325
|
+
e = sys.engine
|
|
326
|
+
db = sys.db
|
|
327
|
+
kpi = sys.kpis
|
|
328
|
+
engine_tbl = rows_table(
|
|
329
|
+
["Field", "Value"],
|
|
330
|
+
[
|
|
331
|
+
["Version", e.version],
|
|
332
|
+
["Uptime", f"{e.uptime_seconds:.0f}s"],
|
|
333
|
+
["PID", e.pid],
|
|
334
|
+
[
|
|
335
|
+
"Inbound",
|
|
336
|
+
f"{e.channels_running}/{e.channels_total} running ({e.channels_stopped} stopped)",
|
|
337
|
+
],
|
|
338
|
+
# #93 engine-wide KPI headline: combined inbound+outbound endpoint count + engine-wide
|
|
339
|
+
# msg/s (reusing the recent_done rate window) — the single-glance roll-up no per-connection
|
|
340
|
+
# row gives. Metadata only (counts + a rate), no PHI.
|
|
341
|
+
[
|
|
342
|
+
"Endpoints (in+out)",
|
|
343
|
+
f"{kpi.connections_running}/{kpi.connections_total} running "
|
|
344
|
+
f"({kpi.connections_stopped} stopped)",
|
|
345
|
+
],
|
|
346
|
+
["Messages (total)", kpi.messages_total],
|
|
347
|
+
["Throughput", f"{kpi.messages_per_second:.1f} msg/s"],
|
|
348
|
+
[
|
|
349
|
+
"Outbox by status",
|
|
350
|
+
", ".join(f"{k}: {v}" for k, v in e.outbox_by_status.items()) or "—",
|
|
351
|
+
],
|
|
352
|
+
],
|
|
353
|
+
adjustable=False,
|
|
354
|
+
)
|
|
355
|
+
store_tbl = rows_table(
|
|
356
|
+
["Field", "Value"],
|
|
357
|
+
[
|
|
358
|
+
["Backend", posture.backend],
|
|
359
|
+
["Encryption at rest", _yn(posture.encryption_enabled)],
|
|
360
|
+
["Key source", posture.key_source],
|
|
361
|
+
["Key fingerprint", _opt(posture.key_id)],
|
|
362
|
+
["Data class", _opt(posture.data_class)],
|
|
363
|
+
["Production", _yn(posture.production)],
|
|
364
|
+
["Environment", _opt(posture.environment)],
|
|
365
|
+
# FIPS-provider attestation (report-only, #73 / ADR 0120). Scoped wording: this is the
|
|
366
|
+
# interpreter's ssl/_hashlib OpenSSL, REPORTED — never "FIPS-140 certified", and separate from
|
|
367
|
+
# the cryptography-wheel OpenSSL that encrypts PHI at rest. Metadata only (no secrets). Read via
|
|
368
|
+
# getattr is defensive, NOT cross-seam compat: SUPPORTED_ENGINE_SEAMS holds exactly one
|
|
369
|
+
# seam (BACKLOG #279), so the field is always present on a mountable engine. It is kept
|
|
370
|
+
# because a None/absent value must render as a dash rather than raise mid-page.
|
|
371
|
+
[
|
|
372
|
+
"FIPS mode (ssl/_hashlib OpenSSL, reported)",
|
|
373
|
+
_fips(getattr(posture, "fips_mode", None)),
|
|
374
|
+
],
|
|
375
|
+
["OpenSSL version (ssl/_hashlib)", _opt(getattr(posture, "openssl_version", None))],
|
|
376
|
+
# Platform memory-encryption read-out (report-only, ADR 0152 Phase 1 / ASVS 11.7.1).
|
|
377
|
+
# Wording is a security property here: every label says "self-reported", and capability
|
|
378
|
+
# ("this silicon can") is a SEPARATE row from activation ("this guest is"), because a
|
|
379
|
+
# single fused "memory encryption: yes" row is exactly what would get screenshotted into
|
|
380
|
+
# a compliance questionnaire. The operator's declaration is labelled as an unverified
|
|
381
|
+
# claim (never "attestation" — that word means a CPU-signed quote here, which is not
|
|
382
|
+
# built), the read-out-vs-declaration check renders its three states distinctly, and the
|
|
383
|
+
# engine's own disclaimer sentence is rendered verbatim so the screenshot carries it.
|
|
384
|
+
[
|
|
385
|
+
"Memory encryption — CPU capability (self-reported)",
|
|
386
|
+
_memenc(getattr(posture, "memory_encryption_self_reported_capability", None)),
|
|
387
|
+
],
|
|
388
|
+
[
|
|
389
|
+
"Memory encryption — this guest (self-reported, NOT attestation)",
|
|
390
|
+
_memenc(getattr(posture, "memory_encryption_self_reported_active", None)),
|
|
391
|
+
],
|
|
392
|
+
[
|
|
393
|
+
"Memory encryption — mechanism / read-out source",
|
|
394
|
+
f"{_opt(getattr(posture, 'memory_encryption_self_reported_mechanism', None))}"
|
|
395
|
+
f" / {_opt(getattr(posture, 'memory_encryption_readout_source', None))}",
|
|
396
|
+
],
|
|
397
|
+
[
|
|
398
|
+
"Memory encryption — operator declaration (unverified, not attestation)",
|
|
399
|
+
_yn(getattr(posture, "memory_encryption_operator_declared", False)),
|
|
400
|
+
],
|
|
401
|
+
[
|
|
402
|
+
"Memory encryption — read-out vs that declaration",
|
|
403
|
+
_contradiction(
|
|
404
|
+
getattr(posture, "memory_encryption_readout_contradicts_declaration", None)
|
|
405
|
+
),
|
|
406
|
+
],
|
|
407
|
+
[
|
|
408
|
+
"Memory encryption — what this does and does not mean",
|
|
409
|
+
_opt(getattr(posture, "memory_encryption_note", None)),
|
|
410
|
+
],
|
|
411
|
+
["Path", db.path],
|
|
412
|
+
["Size", _bytes(db.size_bytes)],
|
|
413
|
+
["Disk free", _bytes(db.disk_free_bytes)],
|
|
414
|
+
["Journal mode", db.journal_mode],
|
|
415
|
+
["Messages", db.messages],
|
|
416
|
+
["Events", db.events],
|
|
417
|
+
["Audit rows", db.audit],
|
|
418
|
+
],
|
|
419
|
+
adjustable=False,
|
|
420
|
+
)
|
|
421
|
+
# ADR 0118: the effective [security] posture — the plain-language switches, any active loosenings,
|
|
422
|
+
# and the synthetic-relaxation notice. Read-only (the IDE is the sole authoring surface); booleans/
|
|
423
|
+
# ints only, no secret material.
|
|
424
|
+
sec = posture.security
|
|
425
|
+
|
|
426
|
+
def _sec(key: str, none_label: str = "—") -> object:
|
|
427
|
+
v = sec.get(key)
|
|
428
|
+
if v is None:
|
|
429
|
+
return none_label
|
|
430
|
+
if isinstance(v, bool):
|
|
431
|
+
return _yn(v)
|
|
432
|
+
return str(v)
|
|
433
|
+
|
|
434
|
+
security_tbl = rows_table(
|
|
435
|
+
["Setting", "Value"],
|
|
436
|
+
[
|
|
437
|
+
["Local access only", _sec("local_access_only")],
|
|
438
|
+
["Require encryption for remote", _sec("require_encryption_for_remote")],
|
|
439
|
+
["Serve web console", _sec("serve_web_console")],
|
|
440
|
+
["Encrypt stored data", _sec("encrypt_stored_data")],
|
|
441
|
+
["Allow unencrypted PHI", _sec("allow_unencrypted_phi")],
|
|
442
|
+
["Require sign-in", _sec("require_sign_in")],
|
|
443
|
+
["Require MFA", _sec("require_mfa")],
|
|
444
|
+
["Sign out after idle (min)", _sec("sign_out_after_idle_minutes")],
|
|
445
|
+
["Max session (hours)", _sec("max_session_hours")],
|
|
446
|
+
["Block unlisted outbound", _sec("block_unlisted_outbound")],
|
|
447
|
+
["Delete message bodies after (days)", _sec("delete_message_bodies_after_days")],
|
|
448
|
+
["Allow keeping PHI indefinitely", _sec("allow_keeping_phi_indefinitely")],
|
|
449
|
+
["Audit all authz decisions", _sec("audit_all_authorization_decisions")],
|
|
450
|
+
[
|
|
451
|
+
"Handles real patient data",
|
|
452
|
+
_sec("handles_real_patient_data", "(derived from environment)"),
|
|
453
|
+
],
|
|
454
|
+
["Production instance", _sec("production_instance", "(derived from environment)")],
|
|
455
|
+
],
|
|
456
|
+
adjustable=False,
|
|
457
|
+
)
|
|
458
|
+
security_section: list[object] = [el("h2", "Security posture"), security_tbl]
|
|
459
|
+
if posture.synthetic_relaxation:
|
|
460
|
+
security_section.append(el("p", posture.synthetic_relaxation, class_="muted"))
|
|
461
|
+
if posture.loosenings:
|
|
462
|
+
security_section.append(
|
|
463
|
+
el("p", "Protections loosened from the secure defaults:", class_="banner")
|
|
464
|
+
)
|
|
465
|
+
security_section.append(
|
|
466
|
+
el(
|
|
467
|
+
"ul",
|
|
468
|
+
*[el("li", f"{lo.switch} — {lo.risk}") for lo in posture.loosenings],
|
|
469
|
+
)
|
|
470
|
+
)
|
|
471
|
+
cluster_tbl = rows_table(
|
|
472
|
+
["Field", "Value"],
|
|
473
|
+
[
|
|
474
|
+
["Role", cluster.role],
|
|
475
|
+
["Clustered", _yn(cluster.clustered)],
|
|
476
|
+
["This node is leader", _yn(cluster.is_leader)],
|
|
477
|
+
["Node id", cluster.node_id],
|
|
478
|
+
["Config version", cluster.config_version],
|
|
479
|
+
["Leader", _opt(nodes.leader_node_id)],
|
|
480
|
+
["Lease owner", _opt(nodes.lease_owner)],
|
|
481
|
+
],
|
|
482
|
+
adjustable=False,
|
|
483
|
+
)
|
|
484
|
+
node_rows = [
|
|
485
|
+
[
|
|
486
|
+
n.node_id,
|
|
487
|
+
_opt(n.host),
|
|
488
|
+
_opt(n.pid),
|
|
489
|
+
n.status,
|
|
490
|
+
_yn(n.is_leader),
|
|
491
|
+
_ts(n.last_seen) if n.last_seen is not None else "—",
|
|
492
|
+
]
|
|
493
|
+
for n in nodes.nodes
|
|
494
|
+
]
|
|
495
|
+
node_tbl = rows_table(
|
|
496
|
+
["Node", "Host", "PID", "Status", "Leader", "Last seen"], node_rows, adjustable=False
|
|
497
|
+
)
|
|
498
|
+
dr_tbl = rows_table(
|
|
499
|
+
["Field", "Value"],
|
|
500
|
+
[
|
|
501
|
+
["DR box", _yn(dr.enabled)],
|
|
502
|
+
["Active", _yn(dr.active)],
|
|
503
|
+
["Threshold", dr.threshold],
|
|
504
|
+
["Activation", dr.activation_mode],
|
|
505
|
+
],
|
|
506
|
+
adjustable=False,
|
|
507
|
+
)
|
|
508
|
+
dr_actions: list[object] = []
|
|
509
|
+
if dr.active:
|
|
510
|
+
dr_actions.append(_post_button("/ui/dr/release", "Release DR"))
|
|
511
|
+
elif dr.enabled:
|
|
512
|
+
dr_actions.append(_post_button("/ui/dr/activate", "Activate DR"))
|
|
513
|
+
actions = el(
|
|
514
|
+
"div",
|
|
515
|
+
_post_button("/ui/statistics/reset", "Reset statistics"),
|
|
516
|
+
_post_button("/ui/status/integrity-check", "Run integrity check"),
|
|
517
|
+
*dr_actions,
|
|
518
|
+
class_="ctls",
|
|
519
|
+
)
|
|
520
|
+
# Hosting-service (NSSM) badge (L6a) — only meaningful when [service].report_status is on; a
|
|
521
|
+
# good/warn/bad class drives the badge colour. Read-only (no control buttons — restart is cut).
|
|
522
|
+
_SERVICE_CLASS = {"running": "ok", "stopped": "error", "not_installed": "error"}
|
|
523
|
+
service_section: list[object] = [el("h2", "Hosting service")]
|
|
524
|
+
if not service.enabled:
|
|
525
|
+
service_section.append(
|
|
526
|
+
el("p", "Service-status reporting is off ([service].report_status).", class_="muted")
|
|
527
|
+
)
|
|
528
|
+
else:
|
|
529
|
+
badge = el(
|
|
530
|
+
"span",
|
|
531
|
+
service.state,
|
|
532
|
+
class_=f"status status-{_SERVICE_CLASS.get(service.state, 'warn')}",
|
|
533
|
+
)
|
|
534
|
+
service_section.append(
|
|
535
|
+
rows_table(
|
|
536
|
+
["Field", "Value"],
|
|
537
|
+
[["Service", _opt(service.service_name)], ["State", badge]],
|
|
538
|
+
adjustable=False,
|
|
539
|
+
)
|
|
540
|
+
)
|
|
541
|
+
# L6b (#75 parity): the no-network update-available signal (#30, ADR 0026) — the desktop shows
|
|
542
|
+
# it as a persistent banner; the web renders a prominent line on the status page. Present only
|
|
543
|
+
# when [update_check] is enabled and a newer version was found (version strings only, no PHI).
|
|
544
|
+
update_banner: Markup = Markup("")
|
|
545
|
+
if sys.update is not None and sys.update.update_available:
|
|
546
|
+
update_banner = el(
|
|
547
|
+
"p",
|
|
548
|
+
"A newer MessageFoundry version is installed — running "
|
|
549
|
+
f"{sys.update.current_version}; {sys.update.pinned_version or '(newer)'} is installed. "
|
|
550
|
+
"Restart the engine to apply.",
|
|
551
|
+
class_="banner",
|
|
552
|
+
)
|
|
553
|
+
return page(
|
|
554
|
+
"Status",
|
|
555
|
+
el("h1", "Status"),
|
|
556
|
+
update_banner,
|
|
557
|
+
el("h2", "Engine"),
|
|
558
|
+
engine_tbl,
|
|
559
|
+
el("h2", "Store"),
|
|
560
|
+
store_tbl,
|
|
561
|
+
*security_section,
|
|
562
|
+
el("h2", "Cluster"),
|
|
563
|
+
cluster_tbl,
|
|
564
|
+
node_tbl,
|
|
565
|
+
el("h2", "Disaster recovery"),
|
|
566
|
+
dr_tbl,
|
|
567
|
+
*service_section,
|
|
568
|
+
el("h2", "Actions"),
|
|
569
|
+
actions,
|
|
570
|
+
active="status",
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def integrity_result(result: IntegrityResult) -> Markup:
|
|
575
|
+
"""The outcome of an on-demand DB integrity check (L3a) — ok/failed + the detail, escaped."""
|
|
576
|
+
verdict = el(
|
|
577
|
+
"span",
|
|
578
|
+
"OK" if result.ok else "FAILED",
|
|
579
|
+
class_=f"status status-{'ok' if result.ok else 'error'}",
|
|
580
|
+
)
|
|
581
|
+
body = el(
|
|
582
|
+
"div",
|
|
583
|
+
el("h1", "Integrity check"),
|
|
584
|
+
el("p", verdict),
|
|
585
|
+
el("pre", result.detail, class_="raw"),
|
|
586
|
+
el("p", el("a", "← Status", href="/ui/status")),
|
|
587
|
+
class_="card",
|
|
588
|
+
)
|
|
589
|
+
return page("Integrity check", body, active="status")
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
# --- Flow & trends (BACKLOG #76, ADR 0065 amendment) -----------------------------------------------
|
|
593
|
+
# The status-colored by-name data-flow graph + historical queue-trend charts, both rendered as INLINE
|
|
594
|
+
# SVG server-side (CSP script-src 'self'; no chart-library CDN) from the read-only monitoring:read
|
|
595
|
+
# endpoints GET /graph/edges + GET /metrics/history. Every dynamic value (connection name, status, count)
|
|
596
|
+
# is placed through the escaping ``el`` builder, so a hostile connection name renders inert. Metadata
|
|
597
|
+
# only — no message body. The graph is the by-name Registry edge set: there is NO channel/route object.
|
|
598
|
+
|
|
599
|
+
#: The graph's column order + display label per node kind (inbound → router → handler → outbound), the
|
|
600
|
+
#: visual left-to-right message-flow direction.
|
|
601
|
+
_GRAPH_COLUMNS: tuple[tuple[str, str], ...] = (
|
|
602
|
+
("inbound", "Inbound"),
|
|
603
|
+
("router", "Routers"),
|
|
604
|
+
("handler", "Handlers"),
|
|
605
|
+
("outbound", "Outbound"),
|
|
606
|
+
)
|
|
607
|
+
_NODE_W = 150
|
|
608
|
+
_NODE_H = 30
|
|
609
|
+
_ROW_GAP = 46
|
|
610
|
+
_COL_GAP = 210
|
|
611
|
+
_MARGIN = 12
|
|
612
|
+
_HEAD_Y = 24
|
|
613
|
+
_TOP = 40
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _short(name: str, limit: int = 20) -> str:
|
|
617
|
+
"""Truncate a long connection/element name for the fixed-width node box (the full name rides an SVG
|
|
618
|
+
``<title>`` tooltip). Escaping happens in ``el`` — this only bounds the visible length."""
|
|
619
|
+
return name if len(name) <= limit else name[: limit - 1] + "…"
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def _flow_graph(graph: GraphResponse) -> Markup:
|
|
623
|
+
"""The status-colored by-name data-flow graph as inline SVG (a layered inbound→router→handler→
|
|
624
|
+
outbound layout). Node colour is CSS-driven off the live ``status`` class (theme tokens), never an
|
|
625
|
+
operator-assigned colour. Edges are lines; a ``heuristic``-provenance edge renders dashed."""
|
|
626
|
+
if not graph.nodes:
|
|
627
|
+
return el("p", "No connections in the graph.", class_="muted")
|
|
628
|
+
columns: dict[str, list[str]] = {kind: [] for kind, _label in _GRAPH_COLUMNS}
|
|
629
|
+
status_by: dict[tuple[str, str], str | None] = {}
|
|
630
|
+
for node in graph.nodes:
|
|
631
|
+
if node.kind in columns:
|
|
632
|
+
columns[node.kind].append(node.name)
|
|
633
|
+
status_by[(node.kind, node.name)] = node.status
|
|
634
|
+
for names in columns.values():
|
|
635
|
+
names.sort()
|
|
636
|
+
|
|
637
|
+
pos: dict[tuple[str, str], tuple[int, int]] = {}
|
|
638
|
+
for ci, (kind, _label) in enumerate(_GRAPH_COLUMNS):
|
|
639
|
+
x = _MARGIN + ci * _COL_GAP
|
|
640
|
+
for ri, name in enumerate(columns[kind]):
|
|
641
|
+
pos[(kind, name)] = (x, _TOP + ri * _ROW_GAP)
|
|
642
|
+
|
|
643
|
+
max_rows = max((len(names) for names in columns.values()), default=1)
|
|
644
|
+
width = _MARGIN + (len(_GRAPH_COLUMNS) - 1) * _COL_GAP + _NODE_W + _MARGIN
|
|
645
|
+
height = _TOP + max(1, max_rows) * _ROW_GAP + _MARGIN
|
|
646
|
+
|
|
647
|
+
parts: list[object] = []
|
|
648
|
+
for ci, (_kind, label) in enumerate(_GRAPH_COLUMNS):
|
|
649
|
+
parts.append(el("text", label, x=_MARGIN + ci * _COL_GAP, y=_HEAD_Y, class_="gcolhead"))
|
|
650
|
+
# Edges first (drawn behind the node boxes). A target the static extractor couldn't resolve simply
|
|
651
|
+
# has no edge here (its source is flagged dynamic separately) — never a fabricated line.
|
|
652
|
+
for edge in graph.edges:
|
|
653
|
+
s = pos.get((edge.source_kind, edge.source))
|
|
654
|
+
t = pos.get((edge.target_kind, edge.target))
|
|
655
|
+
if s is None or t is None:
|
|
656
|
+
continue
|
|
657
|
+
parts.append(
|
|
658
|
+
el(
|
|
659
|
+
"line",
|
|
660
|
+
x1=s[0] + _NODE_W,
|
|
661
|
+
y1=s[1] + _NODE_H // 2,
|
|
662
|
+
x2=t[0],
|
|
663
|
+
y2=t[1] + _NODE_H // 2,
|
|
664
|
+
class_=f"gedge gedge-{edge.provenance}",
|
|
665
|
+
)
|
|
666
|
+
)
|
|
667
|
+
# Node boxes, coloured by live status class (router/handler nodes have no status → the neutral
|
|
668
|
+
# "logic" class). The full name + status ride an SVG <title> tooltip.
|
|
669
|
+
for kind, _label in _GRAPH_COLUMNS:
|
|
670
|
+
for name in columns[kind]:
|
|
671
|
+
x, y = pos[(kind, name)]
|
|
672
|
+
status = status_by.get((kind, name))
|
|
673
|
+
cls = f"gnode gnode-{status}" if status else "gnode gnode-logic"
|
|
674
|
+
tip = f"{name} — {status}" if status else name
|
|
675
|
+
parts.append(
|
|
676
|
+
el(
|
|
677
|
+
"g",
|
|
678
|
+
el("title", tip),
|
|
679
|
+
el("rect", x=x, y=y, width=_NODE_W, height=_NODE_H, rx=5, class_=cls),
|
|
680
|
+
el(
|
|
681
|
+
"text",
|
|
682
|
+
_short(name),
|
|
683
|
+
x=x + 8,
|
|
684
|
+
y=y + _NODE_H // 2 + 4,
|
|
685
|
+
class_="gnodelabel",
|
|
686
|
+
),
|
|
687
|
+
)
|
|
688
|
+
)
|
|
689
|
+
svg = el(
|
|
690
|
+
"svg",
|
|
691
|
+
*parts,
|
|
692
|
+
xmlns="http://www.w3.org/2000/svg",
|
|
693
|
+
viewBox=f"0 0 {width} {height}",
|
|
694
|
+
width=width,
|
|
695
|
+
height=height,
|
|
696
|
+
class_="flowgraph",
|
|
697
|
+
role="img",
|
|
698
|
+
aria_label="Status-colored data-flow graph",
|
|
699
|
+
)
|
|
700
|
+
dynamic_note: Markup = Markup("")
|
|
701
|
+
if graph.dynamic:
|
|
702
|
+
# AC-3 (config.graph): an element whose full target set isn't statically resolvable — surfaced,
|
|
703
|
+
# never silently dropped, so an operator knows the drawn edges may be incomplete for it.
|
|
704
|
+
dynamic_note = el(
|
|
705
|
+
"p",
|
|
706
|
+
"Some elements route dynamically (computed targets) — their edges may be incomplete: "
|
|
707
|
+
+ ", ".join(sorted(graph.dynamic)),
|
|
708
|
+
class_="muted",
|
|
709
|
+
)
|
|
710
|
+
return el("div", el("div", svg, class_="flowgraph-wrap"), dynamic_note)
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def _hms(ts: float) -> str:
|
|
714
|
+
"""A compact HH:MM:SS UTC label for the trend chart's time axis."""
|
|
715
|
+
return datetime.datetime.fromtimestamp(ts, tz=datetime.UTC).strftime("%H:%M:%S")
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
#: Max overlaid series on the trend chart (colour classes series-0..series-5 in app.css).
|
|
719
|
+
_MAX_SERIES = 6
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def _trend_chart(history: MetricsHistoryResponse) -> Markup:
|
|
723
|
+
"""The historical queue-by-status trend as an inline-SVG multi-line chart (one polyline per outbound
|
|
724
|
+
status). Counts only — no PHI. Renders a friendly notice until at least two samples have accrued
|
|
725
|
+
(the ring fills while the Connections dashboard's ~1s /ws/stats socket is open)."""
|
|
726
|
+
samples = history.samples
|
|
727
|
+
if len(samples) < 2:
|
|
728
|
+
return el(
|
|
729
|
+
"p",
|
|
730
|
+
"No trend data yet — keep the Connections dashboard open to accumulate history.",
|
|
731
|
+
class_="muted",
|
|
732
|
+
)
|
|
733
|
+
keys = sorted({k for s in samples for k in s.outbox_by_status})[:_MAX_SERIES]
|
|
734
|
+
if not keys:
|
|
735
|
+
return el("p", "No queued outbound activity has been recorded yet.", class_="muted")
|
|
736
|
+
|
|
737
|
+
width, height = 720, 200
|
|
738
|
+
pad_l, pad_r, pad_t, pad_b = 42, 12, 12, 26
|
|
739
|
+
plot_w = width - pad_l - pad_r
|
|
740
|
+
plot_h = height - pad_t - pad_b
|
|
741
|
+
n = len(samples)
|
|
742
|
+
maxv = max(
|
|
743
|
+
(max((s.outbox_by_status.get(k, 0) for k in keys), default=0) for s in samples),
|
|
744
|
+
default=0,
|
|
745
|
+
)
|
|
746
|
+
maxv = max(maxv, 1)
|
|
747
|
+
|
|
748
|
+
def sx(i: int) -> float:
|
|
749
|
+
return pad_l + (plot_w * i / (n - 1))
|
|
750
|
+
|
|
751
|
+
def sy(v: int) -> float:
|
|
752
|
+
return pad_t + plot_h - (plot_h * v / maxv)
|
|
753
|
+
|
|
754
|
+
parts: list[object] = [
|
|
755
|
+
el("line", x1=pad_l, y1=pad_t, x2=pad_l, y2=pad_t + plot_h, class_="axis"),
|
|
756
|
+
el(
|
|
757
|
+
"line", x1=pad_l, y1=pad_t + plot_h, x2=pad_l + plot_w, y2=pad_t + plot_h, class_="axis"
|
|
758
|
+
),
|
|
759
|
+
el("text", "0", x=pad_l - 6, y=pad_t + plot_h, class_="axislabel", text_anchor="end"),
|
|
760
|
+
el("text", str(maxv), x=pad_l - 6, y=pad_t + 8, class_="axislabel", text_anchor="end"),
|
|
761
|
+
el(
|
|
762
|
+
"text",
|
|
763
|
+
_hms(samples[0].ts),
|
|
764
|
+
x=pad_l,
|
|
765
|
+
y=height - 8,
|
|
766
|
+
class_="axislabel",
|
|
767
|
+
text_anchor="start",
|
|
768
|
+
),
|
|
769
|
+
el(
|
|
770
|
+
"text",
|
|
771
|
+
_hms(samples[-1].ts),
|
|
772
|
+
x=pad_l + plot_w,
|
|
773
|
+
y=height - 8,
|
|
774
|
+
class_="axislabel",
|
|
775
|
+
text_anchor="end",
|
|
776
|
+
),
|
|
777
|
+
]
|
|
778
|
+
for si, key in enumerate(keys):
|
|
779
|
+
points = " ".join(
|
|
780
|
+
f"{sx(i):.1f},{sy(s.outbox_by_status.get(key, 0)):.1f}" for i, s in enumerate(samples)
|
|
781
|
+
)
|
|
782
|
+
parts.append(el("polyline", points=points, fill="none", class_=f"series series-{si}"))
|
|
783
|
+
svg = el(
|
|
784
|
+
"svg",
|
|
785
|
+
*parts,
|
|
786
|
+
xmlns="http://www.w3.org/2000/svg",
|
|
787
|
+
viewBox=f"0 0 {width} {height}",
|
|
788
|
+
width=width,
|
|
789
|
+
height=height,
|
|
790
|
+
class_="trendchart",
|
|
791
|
+
role="img",
|
|
792
|
+
aria_label="Queue-by-status trend chart",
|
|
793
|
+
)
|
|
794
|
+
legend = el(
|
|
795
|
+
"div",
|
|
796
|
+
*[
|
|
797
|
+
el("span", el("span", "", class_=f"swatch series-{si}"), key, class_="legend-item")
|
|
798
|
+
for si, key in enumerate(keys)
|
|
799
|
+
],
|
|
800
|
+
class_="chart-legend",
|
|
801
|
+
)
|
|
802
|
+
return el("div", el("div", svg, class_="trendchart-wrap"), legend)
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
def _flow_and_trends_body(graph: GraphResponse, history: MetricsHistoryResponse) -> Markup:
|
|
806
|
+
"""The live-refreshed inner body (graph + chart) — the poll target ``app.js`` swaps in."""
|
|
807
|
+
return el(
|
|
808
|
+
"div",
|
|
809
|
+
el("h2", "Data-flow graph"),
|
|
810
|
+
_flow_graph(graph),
|
|
811
|
+
el("h2", "Queue trend"),
|
|
812
|
+
_trend_chart(history),
|
|
813
|
+
id="mon-live",
|
|
814
|
+
)
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
def flow_and_trends_fragment(graph: GraphResponse, history: MetricsHistoryResponse) -> Markup:
|
|
818
|
+
"""Just the graph + chart body — fetched by ``app.js`` from ``/ui/monitoring/live`` on an interval."""
|
|
819
|
+
return _flow_and_trends_body(graph, history)
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
def flow_and_trends(graph: GraphResponse, history: MetricsHistoryResponse) -> Markup:
|
|
823
|
+
"""The Flow & trends page (BACKLOG #76): the status-colored data-flow graph over the by-name Registry
|
|
824
|
+
edges + the historical queue-trend chart, both inline SVG. The ``[data-mf-fragment]`` container is
|
|
825
|
+
refreshed by ``app.js`` from ``/ui/monitoring/live`` (server-rendered, already-escaped) so the graph's
|
|
826
|
+
status colours + the trend stay live without a WebSocket."""
|
|
827
|
+
live = el(
|
|
828
|
+
"div",
|
|
829
|
+
_flow_and_trends_body(graph, history),
|
|
830
|
+
data_mf_fragment=True,
|
|
831
|
+
data_fragment_url="/ui/monitoring/live",
|
|
832
|
+
data_fragment_ms="5000",
|
|
833
|
+
)
|
|
834
|
+
return page(
|
|
835
|
+
"Flow & trends",
|
|
836
|
+
el("h1", "Flow & trends"),
|
|
837
|
+
el(
|
|
838
|
+
"p",
|
|
839
|
+
"Live status-colored data-flow graph over the wired connections, plus the historical "
|
|
840
|
+
"queue-by-status trend. Metadata only — no message content.",
|
|
841
|
+
class_="muted",
|
|
842
|
+
),
|
|
843
|
+
live,
|
|
844
|
+
active="flow",
|
|
845
|
+
)
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
# Nav registration (append-at-tail; core order preserved). Co-located with the builders so this lane
|
|
849
|
+
# never edits the central nav literal (ADR 0065 §multi-session-build).
|
|
850
|
+
register_nav("status", "/ui/status", "Status")
|
|
851
|
+
register_nav("alerts", "/ui/alerts", "Alerts")
|
|
852
|
+
register_nav("events", "/ui/events", "Events")
|
|
853
|
+
register_nav("flow", "/ui/monitoring", "Flow & trends")
|