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.
Files changed (40) hide show
  1. messagefoundry_webconsole/__init__.py +128 -0
  2. messagefoundry_webconsole/_auth.py +841 -0
  3. messagefoundry_webconsole/_html.py +539 -0
  4. messagefoundry_webconsole/_security.py +253 -0
  5. messagefoundry_webconsole/_service.py +22 -0
  6. messagefoundry_webconsole/_static.py +78 -0
  7. messagefoundry_webconsole/mount.py +103 -0
  8. messagefoundry_webconsole/pages/__init__.py +25 -0
  9. messagefoundry_webconsole/pages/_common.py +21 -0
  10. messagefoundry_webconsole/pages/account.py +771 -0
  11. messagefoundry_webconsole/pages/admin.py +480 -0
  12. messagefoundry_webconsole/pages/audit.py +66 -0
  13. messagefoundry_webconsole/pages/config.py +113 -0
  14. messagefoundry_webconsole/pages/connections.py +519 -0
  15. messagefoundry_webconsole/pages/messages.py +689 -0
  16. messagefoundry_webconsole/pages/monitoring.py +853 -0
  17. messagefoundry_webconsole/pages/uploaded_logs.py +252 -0
  18. messagefoundry_webconsole/routes/__init__.py +6 -0
  19. messagefoundry_webconsole/routes/_common.py +45 -0
  20. messagefoundry_webconsole/routes/account.py +451 -0
  21. messagefoundry_webconsole/routes/admin.py +505 -0
  22. messagefoundry_webconsole/routes/audit.py +39 -0
  23. messagefoundry_webconsole/routes/config.py +67 -0
  24. messagefoundry_webconsole/routes/connection_writes.py +248 -0
  25. messagefoundry_webconsole/routes/core.py +1077 -0
  26. messagefoundry_webconsole/routes/monitoring.py +94 -0
  27. messagefoundry_webconsole/routes/monitoring_writes.py +205 -0
  28. messagefoundry_webconsole/routes/oidc.py +172 -0
  29. messagefoundry_webconsole/routes/search.py +204 -0
  30. messagefoundry_webconsole/routes/sso.py +88 -0
  31. messagefoundry_webconsole/routes/status.py +178 -0
  32. messagefoundry_webconsole/routes/uploaded_logs.py +181 -0
  33. messagefoundry_webconsole/static/app.css +345 -0
  34. messagefoundry_webconsole/static/app.js +1506 -0
  35. messagefoundry_webconsole/static/csp-probe.js +9 -0
  36. messagefoundry_webconsole-0.2.15.dist-info/METADATA +63 -0
  37. messagefoundry_webconsole-0.2.15.dist-info/RECORD +40 -0
  38. messagefoundry_webconsole-0.2.15.dist-info/WHEEL +4 -0
  39. messagefoundry_webconsole-0.2.15.dist-info/licenses/LICENSE +662 -0
  40. 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")