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,519 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """Connections-area page builders for the /ui ops dashboard (ADR 0065).
4
+
5
+ The dashboard + the pollable connections fragment + the per-connection start/stop/restart controls.
6
+ Every dynamic value is placed through the escaping element builders in :mod:`.._html`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ import binascii
13
+ from datetime import UTC, datetime
14
+ from typing import Literal
15
+ from urllib.parse import quote
16
+
17
+ from messagefoundry.api.models import ConnectionEventInfo, ConnectionRow
18
+
19
+ from .._html import Markup, el, page, rows_table, text
20
+ from ._common import _num, _secs
21
+
22
+ __all__ = [
23
+ "bulk_control_result",
24
+ "connection_details",
25
+ "connections_fragment",
26
+ "dashboard",
27
+ "decode_row_key",
28
+ "flag_rejected",
29
+ "purge_confirm",
30
+ "purge_pending",
31
+ "purge_result",
32
+ ]
33
+
34
+
35
+ def _ts(value: float) -> str:
36
+ """A connection-event timestamp as a UTC wall-clock string."""
37
+ return datetime.fromtimestamp(value, UTC).strftime("%Y-%m-%d %H:%M:%SZ")
38
+
39
+
40
+ def _display_name(name: str) -> str:
41
+ """Drop the redundant ' ▸ in'/' ▸ out' direction suffix the engine appends to a source/standalone-
42
+ outbound display name (#1) — the Dir column already shows direction. A destination's 'channel ▸ dest'
43
+ name has no such suffix and is left intact. The full name stays the row/detail identity."""
44
+ for suffix in (" ▸ in", " ▸ out"):
45
+ if name.endswith(suffix):
46
+ return name[: -len(suffix)]
47
+ return name
48
+
49
+
50
+ def _name_cell(r: ConnectionRow) -> Markup:
51
+ """The Connection cell: the name (direction suffix stripped, #1) links to that connection's pre-filled
52
+ message search (#4b), with a small 'details' link to its detail page (#4a). Both escaped by the
53
+ builder; the details link keeps the FULL name so the route can look the row up."""
54
+ return el(
55
+ "span",
56
+ el(
57
+ "a",
58
+ _display_name(r.name),
59
+ href=f"/ui/messages?channel_id={quote(r.channel_id)}&defer=1",
60
+ ),
61
+ el(
62
+ "a",
63
+ "ⓘ",
64
+ href=f"/ui/connection/{quote(r.name)}",
65
+ class_="detail-link",
66
+ title="Connection details",
67
+ aria_label=f"Details for {_display_name(r.name)}",
68
+ ),
69
+ )
70
+
71
+
72
+ def _b64url(value: str) -> str:
73
+ """urlsafe-base64 (padded) of a UTF-8 string — an attribute/path-safe, ``|``-free encoding."""
74
+ return base64.urlsafe_b64encode(value.encode("utf-8")).decode("ascii")
75
+
76
+
77
+ def _row_key(r: ConnectionRow) -> str:
78
+ """A stable, server-minted checkbox identity for one dashboard row: ``role|b64url(channel_id)|
79
+ b64url(destination or '')``. Minted from the row's own unique (role, channel_id, destination)
80
+ triple, so it is identical on every render (survives the live poll swap) and round-trips names with
81
+ spaces/unsafe characters. urlsafe-base64 never contains ``|``, so the delimiter can't collide. The
82
+ presentation layer emits this as the checkbox ``value``; the bulk /ui endpoints decode it via
83
+ :func:`decode_row_key`."""
84
+ return f"{r.role}|{_b64url(r.channel_id)}|{_b64url(r.destination or '')}"
85
+
86
+
87
+ def decode_row_key(
88
+ key: str,
89
+ ) -> tuple[Literal["source", "destination"], str, str] | None:
90
+ """Decode a :func:`_row_key` back to ``(role, channel_id, destination)`` — ``None`` for any
91
+ malformed/forged value (wrong shape, unknown role, non-base64, non-UTF-8). ``destination`` is ``""``
92
+ for a source row. Never raises — a caller renders ``None`` as a fixed 'unrecognized selection' label
93
+ rather than reflecting attacker-controlled bytes."""
94
+ parts = key.split("|")
95
+ if len(parts) != 3:
96
+ return None
97
+ role, cid_b64, dest_b64 = parts
98
+ role_lit: Literal["source", "destination"]
99
+ if role == "source":
100
+ role_lit = "source"
101
+ elif role == "destination":
102
+ role_lit = "destination"
103
+ else:
104
+ return None
105
+ try:
106
+ cid = base64.urlsafe_b64decode(cid_b64).decode("utf-8")
107
+ dest = base64.urlsafe_b64decode(dest_b64).decode("utf-8")
108
+ except (binascii.Error, ValueError, UnicodeDecodeError):
109
+ return None
110
+ return role_lit, cid, dest
111
+
112
+
113
+ #: The bulk-action dropdown options: (option value the JS maps to an action, visible label). Start/
114
+ #: Stop/Restart/Reset apply to BOTH roles; Purge top/all apply to a stopped-and-quiesced OUTBOUND only
115
+ #: (the JS partitions the live selection and the engine is the final authority).
116
+ _TOOLBAR_ACTIONS: list[tuple[str, str]] = [
117
+ ("start", "Start"),
118
+ ("stop", "Stop"),
119
+ ("restart", "Restart"),
120
+ ("reset", "Reset stats"),
121
+ ("purge-top", "Purge top"),
122
+ ("purge-all", "Purge all"),
123
+ ]
124
+
125
+
126
+ def _selection_checkbox(r: ConnectionRow) -> Markup:
127
+ """One row's selection checkbox. ``value`` is the stable server-minted :func:`_row_key` (the identity
128
+ the bulk /ui endpoints decode); the ``data-*`` carry the row's LIVE role/status/paused/destination so
129
+ ``app.js`` can partition a mixed selection per the role/state matrix (UX-only — the engine is the
130
+ authority). ``data-paused`` (paused AND quiesced) gates purge eligibility INDEPENDENT of the collapsed
131
+ display ``status`` so a failed/filtered-but-paused outbound stays purgeable."""
132
+ return el(
133
+ "input",
134
+ type="checkbox",
135
+ class_="rowcb",
136
+ data_mf_conns_cb=True,
137
+ value=_row_key(r),
138
+ data_role=r.role,
139
+ data_status=r.status,
140
+ data_paused=("1" if r.paused else "0"),
141
+ data_dest=(r.destination or ""),
142
+ # #131: the object-of-interest flag, read by the "Flagged only" client-side filter (app.js).
143
+ data_flagged=("1" if r.flagged else "0"),
144
+ )
145
+
146
+
147
+ def _status_cell(r: ConnectionRow) -> Markup:
148
+ """The Status cell: the connection status pill, plus a small "waiting for reply" badge (#136, ADR
149
+ 0065 amendment) while an outbound is awaiting an MLLP ACK past its display delay. The badge is a
150
+ DISPLAY-ONLY, transient state that clears on the next ~1s poll/ws swap once the reply resolves; it
151
+ appears only on ACK-waiting outbounds (``waiting_for_reply`` is False otherwise)."""
152
+ status = el("span", r.status, class_=f"status status-{r.status}")
153
+ if r.waiting_for_reply:
154
+ return el(
155
+ "span",
156
+ status,
157
+ el("span", "⧖ waiting", class_="waiting", title="Waiting for reply (ACK)"),
158
+ )
159
+ return status
160
+
161
+
162
+ def _flag_name(r: ConnectionRow) -> tuple[str, str]:
163
+ """The (connection name, direction) the flag write targets for a row: the inbound name for a source
164
+ row, the outbound name for a destination row. Direction is what ``POST /connections/{name}/flag``
165
+ disambiguates on."""
166
+ if r.role == "source":
167
+ return r.channel_id, "inbound"
168
+ return (r.destination or ""), "outbound"
169
+
170
+
171
+ def _flag_cell(r: ConnectionRow) -> Markup:
172
+ """The Flag cell (#131, ADR 0007 amendment): a same-origin POST toggle form on a
173
+ ``connections.toml``-managed row (the write seam persists there), or a read-only indicator on a
174
+ code-first row (no TOML home for the flag). The glyph is filled (⚑) when flagged, outline (⚐)
175
+ otherwise; the button posts the DESIRED new value so a submit flips it (like the Alerts ack/resolve
176
+ POST forms). Every value is placed through the escaping builder."""
177
+ glyph = "⚑" if r.flagged else "⚐"
178
+ if not r.toml_managed:
179
+ # Code-first / draining: show the state but no toggle (it can't be persisted to connections.toml).
180
+ return el(
181
+ "span",
182
+ glyph if r.flagged else "",
183
+ class_="flag" + (" flagged" if r.flagged else ""),
184
+ title="Flagged (code-first — edit the .py module to change)" if r.flagged else "",
185
+ )
186
+ name, direction = _flag_name(r)
187
+ return el(
188
+ "form",
189
+ el("input", type="hidden", name="direction", value=direction),
190
+ el("input", type="hidden", name="flagged", value=("false" if r.flagged else "true")),
191
+ el(
192
+ "button",
193
+ glyph,
194
+ type="submit",
195
+ class_="flagbtn" + (" flagged" if r.flagged else ""),
196
+ title=("Unflag " if r.flagged else "Flag ") + "this connection",
197
+ aria_label=("Unflag " if r.flagged else "Flag ") + name,
198
+ ),
199
+ method="post",
200
+ action=f"/ui/connections/{quote(name)}/flag",
201
+ class_="ctl flagform",
202
+ )
203
+
204
+
205
+ def purge_pending(pending: object) -> Markup:
206
+ """Shown when a queue purge is held for a second approver (dual-control, ADR 0014)."""
207
+ approval_id = getattr(pending, "approval_id", "")
208
+ body = el(
209
+ "div",
210
+ el("h1", "Purge held for approval"),
211
+ el("p", "This queue purge is held for a second approver (dual-control).", class_="muted"),
212
+ el("p", text(f"Approval id: {approval_id}"), class_="muted"),
213
+ el("p", el("a", "← Connections", href="/ui")),
214
+ class_="card",
215
+ )
216
+ return page("Pending approval", body, active="dashboard")
217
+
218
+
219
+ def connections_fragment(rows: list[ConnectionRow]) -> Markup:
220
+ """Just the connections table — the poll target that app.js fetches from /ui/connections.
221
+
222
+ A leading selection column replaces the old per-row control cell: a header select-all checkbox and a
223
+ per-row checkbox whose ``value`` = :func:`_row_key`. These are the ONLY per-row controls now — the
224
+ Start/Stop/Restart/Reset/Purge actions moved to the un-polled dashboard toolbar, driven over this
225
+ selection by ``app.js`` (the checkboxes are wiped on each poll/ws swap and re-hydrated from a JS Set)."""
226
+ headers: list[str] = [
227
+ el("input", type="checkbox", data_mf_conns_all=True),
228
+ "Flag",
229
+ "Connection",
230
+ "Dir",
231
+ "Status",
232
+ "In",
233
+ "Out",
234
+ "Queued",
235
+ "Errors",
236
+ "Alerts",
237
+ "Idle",
238
+ ]
239
+ body = [
240
+ [
241
+ _selection_checkbox(r),
242
+ _flag_cell(r),
243
+ _name_cell(r),
244
+ r.direction,
245
+ _status_cell(r),
246
+ _num(r.read),
247
+ _num(r.written),
248
+ _num(r.queue_depth),
249
+ _num(r.errored),
250
+ _num(r.alerts_active),
251
+ _secs(r.idle_seconds),
252
+ ]
253
+ for r in rows
254
+ ]
255
+ return el("div", rows_table(headers, body), id="conns")
256
+
257
+
258
+ def _controls_toolbar() -> Markup:
259
+ """The bulk-action toolbar: an action ``<select>`` + Apply button + a feedback ``<span>``.
260
+
261
+ It lives in the un-polled dashboard shell (a sibling OUTSIDE the ``[data-poll]`` container), so the 5s
262
+ poll / ~1s ``/ws/stats`` swaps that replace ``#conns`` NEVER wipe it and its ``app.js`` init runs once.
263
+ ``app.js`` reads the chosen option + the live selection's ``data-*`` and dispatches: Start/Stop/Restart
264
+ → POST ``/ui/connections/bulk-control``; Reset stats → POST ``/ui/statistics/reset-many``; Purge
265
+ top/all → GET ``/ui/connections/purge-confirm`` (the step-up unlock flow)."""
266
+ options = [el("option", label, value=value) for value, label in _TOOLBAR_ACTIONS]
267
+ return el(
268
+ "div",
269
+ _filter_box(), # filter first, then the bulk-action dropdown + Apply
270
+ _flagged_only_toggle(), # #131: a client-side Flagged-only view filter
271
+ el("select", *options, data_mf_conns_action=True),
272
+ el("button", "Go", type="button", data_mf_conns_apply=True),
273
+ el(
274
+ "span", data_mf_conns_feedback=True, class_="muted"
275
+ ), # dynamic feedback (empty when idle)
276
+ data_mf_conns_toolbar=True,
277
+ class_="ctlbar",
278
+ )
279
+
280
+
281
+ def _filter_box() -> Markup:
282
+ """A filter-as-you-type box over the connections table (client-side, ``app.js``). It lives OUTSIDE the
283
+ ``[data-poll]`` container so its typed value survives the live swap; ``app.js`` hides rows whose text
284
+ doesn't match and re-applies the filter after each poll/ws swap. Purely presentational — the server
285
+ still sends every row, and a hidden row's checkbox value stays in the selection (filter is view-only)."""
286
+ return el(
287
+ "input",
288
+ type="search",
289
+ placeholder="Filter connections…",
290
+ data_mf_conns_filter=True,
291
+ class_="filterbox",
292
+ autocomplete="off",
293
+ aria_label="Filter connections",
294
+ )
295
+
296
+
297
+ def _flagged_only_toggle() -> Markup:
298
+ """The Flagged Objects filter (#131): a checkbox that hides every non-flagged row (client-side,
299
+ ``app.js``). Lives OUTSIDE ``[data-poll]`` (like the filter box) so its state survives the live swap;
300
+ ``app.js`` re-applies it after each poll/ws swap. Purely presentational — the server still sends every
301
+ row, and a hidden row's checkbox stays in the selection Set."""
302
+ return el(
303
+ "label",
304
+ el("input", type="checkbox", data_mf_conns_flagged=True),
305
+ "Flagged only",
306
+ class_="flagfilter",
307
+ title="Show only flagged connections",
308
+ )
309
+
310
+
311
+ def dashboard(rows: list[ConnectionRow]) -> Markup:
312
+ """The connections dashboard page; the table auto-refreshes via the first-party poll script.
313
+
314
+ ``data-poll`` names the same-origin fragment endpoint; ``app.js`` fetches it every ``data-poll-ms``
315
+ and replaces this container's content with the server-rendered, already-escaped fragment. The
316
+ ``#livestats`` strip is filled live by ``app.js`` over the ``/ws/stats`` WebSocket (M-ws); it
317
+ degrades to empty (the polled table still updates) if the socket can't connect. The bulk-action
318
+ ``[data-mf-conns-toolbar]`` and the ``[data-mf-conns-filter]`` box are siblings OUTSIDE ``[data-poll]``
319
+ so a swap never wipes them.
320
+ """
321
+ live = el(
322
+ "div",
323
+ connections_fragment(rows),
324
+ data_poll="/ui/connections",
325
+ data_poll_ms="5000",
326
+ )
327
+ livestats = el("div", id="livestats", class_="livestats")
328
+ return page(
329
+ "Connections",
330
+ # Header + the filter/bulk-action toolbar on one row.
331
+ el("div", el("h1", "Connections"), _controls_toolbar(), class_="page-head"),
332
+ livestats,
333
+ live,
334
+ active="dashboard",
335
+ )
336
+
337
+
338
+ def connection_details(row: ConnectionRow, events: list[ConnectionEventInfo]) -> Markup:
339
+ """Read-only detail view for one connection (#4a): transport/status, live stats, and recent
340
+ connection/transport events — all metadata (no PHI), composed from existing monitoring handlers."""
341
+ peer = row.peer or "—"
342
+ if row.port:
343
+ peer = f"{peer}:{row.port}"
344
+ info = rows_table(
345
+ ["Field", "Value"],
346
+ [
347
+ ["Role", row.role],
348
+ ["Direction", row.direction],
349
+ ["Method", row.method],
350
+ ["Peer", peer],
351
+ ["Channel", row.channel_id],
352
+ ["Status", el("span", row.status, class_=f"status status-{row.status}")],
353
+ ],
354
+ adjustable=False,
355
+ )
356
+ stats = rows_table(
357
+ ["Metric", "Value"],
358
+ [
359
+ ["In", _num(row.read)],
360
+ ["Out", _num(row.written)],
361
+ ["Queued", _num(row.queue_depth)],
362
+ ["Errors", _num(row.errored)],
363
+ ["Alerts", _num(row.alerts_active)],
364
+ ["Idle", _secs(row.idle_seconds)],
365
+ ],
366
+ adjustable=False,
367
+ )
368
+ if events:
369
+ events_tbl: Markup = rows_table(
370
+ ["When", "Kind", "Dir", "Peer", "Reason"],
371
+ [[_ts(e.ts), e.kind, e.direction, e.peer_host or "—", e.reason or "—"] for e in events],
372
+ adjustable=False,
373
+ )
374
+ else:
375
+ events_tbl = el("p", "No recent events.", class_="muted")
376
+ body = el(
377
+ "div",
378
+ el("h1", _display_name(row.name)),
379
+ el(
380
+ "p",
381
+ el(
382
+ "a",
383
+ "View messages →",
384
+ href=f"/ui/messages?channel_id={quote(row.channel_id)}&defer=1",
385
+ ),
386
+ class_="muted",
387
+ ),
388
+ el("h2", "Connection"),
389
+ info,
390
+ el("h2", "Statistics"),
391
+ stats,
392
+ el("h2", "Recent events"),
393
+ events_tbl,
394
+ el("p", el("a", "← Connections", href="/ui")),
395
+ class_="card detail-card",
396
+ )
397
+ return page(f"{_display_name(row.name)} — details", body, active="dashboard")
398
+
399
+
400
+ # --- bulk-action result / confirm pages (connection controls) --------------------
401
+ #
402
+ # Every per-target label reaching these builders is placed through the escaping ``el``/``rows_table``
403
+ # helpers, so a decoded row key / ?dest that carries markup renders inert; an *undecodable* key never
404
+ # arrives as raw bytes — the endpoint replaces it with the fixed UNRECOGNIZED label below.
405
+
406
+ #: Fixed label rendered in place of a forged/undecodable selection (never the raw submitted bytes) —
407
+ #: an outcome whose target is ``None`` (the endpoint couldn't decode the row key / ?dest).
408
+ _UNRECOGNIZED_LABEL = "unrecognized selection"
409
+
410
+
411
+ def _outcomes_table(outcomes: list[tuple[str | None, str]]) -> Markup:
412
+ """A two-column Target/Result table for a bulk-action outcome page — both cells escaped. A ``None``
413
+ target (an undecodable/forged selection) renders the fixed 'unrecognized selection' label, never the
414
+ raw submitted bytes."""
415
+ return rows_table(
416
+ ["Target", "Result"],
417
+ [
418
+ [_UNRECOGNIZED_LABEL if target is None else target, result]
419
+ for target, result in outcomes
420
+ ],
421
+ )
422
+
423
+
424
+ def bulk_control_result(action: str, outcomes: list[tuple[str | None, str]]) -> Markup:
425
+ """Per-target result of a bulk Start/Stop/Restart (both roles). ``action`` labels the batch; each
426
+ outcome pairs a target name (``None`` → the fixed 'unrecognized selection' label) with what happened
427
+ (applied/forbidden/unknown/error)."""
428
+ body = el(
429
+ "div",
430
+ el("h1", f"Bulk {action}"),
431
+ el("p", f"{len(outcomes)} target(s) processed.", class_="muted"),
432
+ _outcomes_table(outcomes),
433
+ el("p", el("a", "← Connections", href="/ui")),
434
+ class_="card",
435
+ )
436
+ return page("Bulk control", body, active="dashboard")
437
+
438
+
439
+ def purge_result(scope: str, outcomes: list[tuple[str | None, str]]) -> Markup:
440
+ """Per-destination result of a bulk queue purge (step-up + dual-control per dest). ``scope`` is the
441
+ chosen 'top'/'all'; each outcome pairs a destination (``None`` → the fixed 'unrecognized selection'
442
+ label) with its result (purged N / held for approval / skipped-running (409) / forbidden (403) /
443
+ unknown (404))."""
444
+ body = el(
445
+ "div",
446
+ el("h1", "Queue purge"),
447
+ el("p", f"Scope: {scope}. {len(outcomes)} destination(s) processed.", class_="muted"),
448
+ _outcomes_table(outcomes),
449
+ el("p", el("a", "← Connections", href="/ui")),
450
+ class_="card",
451
+ )
452
+ return page("Queue purge", body, active="dashboard")
453
+
454
+
455
+ def flag_rejected(name: str, detail: str) -> Markup:
456
+ """Shown when a flag toggle is refused (#131) — most often the SCOPE FORK: the connection is
457
+ code-first (no ``connections.toml`` home for the flag). Both ``name`` and ``detail`` are escaped."""
458
+ body = el(
459
+ "div",
460
+ el("h1", "Flag not changed"),
461
+ el("p", text(f"Connection: {name}"), class_="muted"),
462
+ el("p", text(detail), class_="muted"),
463
+ el(
464
+ "p",
465
+ "The object flag is settable only on a connections.toml-managed connection. A code-first "
466
+ "connection has no TOML home for it — edit its .py module to change the flag.",
467
+ class_="muted",
468
+ ),
469
+ el("p", el("a", "← Connections", href="/ui")),
470
+ class_="card",
471
+ )
472
+ return page("Flag not changed", body, active="dashboard")
473
+
474
+
475
+ def purge_confirm(dests: list[str], scope: str) -> Markup:
476
+ """Step-up-unlock confirm page for a bulk queue purge. Lists exactly the destinations the server
477
+ re-derived as stopped-and-quiesced (purge-eligible) for ``scope`` and offers a same-origin POST form
478
+ to ``/ui/connections/purge-bulk``. With nothing eligible (e.g. the selection was dropped across a
479
+ step-up re-auth, or every target has since re-started), it renders NO form — the operator returns and
480
+ re-selects (fail-safe: a destructive op is never pre-armed)."""
481
+ if not dests:
482
+ body = el(
483
+ "div",
484
+ el("h1", "Purge queued deliveries"),
485
+ el(
486
+ "p",
487
+ "No stopped, quiesced outbound is selected. Stop an outbound and let it quiesce, "
488
+ "then select it and choose Purge again.",
489
+ class_="muted",
490
+ ),
491
+ el("p", el("a", "← Connections", href="/ui")),
492
+ class_="card",
493
+ )
494
+ return page("Confirm purge", body, active="dashboard")
495
+ fields: list[object] = [el("input", type="hidden", name="scope", value=scope)]
496
+ fields.extend(el("input", type="hidden", name="dest", value=d) for d in dests)
497
+ form = el(
498
+ "form",
499
+ *fields,
500
+ el("button", f"Purge {scope}", type="submit"),
501
+ method="post",
502
+ action="/ui/connections/purge-bulk",
503
+ class_="ctl",
504
+ )
505
+ body = el(
506
+ "div",
507
+ el("h1", "Purge queued deliveries"),
508
+ el(
509
+ "p",
510
+ f"This soft-cancels the {scope} queued deliveries to the following stopped outbound(s). "
511
+ "This cannot be undone.",
512
+ class_="muted",
513
+ ),
514
+ el("ul", *[el("li", text(d)) for d in dests]),
515
+ form,
516
+ el("p", el("a", "← Cancel", href="/ui")),
517
+ class_="card",
518
+ )
519
+ return page("Confirm purge", body, active="dashboard")