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,689 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """Messages-area page builders for the /ui ops dashboard (ADR 0065).
4
+
5
+ The message log + single-message detail (with the AUDITED raw body), the HL7 parse tree, and the
6
+ dead-letter list + bulk replay. Every dynamic value — including attacker-influenced HL7/message
7
+ content — is placed through the escaping element builders in :mod:`.._html`, so it can never inject
8
+ markup.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any
14
+
15
+ from messagefoundry.api.models import (
16
+ DeadLetterList,
17
+ MessageDetail,
18
+ MessageList,
19
+ MessageSearchResults,
20
+ )
21
+ from messagefoundry.parsing.tree import TreeNode
22
+
23
+ from .._html import Markup, el, page, rows_table, text
24
+
25
+ __all__ = [
26
+ "dead_letter_pending",
27
+ "dead_letters",
28
+ "message_detail",
29
+ "message_edit",
30
+ "message_search",
31
+ "messages",
32
+ "parse_tree_page",
33
+ "parse_tree_unavailable",
34
+ ]
35
+
36
+
37
+ def _msg_filters(
38
+ channel_id: str,
39
+ status: str,
40
+ message_type: str,
41
+ control_id: str,
42
+ received_from: str,
43
+ received_to: str,
44
+ ) -> Markup:
45
+ """A GET filter form for the message log (reuses the /messages query params). ``received_from``/
46
+ ``received_to`` are ``datetime-local`` values (UTC); submitting runs the search (no ``defer``)."""
47
+ return el(
48
+ "form",
49
+ el("input", name="channel_id", value=channel_id or None, placeholder="channel"),
50
+ el("input", name="status", value=status or None, placeholder="status"),
51
+ el("input", name="message_type", value=message_type or None, placeholder="type"),
52
+ el("input", name="control_id", value=control_id or None, placeholder="control id"),
53
+ el(
54
+ "label",
55
+ "From",
56
+ el("input", name="received_from", type="datetime-local", value=received_from or None),
57
+ class_="dtfield",
58
+ ),
59
+ el(
60
+ "label",
61
+ "To",
62
+ el("input", name="received_to", type="datetime-local", value=received_to or None),
63
+ class_="dtfield",
64
+ ),
65
+ el("button", "Search", type="submit"),
66
+ el("span", "(times UTC)", class_="muted"),
67
+ # Content search (/ui/messages/search) is step-up-gated (bulk PHI decrypt) — a separate page.
68
+ el("a", "Content search →", href="/ui/messages/search", class_="muted"),
69
+ method="get",
70
+ action="/ui/messages",
71
+ class_="filters",
72
+ )
73
+
74
+
75
+ def messages(
76
+ data: MessageList | None,
77
+ *,
78
+ deferred: bool = False,
79
+ channel_id: str = "",
80
+ status: str = "",
81
+ message_type: str = "",
82
+ control_id: str = "",
83
+ received_from: str = "",
84
+ received_to: str = "",
85
+ ) -> Markup:
86
+ """The message log (list of summaries; each summary is view_summary-redacted server-side).
87
+
88
+ ``deferred`` (or ``data is None``) renders the pre-filled filter form WITHOUT running a query — the
89
+ "open a connection's messages, adjust, then Search" landing (#4b). Otherwise the results table + pager
90
+ render as usual."""
91
+ filters = _msg_filters(channel_id, status, message_type, control_id, received_from, received_to)
92
+ if deferred or data is None:
93
+ return page(
94
+ "Messages",
95
+ el("h1", "Messages"),
96
+ filters,
97
+ el("p", "Adjust the filters and click Search to run.", class_="muted"),
98
+ active="messages",
99
+ )
100
+ headers = ["Received", "Channel", "Type", "Status", "Control ID", "Summary"]
101
+ body = [
102
+ [
103
+ m.received_at,
104
+ m.channel_id,
105
+ m.message_type,
106
+ el("span", m.status, class_=f"status status-{m.status}"),
107
+ m.control_id,
108
+ # A link to the audited detail view; the summary text is escaped by the builder.
109
+ el("a", m.summary or "(view)", href=f"/ui/messages/{m.id}"),
110
+ ]
111
+ for m in data.messages
112
+ ]
113
+ pager = el(
114
+ "p",
115
+ text(f"{len(data.messages)} of {data.total} (offset {data.offset})"),
116
+ class_="pager",
117
+ )
118
+ return page(
119
+ "Messages",
120
+ el("h1", "Messages"),
121
+ filters,
122
+ rows_table(headers, body),
123
+ pager,
124
+ active="messages",
125
+ )
126
+
127
+
128
+ def _preset_section(
129
+ presets: list[Any],
130
+ *,
131
+ content: str,
132
+ field_path: str,
133
+ field_value: str,
134
+ target: str,
135
+ channel_id: str,
136
+ status: str,
137
+ message_type: str,
138
+ control_id: str,
139
+ ) -> Markup:
140
+ """Saved / layered filter presets (BACKLOG #151, ADR 0136): save the current search, recall + layer
141
+ saved presets, delete. The content term is loaded server-side from the encrypted preset column — the
142
+ layered run carries only preset ids in the query, never the PHI-shaped needle."""
143
+ # Save the CURRENT search state as a named preset (step-up POST; criteria ride hidden fields — the
144
+ # same terms already in the visible form above, sent in the body, not a URL).
145
+ hidden = [
146
+ el("input", type="hidden", name=k, value=v or None)
147
+ for k, v in (
148
+ ("content", content),
149
+ ("field_path", field_path),
150
+ ("field_value", field_value),
151
+ ("target", target),
152
+ ("channel_id", channel_id),
153
+ ("status", status),
154
+ ("message_type", message_type),
155
+ ("control_id", control_id),
156
+ )
157
+ ]
158
+ save = el(
159
+ "form",
160
+ *hidden,
161
+ el(
162
+ "label", "Save this search as", el("input", name="name", maxlength="128", required=True)
163
+ ),
164
+ el("button", "Save preset", type="submit"),
165
+ method="post",
166
+ action="/ui/messages/search/presets",
167
+ class_="ctl",
168
+ )
169
+ parts: list[object] = [el("h2", "Saved presets"), save]
170
+ if presets:
171
+ # Recall + layer: a GET form of checkboxes → /ui/messages/search/layered?presets=…&presets=…
172
+ # (ids only). Layering AND-composes ≤ 8 presets with exactly one content term (server-composed).
173
+ checks = [
174
+ el(
175
+ "label",
176
+ el("input", type="checkbox", name="presets", value=p.id),
177
+ " ",
178
+ p.name,
179
+ class_="preset-check",
180
+ )
181
+ for p in presets
182
+ ]
183
+ layer = el(
184
+ "form",
185
+ *checks,
186
+ el("button", "Run layered search", type="submit"),
187
+ el(
188
+ "span",
189
+ " (tick ≤ 8; exactly one must carry a content term)",
190
+ class_="muted",
191
+ ),
192
+ method="get",
193
+ action="/ui/messages/search/layered",
194
+ class_="filters",
195
+ )
196
+ deletes = el(
197
+ "div",
198
+ *[
199
+ el(
200
+ "form",
201
+ el("button", f"Delete “{p.name}”", type="submit"),
202
+ method="post",
203
+ action=f"/ui/messages/search/presets/{p.id}/delete",
204
+ class_="ctl",
205
+ )
206
+ for p in presets
207
+ ],
208
+ class_="ctls",
209
+ )
210
+ parts += [layer, deletes]
211
+ else:
212
+ parts.append(el("p", "No saved presets yet.", class_="muted"))
213
+ return el("div", *parts, class_="presets")
214
+
215
+
216
+ def message_search(
217
+ results: MessageSearchResults | None,
218
+ *,
219
+ content: str = "",
220
+ field_path: str = "",
221
+ field_value: str = "",
222
+ target: str = "both",
223
+ channel_id: str = "",
224
+ status: str = "",
225
+ message_type: str = "",
226
+ control_id: str = "",
227
+ error: str = "",
228
+ presets: list[Any] | None = None,
229
+ ) -> Markup:
230
+ """The content-search page (a step-up-unlock GET, ADR 0046 #51): search by an HL7 field path
231
+ (``PID-3``) or a raw/summary substring, over the caller's channels. ``results is None`` = the bare
232
+ form (no search yet). Every matched row is a metadata-only summary (server-redacted) linking to the
233
+ audited detail view — no decrypted body is ever rendered here."""
234
+ target_options = [
235
+ el("option", label, value=value, selected=value == target or None)
236
+ for value, label in (
237
+ ("both", "raw + summary"),
238
+ ("raw", "raw only"),
239
+ ("summary", "summary only"),
240
+ )
241
+ ]
242
+ form = el(
243
+ "form",
244
+ el(
245
+ "p",
246
+ "Search decrypts message bodies — a bulk-PHI operation, audited and step-up gated. "
247
+ "Provide a field path + value (e.g. PID-3 / 100), or a substring.",
248
+ class_="muted",
249
+ ),
250
+ el(
251
+ "label",
252
+ "Field path",
253
+ el("input", name="field_path", value=field_path or None, placeholder="PID-3"),
254
+ ),
255
+ el("label", "Field value", el("input", name="field_value", value=field_value or None)),
256
+ el("label", "Substring", el("input", name="content", value=content or None)),
257
+ el("label", "Target", el("select", *target_options, name="target")),
258
+ el("input", name="channel_id", value=channel_id or None, placeholder="channel (optional)"),
259
+ el("input", name="status", value=status or None, placeholder="status (optional)"),
260
+ el("input", name="message_type", value=message_type or None, placeholder="type (optional)"),
261
+ el(
262
+ "input",
263
+ name="control_id",
264
+ value=control_id or None,
265
+ placeholder="control id (optional)",
266
+ ),
267
+ el("button", "Search", type="submit"),
268
+ method="get",
269
+ action="/ui/messages/search",
270
+ class_="filters",
271
+ )
272
+ parts: list[object] = [
273
+ el("h1", "Content search"),
274
+ el("p", el("a", "← Messages", href="/ui/messages")),
275
+ form,
276
+ ]
277
+ if error:
278
+ parts.append(el("p", error, class_="banner"))
279
+ if results is not None:
280
+ rows = [
281
+ [
282
+ m.received_at,
283
+ m.channel_id,
284
+ m.message_type,
285
+ el("span", m.status, class_=f"status status-{m.status}"),
286
+ m.control_id,
287
+ el("a", m.summary or "(view)", href=f"/ui/messages/{m.id}"),
288
+ ]
289
+ for m in results.messages
290
+ ]
291
+ note = f"{results.matched} match(es) after decrypting {results.scanned} candidate(s)" + (
292
+ f" — scan hit the {results.scan_limit} ceiling; narrow the filters"
293
+ if results.truncated
294
+ else ""
295
+ )
296
+ parts.append(el("p", text(note), class_="pager"))
297
+ parts.append(
298
+ rows_table(["Received", "Channel", "Type", "Status", "Control ID", "Summary"], rows)
299
+ )
300
+ if presets is not None:
301
+ parts.append(
302
+ _preset_section(
303
+ presets,
304
+ content=content,
305
+ field_path=field_path,
306
+ field_value=field_value,
307
+ target=target,
308
+ channel_id=channel_id,
309
+ status=status,
310
+ message_type=message_type,
311
+ control_id=control_id,
312
+ )
313
+ )
314
+ return page("Content search", *parts, active="messages")
315
+
316
+
317
+ def _human_size(n: int) -> str:
318
+ """A compact human-readable byte size (e.g. ``1.2 MiB``) for the attachments panel. Binary units;
319
+ integer bytes below 1 KiB. Pure display — never affects the download."""
320
+ size = float(max(n, 0))
321
+ for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
322
+ if size < 1024 or unit == "TiB":
323
+ return f"{int(size)} {unit}" if unit == "B" else f"{size:.1f} {unit}"
324
+ size /= 1024
325
+ return f"{n} B" # unreachable; keeps mypy happy on the loop's implicit path
326
+
327
+
328
+ def message_detail(detail: MessageDetail) -> Markup:
329
+ """A single message: metadata + the AUDITED raw body (escaped inside <pre>) + deliveries/events, plus
330
+ an Attachments panel (#149, ADR 0105 Phase 3b) when very-large documents were detached at ingress."""
331
+ meta = rows_table(
332
+ ["Field", "Value"],
333
+ [
334
+ ["ID", detail.id],
335
+ ["Channel", detail.channel_id],
336
+ ["Received", detail.received_at],
337
+ ["Type", detail.message_type],
338
+ ["Control ID", detail.control_id],
339
+ ["Status", detail.status],
340
+ ["Summary", detail.summary],
341
+ ["Error", detail.error],
342
+ ],
343
+ adjustable=False,
344
+ )
345
+ # The raw body is attacker-influenced HL7 — rendered as escaped text inside <pre>, never as markup.
346
+ raw = el("pre", detail.raw, class_="raw")
347
+ outbox = rows_table(
348
+ ["Destination", "Status", "Attempts", "Last error"],
349
+ [[o.destination_name, o.status, o.attempts, o.last_error] for o in detail.outbox],
350
+ )
351
+ events = rows_table(
352
+ ["When", "Event", "Destination", "Detail"],
353
+ [[e.ts, e.event, e.destination, e.detail] for e in detail.events],
354
+ )
355
+ replay = el(
356
+ "form",
357
+ el("button", "Replay", type="submit"),
358
+ method="post",
359
+ action=f"/ui/messages/{detail.id}/replay",
360
+ class_="ctl",
361
+ )
362
+ # Edit-and-resubmit (ADR 0090 §9, BACKLOG #153): a LINK (GET) to the editor page — the editable copy
363
+ # opens there so the ORIGINAL shown here is never altered. GET, not a POST, so it is the step-up
364
+ # `unlock` continuation the re-auth flow can hand back to (a body-carrying POST can't be auto-retried).
365
+ edit = el(
366
+ "a",
367
+ "Edit & resubmit →",
368
+ href=f"/ui/messages/{detail.id}/edit",
369
+ class_="btn-link",
370
+ )
371
+ # Attachments panel (#149, ADR 0105 Phase 3b): shown only when a very-large document was detached
372
+ # from this message. Metadata only (content type + human size); the bytes ride the audited,
373
+ # view_raw-gated download link (a top-level GET carrying the session cookie — the /ui route reuses
374
+ # the engine's audited download handler in-process). Rendered as a normal same-origin link.
375
+ attachments_section: list[object] = []
376
+ if detail.attachments:
377
+ att_rows = [
378
+ [
379
+ a.content_type,
380
+ _human_size(a.total_bytes),
381
+ el(
382
+ "a",
383
+ "Download",
384
+ href=f"/ui/messages/{detail.id}/attachments/{a.id}",
385
+ class_="btn-link",
386
+ ),
387
+ ]
388
+ for a in detail.attachments
389
+ ]
390
+ attachments_section = [
391
+ el("h2", "Attachments"),
392
+ el(
393
+ "p",
394
+ "Very-large documents detached from this message. Downloading a document is audited "
395
+ "PHI access.",
396
+ class_="muted",
397
+ ),
398
+ rows_table(["Content type", "Size", ""], att_rows),
399
+ ]
400
+ return page(
401
+ "Message",
402
+ el("p", el("a", "← Messages", href="/ui/messages")),
403
+ el("div", el("h1", "Message detail"), replay, edit, class_="detail-head"),
404
+ meta,
405
+ el(
406
+ "div",
407
+ el("h2", "Raw message"),
408
+ el("a", "Parse tree →", href=f"/ui/messages/{detail.id}/parse-tree", class_="muted"),
409
+ class_="detail-head",
410
+ ),
411
+ raw,
412
+ *attachments_section,
413
+ el("h2", "Deliveries"),
414
+ outbox,
415
+ el("h2", "Events"),
416
+ events,
417
+ active="messages",
418
+ )
419
+
420
+
421
+ def message_edit(
422
+ detail: MessageDetail,
423
+ idempotency_key: str,
424
+ *,
425
+ raw_value: str | None = None,
426
+ error: str = "",
427
+ mode: str = "reroute",
428
+ to: str = "",
429
+ ) -> Markup:
430
+ """The edit-and-resubmit editor (ADR 0090 §9, BACKLOG #153): a COPY of the message's raw body opened
431
+ in an editable ``<textarea>``, a "Modified" badge that app.js reveals as soon as the copy changes, a
432
+ Revert button that restores the original copy, and a Resubmit button that POSTs the edited body. The
433
+ ORIGINAL log entry is untouched — this is a copy — which the banner states explicitly. ``raw_value``
434
+ (a rejected prior attempt echoed back) overrides the pristine copy so a validation error keeps the
435
+ operator's edits; the ``data-original`` attribute always carries the PRISTINE copy for Revert.
436
+
437
+ The edited body is attacker-influenced HL7 rendered as escaped ``<textarea>`` text (never markup);
438
+ ``idempotency_key`` is a fresh per-open token so a double-submit of this form is an idempotent no-op.
439
+ """
440
+ original = detail.raw
441
+ shown = original if raw_value is None else raw_value
442
+ is_direct = mode == "direct"
443
+ err = el("p", text(error), class_="banner") if error else Markup("")
444
+ # data-original carries the PRISTINE copy for the client-side Revert; the badge/Revert/Resubmit
445
+ # wiring lives in app.js (no inline script — the /ui CSP forbids it).
446
+ editor = el(
447
+ "textarea",
448
+ text(shown),
449
+ name="raw",
450
+ id="edit-raw",
451
+ class_="edit-raw",
452
+ rows="18",
453
+ spellcheck="false",
454
+ data_original=original,
455
+ )
456
+ modified_badge = el(
457
+ "span", "Modified", id="edit-modified", class_="badge-modified", hidden=True
458
+ )
459
+ # reroute (default) vs a direct alternate outbound. app.js shows/hides the `to` field on toggle;
460
+ # server-side, an empty `to` means re-route (the endpoint's default). On a rejected retry the
461
+ # selected mode + typed outbound are echoed back (via `mode`/`to`) so the operator's destination
462
+ # choice survives alongside their edits — not silently reset to re-route (review #153-4).
463
+ dest = el(
464
+ "div",
465
+ el(
466
+ "label",
467
+ el(
468
+ "input",
469
+ type="radio",
470
+ name="mode",
471
+ value="reroute",
472
+ checked=not is_direct,
473
+ class_="edit-mode",
474
+ ),
475
+ " Re-route on the original channel (re-parse + route normally)",
476
+ ),
477
+ el(
478
+ "label",
479
+ el(
480
+ "input",
481
+ type="radio",
482
+ name="mode",
483
+ value="direct",
484
+ checked=is_direct,
485
+ class_="edit-mode",
486
+ ),
487
+ " Send directly to an outbound connection",
488
+ ),
489
+ el(
490
+ "div",
491
+ el("label", "Outbound connection", for_="edit-to"),
492
+ el(
493
+ "input",
494
+ type="text",
495
+ name="to",
496
+ id="edit-to",
497
+ value=to,
498
+ maxlength="256",
499
+ autocomplete="off",
500
+ ),
501
+ id="edit-to-row",
502
+ class_="edit-to-row",
503
+ hidden=not is_direct,
504
+ ),
505
+ class_="edit-mode-row",
506
+ )
507
+ form = el(
508
+ "form",
509
+ el("input", type="hidden", name="idempotency_key", value=idempotency_key),
510
+ dest,
511
+ editor,
512
+ el(
513
+ "div",
514
+ el("button", "Resubmit", type="submit", id="edit-resubmit", class_="primary"),
515
+ el("button", "Revert", type="button", id="edit-revert", disabled=True),
516
+ modified_badge,
517
+ class_="ctls edit-ctls",
518
+ ),
519
+ method="post",
520
+ action=f"/ui/messages/{detail.id}/edit-resend",
521
+ class_="edit-form",
522
+ id="edit-form",
523
+ data_mf_edit=True,
524
+ )
525
+ return page(
526
+ "Edit & resubmit",
527
+ el("p", el("a", "← Message", href=f"/ui/messages/{detail.id}")),
528
+ el("h1", "Edit & resubmit"),
529
+ el(
530
+ "p",
531
+ "You are editing a COPY. The original message in the log stays unchanged; Resubmit"
532
+ " creates a new, correlated message.",
533
+ class_="muted",
534
+ ),
535
+ err,
536
+ rows_table(
537
+ ["Field", "Value"],
538
+ [["Original ID", detail.id], ["Channel", detail.channel_id], ["Status", detail.status]],
539
+ adjustable=False,
540
+ ),
541
+ form,
542
+ active="messages",
543
+ )
544
+
545
+
546
+ def _tree_nodes(nodes: list[TreeNode]) -> Markup:
547
+ """Render HL7 parse-tree nodes as a nested list. Every label/value is escaped by ``el`` — the field
548
+ values are attacker-influenced HL7, so they can never inject markup."""
549
+ items: list[object] = []
550
+ for node in nodes:
551
+ label = el("span", node.label, class_="tlabel")
552
+ value = el("span", node.value, class_="tval") if node.value else Markup("")
553
+ children = _tree_nodes(node.children) if node.children else Markup("")
554
+ items.append(el("li", label, " ", value, children))
555
+ return el("ul", *items, class_="tree")
556
+
557
+
558
+ def parse_tree_page(message_id: str, nodes: list[TreeNode]) -> Markup:
559
+ """The HL7 parse tree for one message (server-parsed via the pure ``parsing`` lib; escaped)."""
560
+ return page(
561
+ "Parse tree",
562
+ el("p", el("a", "← Message", href=f"/ui/messages/{message_id}")),
563
+ el("h1", "HL7 parse tree"),
564
+ _tree_nodes(nodes),
565
+ active="messages",
566
+ )
567
+
568
+
569
+ def parse_tree_unavailable(message_id: str, reason: str) -> Markup:
570
+ """Shown when the raw body has no HL7 parse tree (non-HL7 content, or no parseable MSH)."""
571
+ return page(
572
+ "Parse tree",
573
+ el("p", el("a", "← Message", href=f"/ui/messages/{message_id}")),
574
+ el("h1", "HL7 parse tree"),
575
+ el("p", text(f"No HL7 parse tree for this message: {reason}"), class_="muted"),
576
+ active="messages",
577
+ )
578
+
579
+
580
+ def dead_letters(data: DeadLetterList) -> Markup:
581
+ """The dead-letter list (newest first) + per-channel bulk replay (M3).
582
+
583
+ Each row links to the audited message detail (single-message replay lives there, M2b). The bulk
584
+ "Replay all dead" per channel re-queues every dead delivery for that channel (step-up-gated; may be
585
+ held for dual-control approval). Channel names are the ``[TYPE]_[PARTNER]_[MSG]`` URL-safe
586
+ identifiers, carried in the action PATH so the step-up auto-retry re-POST needs no body.
587
+ """
588
+ headers = ["Failed", "Channel", "Destination", "Type", "Attempts", "Last error", "Message"]
589
+ body = [
590
+ [
591
+ d.failed_at,
592
+ d.channel_id,
593
+ d.destination_name,
594
+ d.message_type,
595
+ d.attempts,
596
+ d.last_error,
597
+ el("a", "view", href=f"/ui/messages/{d.message_id}"),
598
+ ]
599
+ for d in data.dead_letters
600
+ ]
601
+ pager = el(
602
+ "p",
603
+ text(f"{len(data.dead_letters)} of {data.total} (offset {data.offset})"),
604
+ class_="pager",
605
+ )
606
+ channels = sorted({d.channel_id for d in data.dead_letters})
607
+ pairs = sorted(
608
+ {(d.channel_id, d.destination_name) for d in data.dead_letters if d.destination_name}
609
+ )
610
+ chan_forms = [
611
+ el(
612
+ "form",
613
+ el("button", f"Replay all dead — {ch}", type="submit"),
614
+ method="post",
615
+ action=f"/ui/dead-letters/{ch}/replay",
616
+ class_="ctl",
617
+ )
618
+ for ch in channels
619
+ ]
620
+ dest_forms = [
621
+ el(
622
+ "form",
623
+ el("button", f"Replay {ch} → {dest}", type="submit"),
624
+ method="post",
625
+ action=f"/ui/dead-letters/{ch}/{dest}/replay",
626
+ class_="ctl",
627
+ )
628
+ for ch, dest in pairs
629
+ ]
630
+ actions: list[object] = []
631
+ if channels:
632
+ # L6b (#75 parity): one action to replay every dead delivery across ALL channels.
633
+ actions += [
634
+ el("h2", "Bulk replay — everything"),
635
+ el(
636
+ "div",
637
+ el(
638
+ "form",
639
+ el("button", "Replay all dead (every channel)", type="submit"),
640
+ method="post",
641
+ action="/ui/dead-letters/replay-all",
642
+ class_="ctl",
643
+ ),
644
+ class_="ctls",
645
+ ),
646
+ ]
647
+ if chan_forms:
648
+ actions += [el("h2", "Bulk replay — per channel"), el("div", *chan_forms, class_="ctls")]
649
+ if dest_forms:
650
+ actions += [
651
+ el("h2", "Bulk replay — per destination"),
652
+ el("div", *dest_forms, class_="ctls"),
653
+ ]
654
+ return page(
655
+ "Dead letters",
656
+ el("h1", "Dead letters"),
657
+ # BACKLOG #278: the rename was declined (see the item) — "dead letter" stays the identifier
658
+ # everywhere (UI, API, store), so operators and engineers keep one word for one thing. It is a
659
+ # messaging-middleware term rather than a healthcare-interface one, though, so the page says in
660
+ # plain interface vocabulary what it holds. Labelling, not renaming: no API/store churn.
661
+ el(
662
+ "p",
663
+ "Deliveries that failed permanently or exhausted their retries — held here for replay, "
664
+ "never discarded.",
665
+ class_="muted",
666
+ ),
667
+ rows_table(headers, body),
668
+ pager,
669
+ *actions,
670
+ active="dead-letters",
671
+ )
672
+
673
+
674
+ def dead_letter_pending(pending: object) -> Markup:
675
+ """Shown when a bulk dead-letter replay is held for a second approver (dual-control, ADR 0014)."""
676
+ approval_id = getattr(pending, "approval_id", "")
677
+ body = el(
678
+ "div",
679
+ el("h1", "Replay held for approval"),
680
+ el(
681
+ "p",
682
+ "This dead-letter replay is held for a second approver (dual-control).",
683
+ class_="muted",
684
+ ),
685
+ el("p", text(f"Approval id: {approval_id}"), class_="muted"),
686
+ el("p", el("a", "← Dead letters", href="/ui/dead-letters")),
687
+ class_="card",
688
+ )
689
+ return page("Pending approval", body, active="dead-letters")