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,480 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """User/RBAC administration pages for the /ui ops dashboard (ADR 0065, L4a).
4
+
5
+ Every builder returns escaped markup via :mod:`.._html` — an admin-entered display name or AD group
6
+ name can never inject markup. The body-carrying forms here (create user, set roles, custom roles, AD
7
+ maps) are the reason the step-up-to-unlock primitive exists: each **form page** is registered as an
8
+ ``unlock`` action, so it always opens inside a fresh step-up window and its POST is submitted once,
9
+ same-origin, never crossing ``/ui/reauth``. A password appears in exactly one place — the create-user
10
+ password ``<input>`` — and is never echoed back into a re-rendered form.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from collections.abc import Iterable, Sequence
16
+
17
+ from messagefoundry.api.auth_models import (
18
+ AdGroupMapEntry,
19
+ AdGroupScopeEntry,
20
+ CustomRoleInfo,
21
+ RoleInfo,
22
+ UserSummary,
23
+ )
24
+
25
+ from .._html import Markup, el, page, register_nav, rows_table
26
+
27
+ __all__ = [
28
+ "ad_groups_page",
29
+ "role_form_page",
30
+ "roles_page",
31
+ "temp_password_page",
32
+ "user_detail_page",
33
+ "user_new_page",
34
+ "users_page",
35
+ ]
36
+
37
+
38
+ def _banner(error: str | None) -> Markup:
39
+ return el("p", error, class_="banner") if error else Markup("")
40
+
41
+
42
+ def _admin_links(active_page: str) -> Markup:
43
+ """The admin area's cross-links (one nav entry, three pages)."""
44
+ links: list[object] = []
45
+ for href, label, key in (
46
+ ("/ui/users", "Users", "users"),
47
+ ("/ui/roles", "Roles", "roles"),
48
+ ("/ui/ad-groups", "AD group mappings", "ad-groups"),
49
+ ):
50
+ links.append(el("a", label, href=href, class_="active" if key == active_page else None))
51
+ links.append(Markup(" "))
52
+ return el("p", *links, class_="muted")
53
+
54
+
55
+ # --- users --------------------------------------------------------------------
56
+
57
+
58
+ def users_page(users: Sequence[UserSummary]) -> Markup:
59
+ """The user list: every account with provider, roles, scope, and status; links to the admin forms."""
60
+ rows: list[list[object]] = []
61
+ for u in users:
62
+ rows.append(
63
+ [
64
+ el("a", u.username, href=f"/ui/users/{u.id}"),
65
+ u.auth_provider,
66
+ u.display_name or "",
67
+ u.email or "",
68
+ ", ".join(u.roles),
69
+ "all" if u.channel_scope is None else ", ".join(u.channel_scope) or "(none)",
70
+ "disabled" if u.disabled else "active",
71
+ ]
72
+ )
73
+ return page(
74
+ "Users",
75
+ el("h1", "Users"),
76
+ _admin_links("users"),
77
+ el("p", el("a", "+ New user", href="/ui/users/new")),
78
+ rows_table(
79
+ ["Username", "Provider", "Display name", "Email", "Roles", "Channel scope", "Status"],
80
+ rows,
81
+ ),
82
+ active="users",
83
+ )
84
+
85
+
86
+ def _role_checkboxes(roles: Iterable[RoleInfo], checked: Iterable[str]) -> list[object]:
87
+ """One labelled checkbox per assignable role (name=roles, value=<role id>)."""
88
+ chosen = set(checked)
89
+ boxes: list[object] = []
90
+ for role in roles:
91
+ boxes.append(
92
+ el(
93
+ "label",
94
+ el(
95
+ "input",
96
+ type="checkbox",
97
+ name="roles",
98
+ value=role.id,
99
+ checked=role.id in chosen,
100
+ ),
101
+ f" {role.display_name} ({role.id})",
102
+ class_="check",
103
+ )
104
+ )
105
+ return boxes
106
+
107
+
108
+ def user_new_page(
109
+ roles: Sequence[RoleInfo],
110
+ *,
111
+ error: str | None = None,
112
+ username: str = "",
113
+ display_name: str = "",
114
+ email: str = "",
115
+ checked: Iterable[str] = (),
116
+ ) -> Markup:
117
+ """The create-user form (an ``unlock`` page: it always opens inside a fresh step-up window).
118
+
119
+ On a rejected submit the form is re-rendered with the non-secret fields preserved — the password
120
+ field is always empty (a password is never echoed back into markup).
121
+ """
122
+ form = el(
123
+ "form",
124
+ el("label", "Username", el("input", name="username", value=username, autofocus=True)),
125
+ el(
126
+ "label",
127
+ "Initial password",
128
+ el("input", name="password", type="password", autocomplete="new-password"),
129
+ ),
130
+ el(
131
+ "p",
132
+ "Convey the initial password out-of-band; the user must change it at first sign-in.",
133
+ class_="muted",
134
+ ),
135
+ el("label", "Display name", el("input", name="display_name", value=display_name)),
136
+ el("label", "Email", el("input", name="email", value=email)),
137
+ el("fieldset", el("legend", "Roles"), *_role_checkboxes(roles, checked)),
138
+ el("button", "Create user", type="submit"),
139
+ method="post",
140
+ action="/ui/users",
141
+ class_="login",
142
+ )
143
+ body = el("div", el("h1", "New user"), _banner(error), form, class_="card")
144
+ return page(
145
+ "New user",
146
+ body,
147
+ el("p", el("a", "← Users", href="/ui/users")),
148
+ active="users",
149
+ )
150
+
151
+
152
+ def user_detail_page(
153
+ user: UserSummary,
154
+ roles: Sequence[RoleInfo],
155
+ *,
156
+ error: str | None = None,
157
+ ) -> Markup:
158
+ """One user's admin page (an ``unlock`` page): profile, roles, channel scope, and account actions.
159
+
160
+ AD accounts get their roles from the AD-group map and their password from the directory, so those
161
+ forms are replaced by notes (the JSON handlers refuse them anyway — this just mirrors the contract).
162
+ """
163
+ is_ad = user.auth_provider == "ad"
164
+ profile = el(
165
+ "form",
166
+ el(
167
+ "label",
168
+ "Display name",
169
+ el("input", name="display_name", value=user.display_name or ""),
170
+ ),
171
+ el("label", "Email", el("input", name="email", value=user.email or "")),
172
+ el(
173
+ "label",
174
+ el("input", type="checkbox", name="disabled", checked=user.disabled),
175
+ " Disabled (sign-in refused)",
176
+ class_="check",
177
+ ),
178
+ el("button", "Save profile", type="submit"),
179
+ method="post",
180
+ action=f"/ui/users/{user.id}/update",
181
+ class_="ctl",
182
+ )
183
+ if is_ad:
184
+ roles_section: Markup = el("p", "AD users get roles from the AD-group map.", class_="muted")
185
+ else:
186
+ roles_section = el(
187
+ "form",
188
+ *_role_checkboxes(roles, user.roles),
189
+ el("button", "Save roles", type="submit"),
190
+ method="post",
191
+ action=f"/ui/users/{user.id}/roles",
192
+ class_="ctl",
193
+ )
194
+ # Three explicit scope states so a deny-all ([]) scope ROUND-TRIPS: an empty textarea alone is
195
+ # ambiguous between "all channels" (None) and "no channels" ([]), and silently widening a stored
196
+ # deny-all to all-channels on a re-save would be a privilege-widening bug (review PR2-M3).
197
+ scope_mode = (
198
+ "all" if user.channel_scope is None else ("none" if user.channel_scope == [] else "list")
199
+ )
200
+ mode_options = [
201
+ el("option", label, value=value, selected=value == scope_mode or None)
202
+ for value, label in (
203
+ ("all", "All channels"),
204
+ ("list", "Only the connections listed below"),
205
+ ("none", "No channels (deny all)"),
206
+ )
207
+ ]
208
+ scope = el(
209
+ "form",
210
+ el("label", "Scope", el("select", *mode_options, name="scope_mode")),
211
+ el(
212
+ "label",
213
+ "Allowed connections (one per line)",
214
+ el("textarea", "\n".join(user.channel_scope or []), name="channels", rows=4),
215
+ ),
216
+ el("button", "Save channel scope", type="submit"),
217
+ method="post",
218
+ action=f"/ui/users/{user.id}/channel-scope",
219
+ class_="ctl",
220
+ )
221
+ danger: list[object] = []
222
+ if not is_ad:
223
+ danger.append(
224
+ el(
225
+ "form",
226
+ el("button", "Reset password", type="submit"),
227
+ method="post",
228
+ action=f"/ui/users/{user.id}/reset-password",
229
+ class_="ctl",
230
+ )
231
+ )
232
+ danger.append(
233
+ el(
234
+ "form",
235
+ el("button", "Reset MFA", type="submit"),
236
+ method="post",
237
+ action=f"/ui/users/{user.id}/reset-mfa",
238
+ class_="ctl",
239
+ )
240
+ )
241
+ danger.append(
242
+ el(
243
+ "form",
244
+ el("button", "Sign out all sessions", type="submit"),
245
+ method="post",
246
+ action=f"/ui/users/{user.id}/revoke-sessions",
247
+ class_="ctl",
248
+ )
249
+ )
250
+ danger.append(
251
+ el(
252
+ "form",
253
+ el("button", "Delete user", type="submit"),
254
+ method="post",
255
+ action=f"/ui/users/{user.id}/delete",
256
+ class_="ctl",
257
+ )
258
+ )
259
+ return page(
260
+ f"User {user.username}",
261
+ el("h1", f"User: {user.username}"),
262
+ el(
263
+ "p",
264
+ f"Provider: {user.auth_provider} — {'disabled' if user.disabled else 'active'}",
265
+ class_="muted",
266
+ ),
267
+ _banner(error),
268
+ el("div", el("h2", "Profile"), profile, class_="card"),
269
+ el("div", el("h2", "Roles"), roles_section, class_="card"),
270
+ el("div", el("h2", "Channel scope"), scope, class_="card"),
271
+ el("div", el("h2", "Account actions"), *danger, class_="card"),
272
+ el("p", el("a", "← Users", href="/ui/users")),
273
+ active="users",
274
+ )
275
+
276
+
277
+ def temp_password_page(username: str, temp_password: str) -> Markup:
278
+ """The one-time result of an admin password reset — shown once, never stored or logged."""
279
+ body = el(
280
+ "div",
281
+ el("h1", "Temporary password issued"),
282
+ el(
283
+ "p",
284
+ f"Convey this to {username} out-of-band. It is shown once and must be changed at "
285
+ "first sign-in.",
286
+ class_="muted",
287
+ ),
288
+ el("p", el("code", temp_password)),
289
+ el("p", el("a", "← Users", href="/ui/users")),
290
+ class_="card",
291
+ )
292
+ return page("Temporary password", body, active="users")
293
+
294
+
295
+ # --- roles ---------------------------------------------------------------------
296
+
297
+
298
+ def roles_page(roles: Sequence[RoleInfo]) -> Markup:
299
+ """All assignable roles: the six fixed built-ins plus admin-defined custom roles (ADR 0045)."""
300
+ rows: list[list[object]] = []
301
+ for role in roles:
302
+ name: object = (
303
+ role.display_name
304
+ if role.builtin
305
+ else el("a", role.display_name, href=f"/ui/roles/{role.id}/edit")
306
+ )
307
+ rows.append(
308
+ [
309
+ name,
310
+ role.id,
311
+ "built-in" if role.builtin else "custom",
312
+ ", ".join(role.permissions),
313
+ ]
314
+ )
315
+ return page(
316
+ "Roles",
317
+ el("h1", "Roles"),
318
+ _admin_links("roles"),
319
+ el("p", el("a", "+ New custom role", href="/ui/roles/new")),
320
+ el(
321
+ "p",
322
+ "Built-in roles are fixed; a custom role grants a named subset of the permission "
323
+ "catalog (never user administration, approvals, or DR).",
324
+ class_="muted",
325
+ ),
326
+ rows_table(["Role", "Id", "Type", "Permissions"], rows),
327
+ active="users",
328
+ )
329
+
330
+
331
+ def _permission_checkboxes(catalog: Sequence[str], checked: Iterable[str]) -> list[object]:
332
+ chosen = set(checked)
333
+ return [
334
+ el(
335
+ "label",
336
+ el("input", type="checkbox", name="permissions", value=perm, checked=perm in chosen),
337
+ f" {perm}",
338
+ class_="check",
339
+ )
340
+ for perm in catalog
341
+ ]
342
+
343
+
344
+ def role_form_page(
345
+ catalog: Sequence[str],
346
+ *,
347
+ role: CustomRoleInfo | None = None,
348
+ error: str | None = None,
349
+ display_name: str | None = None,
350
+ description: str | None = None,
351
+ checked: Iterable[str] | None = None,
352
+ ) -> Markup:
353
+ """Create (``role is None``) or edit a custom role (an ``unlock`` page).
354
+
355
+ ``display_name``/``description``/``checked`` override the role's stored values when re-rendering a
356
+ rejected submit, so typed input isn't lost.
357
+ """
358
+ name_value = display_name if display_name is not None else (role.display_name if role else "")
359
+ desc_value = (
360
+ description if description is not None else (role.description or "" if role else "")
361
+ )
362
+ perm_checked = checked if checked is not None else (role.permissions if role else ())
363
+ action = f"/ui/roles/custom/{role.id}/update" if role else "/ui/roles/custom"
364
+ form = el(
365
+ "form",
366
+ el("label", "Name", el("input", name="display_name", value=name_value, autofocus=True)),
367
+ el("label", "Description", el("input", name="description", value=desc_value)),
368
+ el("fieldset", el("legend", "Permissions"), *_permission_checkboxes(catalog, perm_checked)),
369
+ el("button", "Save role" if role else "Create role", type="submit"),
370
+ method="post",
371
+ action=action,
372
+ class_="login",
373
+ )
374
+ extras: list[object] = []
375
+ if role:
376
+ extras.append(
377
+ el(
378
+ "form",
379
+ el("button", "Delete role", type="submit"),
380
+ method="post",
381
+ action=f"/ui/roles/custom/{role.id}/delete",
382
+ class_="ctl",
383
+ )
384
+ )
385
+ title = "Edit custom role" if role else "New custom role"
386
+ body = el("div", el("h1", title), _banner(error), form, *extras, class_="card")
387
+ return page(title, body, el("p", el("a", "← Roles", href="/ui/roles")), active="users")
388
+
389
+
390
+ # --- AD group mappings -----------------------------------------------------------
391
+
392
+
393
+ def _map_rows(
394
+ first_name: str,
395
+ pairs: Sequence[tuple[str, Markup]],
396
+ blank: Markup,
397
+ blanks: int = 3,
398
+ ) -> list[object]:
399
+ """Rows of paired inputs (existing entries + a few blank rows; empty rows are ignored on save)."""
400
+ rows: list[object] = []
401
+ for value, partner in pairs:
402
+ rows.append(el("div", el("input", name=first_name, value=value), partner, class_="maprow"))
403
+ for _ in range(blanks):
404
+ rows.append(el("div", el("input", name=first_name, value=""), blank, class_="maprow"))
405
+ return rows
406
+
407
+
408
+ def _role_select(roles: Sequence[RoleInfo], selected: str = "") -> Markup:
409
+ options: list[object] = [el("option", "", value="")]
410
+ for role in roles:
411
+ options.append(el("option", role.id, value=role.id, selected=role.id == selected or None))
412
+ return el("select", *options, name="role")
413
+
414
+
415
+ def ad_groups_page(
416
+ map_entries: Sequence[AdGroupMapEntry],
417
+ scope_entries: Sequence[AdGroupScopeEntry],
418
+ roles: Sequence[RoleInfo],
419
+ *,
420
+ error: str | None = None,
421
+ ) -> Markup:
422
+ """Both AD-group mappings (group→role and group→channel scope), each saved as a full replacement.
423
+
424
+ Rows with an empty group or an unselected role/channel are dropped on save — the blank tail rows
425
+ exist to add entries without any client-side scripting (CSP: no inline JS).
426
+ """
427
+ role_rows = _map_rows(
428
+ "ad_group",
429
+ [(e.ad_group, _role_select(roles, e.role)) for e in map_entries],
430
+ _role_select(roles),
431
+ )
432
+ role_form = el(
433
+ "form",
434
+ *role_rows,
435
+ el("button", "Save role map", type="submit"),
436
+ method="post",
437
+ action="/ui/ad-groups/map",
438
+ class_="ctl",
439
+ )
440
+ scope_rows = _map_rows(
441
+ "ad_group",
442
+ [
443
+ (e.ad_group, el("input", name="channel", value=e.channel, placeholder="channel or *"))
444
+ for e in scope_entries
445
+ ],
446
+ el("input", name="channel", value="", placeholder="channel or *"),
447
+ )
448
+ scope_form = el(
449
+ "form",
450
+ *scope_rows,
451
+ el("button", "Save scope map", type="submit"),
452
+ method="post",
453
+ action="/ui/ad-groups/scope-map",
454
+ class_="ctl",
455
+ )
456
+ return page(
457
+ "AD group mappings",
458
+ el("h1", "AD group mappings"),
459
+ _admin_links("ad-groups"),
460
+ _banner(error),
461
+ el(
462
+ "div",
463
+ el("h2", "Group → role"),
464
+ el("p", "AD users receive the mapped roles at sign-in.", class_="muted"),
465
+ role_form,
466
+ class_="card",
467
+ ),
468
+ el(
469
+ "div",
470
+ el("h2", "Group → channel scope"),
471
+ el("p", "Channel ", el("code", "*"), " grants all channels.", class_="muted"),
472
+ scope_form,
473
+ class_="card",
474
+ ),
475
+ active="users",
476
+ )
477
+
478
+
479
+ # Nav registration (append-at-tail): ONE entry for the admin area; Roles + AD mappings cross-link.
480
+ register_nav("users", "/ui/users", "Users")
@@ -0,0 +1,66 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """Audit + self-service security-event page builders for the /ui ops dashboard (ADR 0065, L1c).
4
+
5
+ Read-only views over the tamper-evident audit log: the full audit trail (``audit:read``) and the
6
+ caller's own ``auth.*`` security-event history (self-service). Both are metadata-only — the audit
7
+ ``detail`` is PHI-free JSON — but every value is still placed through the escaping element builders in
8
+ :mod:`.._html`, so an actor/action/detail string can never inject markup.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from datetime import UTC, datetime
14
+
15
+ from messagefoundry.api.auth_models import AuditList, SecurityEventsList
16
+
17
+ from .._html import Markup, el, page, register_nav, rows_table
18
+
19
+ __all__ = ["audit_log", "security_events"]
20
+
21
+
22
+ def _ts(ts: float) -> str:
23
+ """Render an epoch timestamp as a UTC ISO string (seconds); the raw float is opaque to operators."""
24
+ return datetime.fromtimestamp(ts, UTC).strftime("%Y-%m-%d %H:%M:%SZ")
25
+
26
+
27
+ def audit_log(data: AuditList) -> Markup:
28
+ """The full audit trail (``audit:read``): actor, action, channel, and PHI-free detail, newest first."""
29
+ rows = [
30
+ [_ts(e.ts), e.actor or "—", e.action, e.channel_id or "—", e.detail or ""]
31
+ for e in data.entries
32
+ ]
33
+ return page(
34
+ "Audit",
35
+ el("h1", "Audit log"),
36
+ el(
37
+ "p",
38
+ "The tamper-evident audit trail (metadata only — no PHI). Most recent first.",
39
+ class_="muted",
40
+ ),
41
+ rows_table(["When", "Actor", "Action", "Channel", "Detail"], rows),
42
+ active="audit",
43
+ )
44
+
45
+
46
+ def security_events(data: SecurityEventsList) -> Markup:
47
+ """The caller's OWN security-event history (self-service): sign-ins, lockouts, password/MFA
48
+ changes on their account, newest first. No permission needed beyond a valid session."""
49
+ rows = [[_ts(e.ts), e.action, e.detail or ""] for e in data.events]
50
+ return page(
51
+ "My security events",
52
+ el("h1", "My security events"),
53
+ el(
54
+ "p",
55
+ "Recent security-relevant activity on your account (sign-ins, lockouts, credential "
56
+ "changes). Most recent first.",
57
+ class_="muted",
58
+ ),
59
+ rows_table(["When", "Event", "Detail"], rows),
60
+ active="security-events",
61
+ )
62
+
63
+
64
+ # Nav registration (append-at-tail). Co-located with the builders (ADR 0065 §multi-session-build).
65
+ register_nav("audit", "/ui/audit", "Audit")
66
+ register_nav("security-events", "/ui/security-events", "My security events")
@@ -0,0 +1,113 @@
1
+ # SPDX-License-Identifier: AGPL-3.0-or-later
2
+ # Copyright (C) 2026 MessageFoundry Organization and contributors
3
+ """Config-deploy page for the /ui ops dashboard (ADR 0065).
4
+
5
+ STRICTLY within BACKLOG #26 (no visual/template authoring): the only action is to **reload** the
6
+ engine's already-configured code-first graph from its **own startup directory** — never a user-supplied
7
+ path — showing pass/fail. There is deliberately NO module-content editor, NO filesystem picker, and NO
8
+ dry-run diff here; authoring lives in the VS Code extension. Every value is escaped by the builders.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from messagefoundry.api.models import ConfigProvenance, ReloadResult
14
+
15
+ from .._html import Markup, el, page, register_nav, rows_table, text
16
+
17
+ __all__ = ["config_page", "reload_pending", "reload_result"]
18
+
19
+
20
+ def _provenance_badge(prov: ConfigProvenance | None) -> Markup:
21
+ """A small 'running commit — clean|DRIFTED' pill (ADR 0041 D1). Empty when the engine hasn't loaded
22
+ a graph yet or provenance is unavailable, so the page renders unchanged on an older engine."""
23
+ if prov is None or not prov.loaded:
24
+ return Markup("")
25
+ if prov.git_head:
26
+ ident = f"commit {prov.git_head[:7]}"
27
+ elif prov.fingerprint:
28
+ ident = f"fingerprint {prov.fingerprint[:12]}"
29
+ else:
30
+ ident = "loaded"
31
+ drifted = bool(prov.drift)
32
+ pill = el(
33
+ "span",
34
+ "DRIFTED" if drifted else "clean",
35
+ class_=f"status status-{'error' if drifted else 'running'}",
36
+ )
37
+ note = (
38
+ " — the config on disk differs from the running graph; reload to apply"
39
+ if drifted
40
+ else " — the running graph matches the config on disk"
41
+ )
42
+ return el("p", text(f"Running config: {ident} "), pill, text(note), class_="muted")
43
+
44
+
45
+ def config_page(prov: ConfigProvenance | None = None) -> Markup:
46
+ """The config-deploy page: a single 'Reload configuration' action (server's startup dir only), with
47
+ a read-only provenance badge (running commit + clean/DRIFTED) when available (ADR 0041 D1)."""
48
+ reload_form = el(
49
+ "form",
50
+ el("button", "Reload configuration", type="submit"),
51
+ method="post",
52
+ action="/ui/config/reload",
53
+ class_="ctl",
54
+ )
55
+ return page(
56
+ "Configuration",
57
+ el("h1", "Configuration"),
58
+ _provenance_badge(prov),
59
+ el(
60
+ "p",
61
+ "Reload the engine's code-first graph from its configured startup directory "
62
+ "(quiesce-and-swap; in-flight deliveries keep draining). Authoring lives in the VS Code "
63
+ "extension — this only redeploys what is already staged. Reload is step-up-gated and may be "
64
+ "held for a second approver (dual-control).",
65
+ class_="muted",
66
+ ),
67
+ reload_form,
68
+ active="config",
69
+ )
70
+
71
+
72
+ def reload_result(result: ReloadResult) -> Markup:
73
+ """The outcome of a successful config reload — the element counts of the now-live graph."""
74
+ tbl = rows_table(
75
+ ["Element", "Count"],
76
+ [
77
+ ["Inbound connections", result.inbound],
78
+ ["Outbound connections", result.outbound],
79
+ ["Routers", result.routers],
80
+ ],
81
+ adjustable=False,
82
+ )
83
+ body = el(
84
+ "div",
85
+ el("h1", "Configuration reloaded"),
86
+ el("p", "The code-first graph is now live.", class_="muted"),
87
+ tbl,
88
+ el("p", el("a", "← Configuration", href="/ui/config")),
89
+ class_="card",
90
+ )
91
+ return page("Reloaded", body, active="config")
92
+
93
+
94
+ def reload_pending(pending: object) -> Markup:
95
+ """Shown when a config reload is held for a second approver (dual-control, ADR 0041)."""
96
+ approval_id = getattr(pending, "approval_id", "")
97
+ body = el(
98
+ "div",
99
+ el("h1", "Reload held for approval"),
100
+ el(
101
+ "p",
102
+ "This configuration reload is held for a second approver (dual-control).",
103
+ class_="muted",
104
+ ),
105
+ el("p", text(f"Approval id: {approval_id}"), class_="muted"),
106
+ el("p", el("a", "← Configuration", href="/ui/config")),
107
+ class_="card",
108
+ )
109
+ return page("Pending approval", body, active="config")
110
+
111
+
112
+ # Nav registration (append-at-tail). Co-located with the builders (ADR 0065 §multi-session-build).
113
+ register_nav("config", "/ui/config", "Configuration")