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,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")
|