appmanager-server 0.2.0__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 (89) hide show
  1. appmanager/__init__.py +52 -0
  2. appmanager/admin/__init__.py +3 -0
  3. appmanager/admin/app_installer.py +739 -0
  4. appmanager/admin/members.py +95 -0
  5. appmanager/admin/registry.py +372 -0
  6. appmanager/admin/routes.py +900 -0
  7. appmanager/api/__init__.py +5 -0
  8. appmanager/api/routes.py +373 -0
  9. appmanager/app_config.py +101 -0
  10. appmanager/auth/__init__.py +3 -0
  11. appmanager/auth/oauth.py +77 -0
  12. appmanager/auth/routes.py +224 -0
  13. appmanager/auth/utils.py +175 -0
  14. appmanager/bridge.py +90 -0
  15. appmanager/cli.py +1171 -0
  16. appmanager/config.py +58 -0
  17. appmanager/database.py +120 -0
  18. appmanager/extension.py +198 -0
  19. appmanager/extensions.py +225 -0
  20. appmanager/health.py +133 -0
  21. appmanager/hooks.py +187 -0
  22. appmanager/middleware.py +256 -0
  23. appmanager/models.py +307 -0
  24. appmanager/py.typed +1 -0
  25. appmanager/sdk.py +30 -0
  26. appmanager/security.py +227 -0
  27. appmanager/security_scanner.py +582 -0
  28. appmanager/signals.py +21 -0
  29. appmanager/static/css/app.css +808 -0
  30. appmanager/static/vendor/fonts/fonts.css +288 -0
  31. appmanager/static/vendor/fonts/jetbrainsmono-0.woff2 +0 -0
  32. appmanager/static/vendor/fonts/jetbrainsmono-1.woff2 +0 -0
  33. appmanager/static/vendor/fonts/jetbrainsmono-10.woff2 +0 -0
  34. appmanager/static/vendor/fonts/jetbrainsmono-11.woff2 +0 -0
  35. appmanager/static/vendor/fonts/jetbrainsmono-12.woff2 +0 -0
  36. appmanager/static/vendor/fonts/jetbrainsmono-13.woff2 +0 -0
  37. appmanager/static/vendor/fonts/jetbrainsmono-14.woff2 +0 -0
  38. appmanager/static/vendor/fonts/jetbrainsmono-15.woff2 +0 -0
  39. appmanager/static/vendor/fonts/jetbrainsmono-16.woff2 +0 -0
  40. appmanager/static/vendor/fonts/jetbrainsmono-17.woff2 +0 -0
  41. appmanager/static/vendor/fonts/jetbrainsmono-18.woff2 +0 -0
  42. appmanager/static/vendor/fonts/jetbrainsmono-19.woff2 +0 -0
  43. appmanager/static/vendor/fonts/jetbrainsmono-2.woff2 +0 -0
  44. appmanager/static/vendor/fonts/jetbrainsmono-20.woff2 +0 -0
  45. appmanager/static/vendor/fonts/jetbrainsmono-21.woff2 +0 -0
  46. appmanager/static/vendor/fonts/jetbrainsmono-22.woff2 +0 -0
  47. appmanager/static/vendor/fonts/jetbrainsmono-23.woff2 +0 -0
  48. appmanager/static/vendor/fonts/jetbrainsmono-3.woff2 +0 -0
  49. appmanager/static/vendor/fonts/jetbrainsmono-4.woff2 +0 -0
  50. appmanager/static/vendor/fonts/jetbrainsmono-5.woff2 +0 -0
  51. appmanager/static/vendor/fonts/jetbrainsmono-6.woff2 +0 -0
  52. appmanager/static/vendor/fonts/jetbrainsmono-7.woff2 +0 -0
  53. appmanager/static/vendor/fonts/jetbrainsmono-8.woff2 +0 -0
  54. appmanager/static/vendor/fonts/jetbrainsmono-9.woff2 +0 -0
  55. appmanager/static/vendor/fonts/spacegrotesk-0.woff2 +0 -0
  56. appmanager/static/vendor/fonts/spacegrotesk-1.woff2 +0 -0
  57. appmanager/static/vendor/fonts/spacegrotesk-10.woff2 +0 -0
  58. appmanager/static/vendor/fonts/spacegrotesk-11.woff2 +0 -0
  59. appmanager/static/vendor/fonts/spacegrotesk-2.woff2 +0 -0
  60. appmanager/static/vendor/fonts/spacegrotesk-3.woff2 +0 -0
  61. appmanager/static/vendor/fonts/spacegrotesk-4.woff2 +0 -0
  62. appmanager/static/vendor/fonts/spacegrotesk-5.woff2 +0 -0
  63. appmanager/static/vendor/fonts/spacegrotesk-6.woff2 +0 -0
  64. appmanager/static/vendor/fonts/spacegrotesk-7.woff2 +0 -0
  65. appmanager/static/vendor/fonts/spacegrotesk-8.woff2 +0 -0
  66. appmanager/static/vendor/fonts/spacegrotesk-9.woff2 +0 -0
  67. appmanager/static/vendor/htmx/htmx.min.js +1 -0
  68. appmanager/templates/admin/_member_drawer.html +98 -0
  69. appmanager/templates/admin/_members_table.html +108 -0
  70. appmanager/templates/admin/app_detail.html +136 -0
  71. appmanager/templates/admin/dashboard.html +902 -0
  72. appmanager/templates/admin/flairs.html +90 -0
  73. appmanager/templates/admin/permissions.html +88 -0
  74. appmanager/templates/admin/roles.html +119 -0
  75. appmanager/templates/admin/users.html +156 -0
  76. appmanager/templates/auth/login.html +58 -0
  77. appmanager/templates/auth/profile.html +44 -0
  78. appmanager/templates/base.html +76 -0
  79. appmanager/templates/errors/401.html +19 -0
  80. appmanager/templates/errors/403.html +19 -0
  81. appmanager/templates/errors/404.html +18 -0
  82. appmanager/templates/errors/500.html +19 -0
  83. appmanager/templates/index.html +54 -0
  84. appmanager_server-0.2.0.dist-info/METADATA +320 -0
  85. appmanager_server-0.2.0.dist-info/RECORD +89 -0
  86. appmanager_server-0.2.0.dist-info/WHEEL +5 -0
  87. appmanager_server-0.2.0.dist-info/entry_points.txt +5 -0
  88. appmanager_server-0.2.0.dist-info/licenses/LICENSE +21 -0
  89. appmanager_server-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,95 @@
1
+ """
2
+ Member list/detail query helpers for the admin console.
3
+
4
+ Keeps the member-management data access out of routes.py so the
5
+ pagination/search/aggregation logic is testable in isolation and the
6
+ N+1 permission lookups are contained in one place.
7
+ """
8
+
9
+ from sqlalchemy import func, or_
10
+
11
+ from appmanager.database import db
12
+ from appmanager.models import InstalledApp, User, UserAppPermission
13
+
14
+ DEFAULT_PER_PAGE = 25
15
+ PER_PAGE_CHOICES = (25, 50, 100)
16
+
17
+
18
+ def list_members(q=None, role=None, page=1, per_page=DEFAULT_PER_PAGE, sort="last_login"):
19
+ """
20
+ Server-side paginated + filtered member list.
21
+
22
+ Returns a SQLAlchemy Pagination object. Never loads the whole table.
23
+ """
24
+ if per_page not in PER_PAGE_CHOICES:
25
+ per_page = DEFAULT_PER_PAGE
26
+
27
+ query = User.query
28
+
29
+ if q:
30
+ like = f"%{q.strip()}%"
31
+ query = query.filter(or_(User.name.ilike(like), User.email.ilike(like)))
32
+ if role:
33
+ query = query.filter_by(role=role)
34
+
35
+ if sort == "name":
36
+ query = query.order_by(User.name.asc())
37
+ elif sort == "email":
38
+ query = query.order_by(User.email.asc())
39
+ elif sort == "created":
40
+ query = query.order_by(User.created_at.desc())
41
+ elif sort == "logins":
42
+ query = query.order_by(User.login_count.desc())
43
+ else: # last_login
44
+ query = query.order_by(User.last_login_at.desc().nullslast())
45
+
46
+ return query.paginate(page=page, per_page=per_page, error_out=False)
47
+
48
+
49
+ def app_access_counts(user_ids):
50
+ """
51
+ One aggregate query: {user_id: count_of_apps_they_can_access}.
52
+ Replaces the per-user N+1 permission lookups in the member table.
53
+ """
54
+ if not user_ids:
55
+ return {}
56
+ rows = (
57
+ db.session.query(UserAppPermission.user_id, func.count(UserAppPermission.id))
58
+ .filter(UserAppPermission.user_id.in_(user_ids))
59
+ .filter(UserAppPermission.can_access.is_(True))
60
+ .group_by(UserAppPermission.user_id)
61
+ .all()
62
+ )
63
+ return {uid: cnt for uid, cnt in rows}
64
+
65
+
66
+ def total_app_count():
67
+ """Number of installed apps (for the 'x of N apps' readout)."""
68
+ return InstalledApp.query.count()
69
+
70
+
71
+ def member_permissions(user_id):
72
+ """
73
+ Full per-app access list for a single member (used by the edit drawer).
74
+ Returns list of dicts: {app_id, name, slug, can_access, inherited}.
75
+ """
76
+ user = db.session.get(User, user_id)
77
+ if not user:
78
+ return []
79
+ apps = InstalledApp.query.order_by(InstalledApp.name.asc()).all()
80
+ perms = {
81
+ p.app_id: p.can_access for p in UserAppPermission.query.filter_by(user_id=user_id).all()
82
+ }
83
+ inherited = user.is_admin()
84
+ result = []
85
+ for a in apps:
86
+ result.append(
87
+ {
88
+ "app_id": a.id,
89
+ "name": a.name,
90
+ "slug": a.slug,
91
+ "can_access": perms.get(a.id, True),
92
+ "inherited": inherited,
93
+ }
94
+ )
95
+ return result
@@ -0,0 +1,372 @@
1
+ """
2
+ Extension admin registry — the manifest contract.
3
+
4
+ Each installed app can declare its own admin surface in its `manifest.json`:
5
+
6
+ {
7
+ "name": "User Flairs",
8
+ "slug": "user-flairs",
9
+ "type": "extension",
10
+ "admin_sections": [
11
+ {"id": "presets", "label": "Flair presets", "icon": "tag",
12
+ "blueprint": "flairs_admin:presets", "order": 10}
13
+ ],
14
+ "settings_schema": [
15
+ {"key": "max_flairs", "type": "integer", "label": "Max flairs per member", "default": 3}
16
+ ]
17
+ }
18
+
19
+ Two mechanisms:
20
+ * `settings_schema` → the framework renders a generated typed form (zero code).
21
+ * `admin_sections` → the extension ships its own Flask blueprint; the framework
22
+ mounts it at `/admin/apps/<slug>/<panel>` with admin auth
23
+ enforced at mount time (never trusted to the extension).
24
+
25
+ The registry persists declared panels to `app_admin_panels` on install/upgrade
26
+ and provides a kill switch so a broken panel never takes down the rest of /admin.
27
+ """
28
+
29
+ import importlib
30
+ import json
31
+ import os
32
+ import traceback
33
+
34
+ from flask import Blueprint, current_app, redirect, request, url_for
35
+
36
+ from appmanager.database import db
37
+ from appmanager.models import AppAdminPanel, InstalledApp
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # Manifest parsing
41
+ # ---------------------------------------------------------------------------
42
+
43
+
44
+ def parse_manifest(app_dir):
45
+ """Parse manifest.json from an installed app's directory."""
46
+ manifest_path = os.path.join(app_dir, "manifest.json")
47
+ if not os.path.exists(manifest_path):
48
+ return {}
49
+ try:
50
+ with open(manifest_path, "r", encoding="utf-8") as f:
51
+ data = json.load(f)
52
+ return data if isinstance(data, dict) else {}
53
+ except Exception:
54
+ return {}
55
+
56
+
57
+ def app_dir_for(app_record):
58
+ """Resolve the on-disk directory for an installed app."""
59
+ base = current_app.config.get("INSTALLED_APPS_DIR")
60
+ return os.path.join(base, app_record.slug) if base else None
61
+
62
+
63
+ def manifest_for(app_record):
64
+ """Fetch the manifest dict for an installed app (empty if none)."""
65
+ d = app_dir_for(app_record)
66
+ if not d:
67
+ return {}
68
+ return parse_manifest(d)
69
+
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # Persistence
73
+ # ---------------------------------------------------------------------------
74
+
75
+
76
+ def sync_panels(app_record, manifest=None):
77
+ """
78
+ Reconcile `app_admin_panels` rows with the app's manifest. Called on
79
+ install and on upgrade/reload. Returns the list of panel dicts.
80
+ """
81
+ manifest = manifest or manifest_for(app_record)
82
+ declared = manifest.get("admin_sections", []) or []
83
+ # Remove panels no longer declared
84
+ declared_ids = {p.get("id") for p in declared if p.get("id")}
85
+ AppAdminPanel.query.filter_by(app_id=app_record.id).filter(
86
+ ~AppAdminPanel.panel_id.in_(declared_ids) if declared_ids else True
87
+ ).delete(synchronize_session=False)
88
+
89
+ for i, p in enumerate(declared):
90
+ pid = p.get("id")
91
+ if not pid:
92
+ continue
93
+ row = AppAdminPanel.query.filter_by(app_id=app_record.id, panel_id=pid).first()
94
+ if not row:
95
+ row = AppAdminPanel(app_id=app_record.id, panel_id=pid)
96
+ db.session.add(row)
97
+ row.label = p.get("label", pid)
98
+ row.icon = p.get("icon")
99
+ row.endpoint = p.get("blueprint")
100
+ row.sort_order = p.get("order", i * 10)
101
+ db.session.commit()
102
+ return [
103
+ p.to_dict()
104
+ for p in AppAdminPanel.query.filter_by(app_id=app_record.id)
105
+ .order_by(AppAdminPanel.sort_order)
106
+ .all()
107
+ ]
108
+
109
+
110
+ def panels_for(app_record):
111
+ """Ordered list of panel dicts for an app."""
112
+ rows = (
113
+ AppAdminPanel.query.filter_by(app_id=app_record.id).order_by(AppAdminPanel.sort_order).all()
114
+ )
115
+ return [r.to_dict() for r in rows]
116
+
117
+
118
+ def settings_schema_for(app_record):
119
+ """The app's declared settings_schema (list of descriptors)."""
120
+ manifest = manifest_for(app_record)
121
+ schema = manifest.get("settings_schema", []) or []
122
+ return schema if isinstance(schema, list) else []
123
+
124
+
125
+ # ---------------------------------------------------------------------------
126
+ # Blueprint mounting (with admin auth enforced at mount time)
127
+ # ---------------------------------------------------------------------------
128
+
129
+
130
+ def _resolve_blueprint(endpoint_ref):
131
+ """
132
+ Resolve 'module:blueprint_var' or 'module:blueprint_var:endpoint' to a
133
+ (blueprint, endpoint_name) tuple. Returns (None, None) on any failure.
134
+ """
135
+ if not endpoint_ref:
136
+ return None, None
137
+ parts = endpoint_ref.split(":")
138
+ if len(parts) < 2:
139
+ return None, None
140
+ module_path, attr = parts[0], parts[1]
141
+ endpoint = parts[2] if len(parts) > 2 else None
142
+ try:
143
+ module = importlib.import_module(module_path)
144
+ bp = getattr(module, attr)
145
+ if not isinstance(bp, Blueprint):
146
+ return None, None
147
+ return bp, endpoint
148
+ except Exception:
149
+ return None, None
150
+
151
+
152
+ def mount_app_admin_blueprints(app_record, admin_bp):
153
+ """
154
+ Mount an app's declared admin blueprints under `/admin/apps/<slug>/<panel>`.
155
+ Admin auth is enforced via a before_request on the mounted blueprint — the
156
+ extension is never trusted to self-guard. Returns (mounted, errors).
157
+ """
158
+ from appmanager.auth.utils import get_current_user
159
+
160
+ def _admin_guard():
161
+ """before_request guard: 401/redirect unless the caller is an admin."""
162
+ from flask import request
163
+
164
+ user = get_current_user()
165
+ if not user:
166
+ if request.is_json or request.path.startswith("/api/"):
167
+ return {"error": "Authentication required"}, 401
168
+ return redirect(url_for("auth.login", next=request.url))
169
+ if not user.is_admin():
170
+ if request.is_json or request.path.startswith("/api/"):
171
+ return {"error": "Admin privilege required"}, 403
172
+ return redirect(url_for("auth.profile"))
173
+ return None
174
+
175
+ mounted = []
176
+ errors = []
177
+ for panel in panels_for(app_record):
178
+ if not panel.get("endpoint"):
179
+ continue # generated-form panel, no blueprint to mount
180
+ bp, endpoint = _resolve_blueprint(panel["endpoint"])
181
+ if bp is None:
182
+ errors.append(
183
+ f"Panel '{panel['panel_id']}': could not resolve blueprint '{panel['endpoint']}'"
184
+ )
185
+ continue
186
+
187
+ # Namespace the blueprint under /admin/apps/<slug>/<panel_id>
188
+ # (admin_bp already carries the /admin prefix, so nest under /apps/...)
189
+ url_prefix = f"/apps/{app_record.slug}/{panel['panel_id']}"
190
+ bp.url_prefix = url_prefix
191
+ # Enforce admin auth at mount time — non-negotiable
192
+ bp.before_request(_admin_guard)
193
+ try:
194
+ admin_bp.register_blueprint(bp)
195
+ mounted.append(panel["panel_id"])
196
+ except Exception as e:
197
+ errors.append(f"Panel '{panel['panel_id']}': mount failed: {e}")
198
+ return mounted, errors
199
+
200
+
201
+ def mount_all_app_admin_blueprints(admin_bp):
202
+ """
203
+ Mount admin blueprints for every installed app that declares them.
204
+ Called once at startup. Failures are logged, never fatal.
205
+ """
206
+ apps = InstalledApp.query.all()
207
+ for a in apps:
208
+ try:
209
+ # Reconcile declared panels with the manifest on every startup so
210
+ # manifest edits are picked up without a manual reinstall.
211
+ sync_panels(a)
212
+ mount_app_admin_blueprints(a, admin_bp)
213
+ except Exception as e:
214
+ print(f"[ADMIN REGISTRY] Failed to mount panels for '{a.slug}': {e}")
215
+
216
+
217
+ # ---------------------------------------------------------------------------
218
+ # Generated settings form (settings_schema → form)
219
+ # ---------------------------------------------------------------------------
220
+
221
+
222
+ def render_settings_form(app_record, configs):
223
+ """
224
+ Render a generated settings form from the app's settings_schema.
225
+ Returns HTML string, or None if the app has no schema.
226
+ """
227
+ schema = settings_schema_for(app_record)
228
+ if not schema:
229
+ return None
230
+ from markupsafe import Markup
231
+
232
+ from appmanager.app_config import get_configs
233
+
234
+ configs = configs or get_configs(app_record.id)
235
+ rows = []
236
+ for desc in schema:
237
+ key = desc.get("key")
238
+ if not key:
239
+ continue
240
+ label = desc.get("label", key)
241
+ vtype = desc.get("type", "json")
242
+ value = configs.get(key, desc.get("default"))
243
+ rows.append(_schema_field(key, label, vtype, value, desc))
244
+ body = "\n".join(rows)
245
+ return Markup(
246
+ f'<form method="POST" action="/admin/apps/{app_record.slug}/settings" class="schema-form">'
247
+ f'<input type="hidden" name="csrf_token" value="{_csrf()}">'
248
+ f"{body}"
249
+ f'<div class="action-group mt-2"><button type="submit" class="btn btn-primary btn-sm">Save Settings</button></div>'
250
+ f"</form>"
251
+ )
252
+
253
+
254
+ def _csrf():
255
+ try:
256
+ from appmanager.security import generate_csrf_token
257
+
258
+ return generate_csrf_token()
259
+ except Exception:
260
+ return ""
261
+
262
+
263
+ def _schema_field(key, label, vtype, value, desc):
264
+ """Render a single schema field as HTML."""
265
+ if vtype == "boolean":
266
+ checked = " checked" if value else ""
267
+ return (
268
+ f'<div class="form-group"><label class="form-label">{label}</label>'
269
+ f'<label class="flex-center" style="gap:0.5rem;cursor:pointer;">'
270
+ f'<input type="checkbox" name="cfg_{key}" value="1"{checked} class="perm-toggle">'
271
+ f'<span class="text-muted" style="font-size:0.8rem;">enabled</span></label></div>'
272
+ )
273
+ if vtype == "color":
274
+ val = value or "#F5A524"
275
+ return (
276
+ f'<div class="form-group"><label class="form-label">{label}</label>'
277
+ f'<input type="color" name="cfg_{key}" value="{val}" '
278
+ f'style="height:36px;width:60px;padding:0;border:none;background:none;cursor:pointer;"></div>'
279
+ )
280
+ if vtype == "integer":
281
+ val = value if value is not None else ""
282
+ return (
283
+ f'<div class="form-group"><label class="form-label">{label}</label>'
284
+ f'<input type="number" name="cfg_{key}" value="{val}" class="form-input"></div>'
285
+ )
286
+ if vtype == "textarea":
287
+ val = value if value is not None else ""
288
+ return (
289
+ f'<div class="form-group"><label class="form-label">{label}</label>'
290
+ f'<textarea name="cfg_{key}" class="form-textarea" rows="3">{val}</textarea></div>'
291
+ )
292
+ # string / json default
293
+ val = value if value is not None else ""
294
+ if isinstance(val, (dict, list)):
295
+ val = json.dumps(val)
296
+ return (
297
+ f'<div class="form-group"><label class="form-label">{label}</label>'
298
+ f'<input type="text" name="cfg_{key}" value="{val}" class="form-input"></div>'
299
+ )
300
+
301
+
302
+ def save_schema_form(app_record):
303
+ """
304
+ Persist a submitted generated settings form. Reads `cfg_*` fields and
305
+ writes typed config rows. Returns (ok, message).
306
+ """
307
+ from appmanager.app_config import set_config
308
+
309
+ schema = settings_schema_for(app_record)
310
+ if not schema:
311
+ return False, "No settings schema declared for this app."
312
+ for desc in schema:
313
+ key = desc.get("key")
314
+ if not key:
315
+ continue
316
+ vtype = desc.get("type", "json")
317
+ field = f"cfg_{key}"
318
+ if vtype == "boolean":
319
+ value = request.form.get(field) == "1"
320
+ elif vtype == "integer":
321
+ raw = request.form.get(field, "").strip()
322
+ value = int(raw) if raw else None
323
+ elif vtype == "json":
324
+ raw = request.form.get(field, "").strip()
325
+ try:
326
+ value = json.loads(raw) if raw else None
327
+ except Exception:
328
+ return False, f"Invalid JSON for '{key}'."
329
+ else:
330
+ value = request.form.get(field, "")
331
+ set_config(
332
+ app_record.id, key, value, value_type=vtype, is_secret=desc.get("is_secret", False)
333
+ )
334
+ return True, "Settings saved."
335
+
336
+
337
+ # ---------------------------------------------------------------------------
338
+ # Kill switch — render a panel safely, never take down /admin
339
+ # ---------------------------------------------------------------------------
340
+
341
+
342
+ def render_panel_safe(app_record, panel, **ctx):
343
+ """
344
+ Render a single admin panel's content. If the panel is a generated form,
345
+ render it. If it's a blueprint panel, we can't easily call its endpoint
346
+ here — instead we link to it. Any error renders an inline error card.
347
+ """
348
+ try:
349
+ if not panel.get("endpoint"):
350
+ from appmanager.app_config import get_configs
351
+
352
+ form = render_settings_form(app_record, get_configs(app_record.id))
353
+ if form is None:
354
+ return '<div class="empty-state"><p>No settings schema or admin panel declared for this app.</p></div>'
355
+ return form
356
+ # Blueprint panel — render a link card (content lives at its own route)
357
+ url = f"/admin/apps/{app_record.slug}/{panel['panel_id']}"
358
+ return (
359
+ f'<div class="panel-link-card">'
360
+ f'<p class="text-muted">This app provides a custom admin panel.</p>'
361
+ f'<a href="{url}" class="btn btn-primary btn-sm">Open {panel["label"]}</a>'
362
+ f"</div>"
363
+ )
364
+ except Exception as e:
365
+ tb = traceback.format_exc()
366
+ return (
367
+ f'<div class="alert alert-danger">'
368
+ f"<strong>Panel error:</strong> {e}"
369
+ f'<details class="mt-1"><summary class="mono" style="font-size:0.75rem;cursor:pointer;">traceback</summary>'
370
+ f'<pre class="mono" style="font-size:0.7rem;overflow:auto;max-height:200px;">{tb}</pre></details>'
371
+ f"</div>"
372
+ )