agami-core 0.3.2__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.
- admin.py +1343 -0
- agami_core-0.3.2.dist-info/METADATA +185 -0
- agami_core-0.3.2.dist-info/RECORD +39 -0
- agami_core-0.3.2.dist-info/WHEEL +5 -0
- agami_core-0.3.2.dist-info/top_level.txt +20 -0
- agami_paths.py +180 -0
- contracts.py +239 -0
- deploy_preflight.py +116 -0
- execute_sql.py +785 -0
- mcp_harness.py +150 -0
- mcp_http.py +381 -0
- model_deploy.py +136 -0
- model_store.py +449 -0
- oauth_server.py +660 -0
- oidc.py +190 -0
- onboarding.py +174 -0
- oss_adapters.py +84 -0
- passwords.py +42 -0
- ports.py +106 -0
- semantic_model/__init__.py +35 -0
- semantic_model/build.py +655 -0
- semantic_model/cli.py +1242 -0
- semantic_model/curate.py +1006 -0
- semantic_model/derived.py +201 -0
- semantic_model/dialects.py +617 -0
- semantic_model/introspect.py +1113 -0
- semantic_model/loader.py +543 -0
- semantic_model/metadata_sources.py +232 -0
- semantic_model/models.py +708 -0
- semantic_model/org_draft.py +210 -0
- semantic_model/requirements.txt +11 -0
- semantic_model/runtime.py +1311 -0
- semantic_model/snapshot.py +121 -0
- semantic_model/units.py +192 -0
- semantic_model/validator.py +768 -0
- store.py +166 -0
- tools.py +1226 -0
- ui.py +503 -0
- user_store.py +233 -0
admin.py
ADDED
|
@@ -0,0 +1,1343 @@
|
|
|
1
|
+
"""The admin web surface — onboard/enable/disable/list users, plus the friendly browser landings.
|
|
2
|
+
|
|
3
|
+
Two auth surfaces live in this server: the MCP bearer JWT (claude.ai) and — here — a browser
|
|
4
|
+
**session cookie** for the human admin. `/admin/*` is session-gated (the admin-gate = the
|
|
5
|
+
env-configured `AGAMI_ADMIN_USERNAME`); a non-admin, even with valid credentials, can't get in. This
|
|
6
|
+
module also renders the friendly landings a human sees if they point a browser at the server.
|
|
7
|
+
|
|
8
|
+
The page builders (`*_html`) are split from the request handlers so previews can render them with
|
|
9
|
+
sample values. Every interpolated value goes through `ui.esc` (these pages show emails/names).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import hmac
|
|
16
|
+
import os
|
|
17
|
+
from datetime import datetime, timedelta, timezone
|
|
18
|
+
from typing import Any
|
|
19
|
+
from urllib.parse import quote
|
|
20
|
+
|
|
21
|
+
import jwt
|
|
22
|
+
import ui
|
|
23
|
+
import user_store
|
|
24
|
+
|
|
25
|
+
# Reuse the OAuth provider's shared HS256 secret accessor + store opener + the admin OIDC-start handler
|
|
26
|
+
# so the admin surface signs with the same key, reads the same datastore, and runs the same hardened
|
|
27
|
+
# OIDC flow (no second source of truth).
|
|
28
|
+
from oauth_server import _open_store, _signing_secret, admin_oidc_start
|
|
29
|
+
from starlette.requests import Request
|
|
30
|
+
from starlette.responses import HTMLResponse, RedirectResponse, Response
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _full_name(user: dict[str, Any]) -> str:
|
|
34
|
+
"""A user's display name from first/last; falls back to the email's local part when unnamed."""
|
|
35
|
+
name = " ".join(p for p in (user.get("first_name"), user.get("last_name")) if p).strip()
|
|
36
|
+
return name or (user.get("email") or "").split("@")[0]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
# Auth pages
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def admin_login_body_html(error: str = "", provider: str | None = None) -> str:
|
|
45
|
+
"""The admin sign-in page: the admin's pinned social provider (when configured) above the password
|
|
46
|
+
form — matching the MCP login's options. Only the admin's *one* pinned provider is offered, since
|
|
47
|
+
that's the only one that can resolve to the admin (a second button would just say "not an admin").
|
|
48
|
+
No banner copy — the logo and the form speak for themselves (this is the admin's own login)."""
|
|
49
|
+
button = (
|
|
50
|
+
ui.provider_button(provider, f"/admin/oidc/start?provider={provider}") if provider else ""
|
|
51
|
+
)
|
|
52
|
+
social = f'<div class="providers">{button}</div><div class="divider">or</div>' if button else ""
|
|
53
|
+
alert = f'<div class="alert error">{ui.esc(error)}</div>' if error else ""
|
|
54
|
+
body = f"""{alert}{social}
|
|
55
|
+
<form method="post">
|
|
56
|
+
<label for="u">Email</label>
|
|
57
|
+
<input id="u" name="username" type="email" autocomplete="email" placeholder="you@example.com">
|
|
58
|
+
<label for="p">Password</label>
|
|
59
|
+
<input id="p" name="password" type="password" autocomplete="current-password" placeholder="••••••••">
|
|
60
|
+
<button class="btn" type="submit" style="margin-top:22px">Sign in</button>
|
|
61
|
+
</form>"""
|
|
62
|
+
return ui.auth_page("Admin sign in", body)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
# Admin console — Users tab (+ Dashboard / Activity placeholders)
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _status_pill(status: str) -> str:
|
|
71
|
+
cls = "active" if status == "active" else "disabled"
|
|
72
|
+
return f'<span class="pill {cls}">{ui.esc(status)}</span>'
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _row_action(user: dict[str, Any], csrf: str, admin_username: str) -> str:
|
|
76
|
+
# The admin can't disable their own account (it would lock themselves out). Keyed on username —
|
|
77
|
+
# the stable identity column the status update writes against (email is display-only).
|
|
78
|
+
if user.get("username") == admin_username:
|
|
79
|
+
return '<span class="muted">—</span>'
|
|
80
|
+
active = user["status"] == "active"
|
|
81
|
+
target, label, cls = (
|
|
82
|
+
("disabled", "Disable", "danger") if active else ("active", "Enable", "secondary")
|
|
83
|
+
)
|
|
84
|
+
return (
|
|
85
|
+
'<form method="post" action="/admin/users/status" style="display:inline">'
|
|
86
|
+
f'<input type="hidden" name="csrf" value="{ui.esc(csrf)}">'
|
|
87
|
+
f'<input type="hidden" name="username" value="{ui.esc(user.get("username"))}">'
|
|
88
|
+
f'<input type="hidden" name="status" value="{target}">'
|
|
89
|
+
f'<button class="btn tiny {cls}" type="submit">{label}</button></form>'
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _add_user_drawer(csrf: str) -> str:
|
|
94
|
+
"""A CSS-only right-side drawer (no JS) with a minimal 'Add user' form.
|
|
95
|
+
|
|
96
|
+
Just email + first/last name: the teammate picks their own sign-in method (Google, Microsoft, or
|
|
97
|
+
a password they set themselves) the first time they sign in — the admin never sets a password."""
|
|
98
|
+
return f"""<input type="checkbox" id="add-user" class="drawer-toggle">
|
|
99
|
+
<div class="drawer-wrap">
|
|
100
|
+
<label for="add-user" class="drawer-backdrop"></label>
|
|
101
|
+
<aside class="drawer">
|
|
102
|
+
<div class="drawer-head"><h1 style="font-size:17px">Add user</h1>
|
|
103
|
+
<label for="add-user" class="drawer-x" aria-label="Close">×</label></div>
|
|
104
|
+
<p class="sub" style="margin-bottom:8px">They'll sign in with this email — through Google, Microsoft,
|
|
105
|
+
or a password they set the first time — and can then use this agami server from Claude.</p>
|
|
106
|
+
<form method="post" action="/admin/users">
|
|
107
|
+
<input type="hidden" name="csrf" value="{ui.esc(csrf)}">
|
|
108
|
+
<label for="d-email">Email</label>
|
|
109
|
+
<input id="d-email" name="email" type="email" placeholder="you@example.com">
|
|
110
|
+
<label for="d-first">First name</label>
|
|
111
|
+
<input id="d-first" name="first_name" type="text" placeholder="Jordan">
|
|
112
|
+
<label for="d-last">Last name</label>
|
|
113
|
+
<input id="d-last" name="last_name" type="text" placeholder="Lee">
|
|
114
|
+
<button class="btn" type="submit" style="margin-top:22px">Add user</button>
|
|
115
|
+
</form>
|
|
116
|
+
</aside>
|
|
117
|
+
</div>"""
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _signin_cell(user: dict[str, Any], setup_links: dict[str, str]) -> str:
|
|
121
|
+
"""The Sign-in column: the user's method, plus — for a *pending* user in a password deployment —
|
|
122
|
+
a copy-able setup link the admin shares out-of-band (the page is session-gated, admin-only)."""
|
|
123
|
+
sign_in = user.get("oidc_provider") or (
|
|
124
|
+
"password" if user.get("has_password") else "not set yet"
|
|
125
|
+
)
|
|
126
|
+
link = setup_links.get(user.get("username", ""))
|
|
127
|
+
extra = (
|
|
128
|
+
f'<details class="setup"><summary>Setup link</summary>'
|
|
129
|
+
f'<input class="code" readonly value="{ui.esc(link)}" style="width:100%;margin-top:6px">'
|
|
130
|
+
f"</details>"
|
|
131
|
+
if link
|
|
132
|
+
else ""
|
|
133
|
+
)
|
|
134
|
+
return f'<td class="muted">{ui.esc(sign_in)}{extra}</td>'
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def users_tab_html(
|
|
138
|
+
users: list[dict[str, Any]],
|
|
139
|
+
csrf: str,
|
|
140
|
+
*,
|
|
141
|
+
admin_username: str = "",
|
|
142
|
+
admin_email: str = "",
|
|
143
|
+
admin_label: str = "",
|
|
144
|
+
setup_links: dict[str, str] | None = None,
|
|
145
|
+
error: str = "",
|
|
146
|
+
ok: str = "",
|
|
147
|
+
) -> str:
|
|
148
|
+
"""The Users tab: a roster table + an 'Add user' button that opens the drawer. `setup_links`
|
|
149
|
+
(username → URL) attaches a copy-able setup link to each pending row (password deployments)."""
|
|
150
|
+
setup_links = setup_links or {}
|
|
151
|
+
rows = ""
|
|
152
|
+
for u in users:
|
|
153
|
+
rows += (
|
|
154
|
+
"<tr>"
|
|
155
|
+
f"<td><strong>{ui.esc(_full_name(u))}</strong></td>"
|
|
156
|
+
f'<td class="muted">{ui.esc(u.get("email") or "—")}</td>'
|
|
157
|
+
f"{_signin_cell(u, setup_links)}"
|
|
158
|
+
f"<td>{_status_pill(u['status'])}</td>"
|
|
159
|
+
f'<td style="text-align:right">{_row_action(u, csrf, admin_username)}</td>'
|
|
160
|
+
"</tr>"
|
|
161
|
+
)
|
|
162
|
+
alerts = (f'<div class="alert ok">{ui.esc(ok)}</div>' if ok else "") + (
|
|
163
|
+
f'<div class="alert error">{ui.esc(error)}</div>' if error else ""
|
|
164
|
+
)
|
|
165
|
+
panel = f"""{alerts}
|
|
166
|
+
<div class="row" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px">
|
|
167
|
+
<p class="muted" style="margin:0">People who can use this agami server.</p>
|
|
168
|
+
<label for="add-user" class="btn tiny">+ Add user</label>
|
|
169
|
+
</div>
|
|
170
|
+
<div class="table-wrap"><table>
|
|
171
|
+
<thead><tr><th>Name</th><th>Email</th><th>Sign-in</th><th>Status</th><th></th></tr></thead>
|
|
172
|
+
<tbody>{rows}</tbody>
|
|
173
|
+
</table></div>"""
|
|
174
|
+
return ui.admin_shell(
|
|
175
|
+
"Users · agami admin",
|
|
176
|
+
"users",
|
|
177
|
+
panel,
|
|
178
|
+
admin_label=admin_label or admin_username,
|
|
179
|
+
admin_email=admin_email,
|
|
180
|
+
extra=_add_user_drawer(csrf),
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _coming_soon(tab: str, label: str, *, admin_label: str = "", admin_email: str = "") -> str:
|
|
185
|
+
panel = f'<div class="empty"><strong>{ui.esc(label)}</strong><br>Coming soon.</div>'
|
|
186
|
+
return ui.admin_shell(
|
|
187
|
+
f"{label} · agami admin", tab, panel, admin_label=admin_label, admin_email=admin_email
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def dashboard_tab_html(*, admin_label: str = "", admin_email: str = "") -> str:
|
|
192
|
+
return _coming_soon("dashboard", "Dashboard", admin_label=admin_label, admin_email=admin_email)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# ---------------------------------------------------------------------------
|
|
196
|
+
# Activity view — every tool call, folded into its conversation (thread ▸ turn ▸ call)
|
|
197
|
+
# ---------------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _ok_pill(success: Any) -> str:
|
|
201
|
+
ok = bool(success)
|
|
202
|
+
return f'<span class="pill {"active" if ok else "disabled"}">{"ok" if ok else "error"}</span>'
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _utc(ts: str | None) -> str:
|
|
206
|
+
"""A <time> the browser-local script (ui._doc) renders in the viewer's zone; the UTC value is the
|
|
207
|
+
fallback text for no-JS."""
|
|
208
|
+
return f'<time data-utc="{ui.esc(ts)}">{ui.esc(ts)}</time>'
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _call_card(c: dict[str, Any]) -> str:
|
|
212
|
+
"""One call inside a turn. A **query** call (has `sql`) shows its agent-framing + SQL; a **non-query**
|
|
213
|
+
call (list_datasources, get_datasource_schema, …) has no SQL, so it shows its tool name — so every call
|
|
214
|
+
in the conversation is visible, not just the queries. Every card's meta line carries the call's OWN
|
|
215
|
+
datasource (a turn can span datasources — a cross-datasource question runs one execute_sql each — so
|
|
216
|
+
per-call attribution matters). SQL/framing are self-reported + attacker-influenceable → escaped; rows
|
|
217
|
+
shown only when the call recorded a count."""
|
|
218
|
+
if c.get("sql"):
|
|
219
|
+
head = (
|
|
220
|
+
f'<div class="muted" style="margin:0 0 6px">↳ {ui.esc(c["agent_query"])} '
|
|
221
|
+
f'<span class="muted">· agent-reported</span></div>'
|
|
222
|
+
if c.get("agent_query")
|
|
223
|
+
else ""
|
|
224
|
+
)
|
|
225
|
+
body = (
|
|
226
|
+
f'<pre class="code" style="white-space:pre-wrap;padding:12px;display:block;margin-top:4px">'
|
|
227
|
+
f"{ui.esc(c['sql'])}</pre>"
|
|
228
|
+
)
|
|
229
|
+
else:
|
|
230
|
+
head = (
|
|
231
|
+
'<div style="margin:0 0 6px">'
|
|
232
|
+
f'<span class="pill" style="background:var(--chip);color:var(--ink)">{ui.esc(c["tool_name"])}</span>'
|
|
233
|
+
"</div>"
|
|
234
|
+
)
|
|
235
|
+
body = ""
|
|
236
|
+
ds = ui.esc(c.get("datasource") or "—")
|
|
237
|
+
lat = (str(c["execution_ms"]) + " ms") if c.get("execution_ms") is not None else ""
|
|
238
|
+
rows_bit = f' · {c["row_count"]} rows' if c.get("row_count") is not None else ""
|
|
239
|
+
return (
|
|
240
|
+
'<div style="border-top:1px solid var(--line);padding:9px 0 11px">'
|
|
241
|
+
f"{head}{body}"
|
|
242
|
+
f'<div class="muted" style="font-size:13px;margin-top:6px">{_utc(c["ts"])} · {ds} · '
|
|
243
|
+
f"{lat} {_ok_pill(c['success'])}{rows_bit}</div></div>"
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _session_drawer(s: dict[str, Any], idx: int) -> str:
|
|
248
|
+
sid = f"sess-{idx}" # DOM id is the row index, never the (self-reported, attacker-influenceable) key
|
|
249
|
+
# Render the conversation as **turns** (one user question -> the N calls answering it), grouped on
|
|
250
|
+
# correlation_id by list_sessions. The turn header shows the verbatim question once; each call (a
|
|
251
|
+
# query's SQL, or a non-query tool's name) lists beneath it. Degrades cleanly: a call with no
|
|
252
|
+
# correlation_id is its own one-call turn, so this reads like a flat list when Claude doesn't self-report.
|
|
253
|
+
cards = ""
|
|
254
|
+
for t in s["turns"]:
|
|
255
|
+
question = t.get("question") or "(no question reported)"
|
|
256
|
+
n = len(t["calls"])
|
|
257
|
+
call_cards = "".join(_call_card(c) for c in t["calls"])
|
|
258
|
+
# The question is Claude-self-reported (best-effort, attacker-influenceable) — mark it so, like
|
|
259
|
+
# the rest of the activity log; "User asked" is the framing, "· self-reported" the provenance.
|
|
260
|
+
asked = (
|
|
261
|
+
f'<span class="muted">User asked</span> <strong>{ui.esc(question)}</strong> '
|
|
262
|
+
'<span class="muted" style="font-size:13px">· self-reported</span>'
|
|
263
|
+
if t.get("question")
|
|
264
|
+
else f'<strong class="muted">{ui.esc(question)}</strong>'
|
|
265
|
+
)
|
|
266
|
+
cards += (
|
|
267
|
+
'<div style="border-top:2px solid var(--line);padding:14px 0 2px;margin-top:8px">'
|
|
268
|
+
f'<div style="margin-bottom:4px">{asked} '
|
|
269
|
+
f'<span class="muted" style="font-size:13px">· {n} {"call" if n == 1 else "calls"}</span>'
|
|
270
|
+
f"</div>{call_cards}</div>"
|
|
271
|
+
)
|
|
272
|
+
return f"""<input type="checkbox" id="{sid}" class="drawer-toggle">
|
|
273
|
+
<div class="drawer-wrap"><label for="{sid}" class="drawer-backdrop"></label>
|
|
274
|
+
<aside class="drawer" style="width:560px">
|
|
275
|
+
<div class="drawer-head"><h1 style="font-size:17px">Conversation</h1>
|
|
276
|
+
<label for="{sid}" class="drawer-x" aria-label="Close">×</label></div>
|
|
277
|
+
<p class="sub" style="margin-bottom:8px">{ui.esc(s.get("actor") or "—")} · {ui.esc(", ".join(s["datasources"]) or "—")} · {s["call_count"]} calls · started {_utc(s["started"])}</p>
|
|
278
|
+
{cards}</aside></div>"""
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def activity_tab_html(
|
|
282
|
+
sessions: list[dict[str, Any]] | None = None, *, admin_label: str = "", admin_email: str = ""
|
|
283
|
+
) -> str:
|
|
284
|
+
"""The Activity tab: **every** tool call grouped into conversations (best-effort via the self-reported
|
|
285
|
+
`thread_id`; ungrouped singletons otherwise — so it stays audit-complete). Each row opens to the
|
|
286
|
+
conversation's turns, and within them every call (query or not)."""
|
|
287
|
+
sessions = sessions or []
|
|
288
|
+
body_rows, drawers = "", ""
|
|
289
|
+
for i, s in enumerate(sessions):
|
|
290
|
+
body_rows += (
|
|
291
|
+
"<tr>"
|
|
292
|
+
f'<td><label for="sess-{i}" style="cursor:pointer;color:var(--brand)">{_utc(s["started"])}</label></td>'
|
|
293
|
+
f"<td><strong>{ui.esc(s.get('actor') or '—')}</strong></td>"
|
|
294
|
+
f'<td class="muted">{ui.esc(", ".join(s["datasources"]) or "—")}</td>'
|
|
295
|
+
f'<td class="muted">{s["call_count"]}</td>'
|
|
296
|
+
f'<td class="muted">{s["error_count"] or "—"}</td>'
|
|
297
|
+
f'<td class="muted">{(str(s["avg_ms"]) + " ms") if s.get("avg_ms") is not None else "—"}</td>'
|
|
298
|
+
f"<td>{_utc(s['last_activity'])}</td>"
|
|
299
|
+
f'<td style="text-align:right"><label for="sess-{i}" class="btn tiny secondary">Open</label></td>'
|
|
300
|
+
"</tr>"
|
|
301
|
+
)
|
|
302
|
+
drawers += _session_drawer(s, i)
|
|
303
|
+
empty = '<p class="muted">No activity yet.</p>' if not sessions else ""
|
|
304
|
+
panel = f"""{empty}
|
|
305
|
+
<div class="table-wrap"><table>
|
|
306
|
+
<thead><tr><th>Started</th><th>User</th><th>Datasources</th><th>Calls</th><th>Errors</th><th>Avg time</th><th>Last activity</th><th></th></tr></thead>
|
|
307
|
+
<tbody>{body_rows}</tbody></table></div>"""
|
|
308
|
+
return ui.admin_shell(
|
|
309
|
+
"Activity · agami admin",
|
|
310
|
+
"activity",
|
|
311
|
+
panel,
|
|
312
|
+
admin_label=admin_label,
|
|
313
|
+
admin_email=admin_email,
|
|
314
|
+
extra=drawers,
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
# ---------------------------------------------------------------------------
|
|
319
|
+
# Friendly browser landings
|
|
320
|
+
# ---------------------------------------------------------------------------
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _connect_block(base_url: str) -> str:
|
|
324
|
+
return (
|
|
325
|
+
'<p class="small" style="margin-bottom:6px">Add this server to Claude as a custom connector:</p>'
|
|
326
|
+
f'<p><span class="code">{ui.esc(base_url)}/mcp</span></p>'
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def landing_body_html(base_url: str) -> str:
|
|
331
|
+
"""The root page a human lands on if they open the server URL in a browser."""
|
|
332
|
+
body = f"""<div class="consent"><p class="who">agami</p>
|
|
333
|
+
<p class="small">A governed, self-hosted data agent for Claude.</p></div>
|
|
334
|
+
{_connect_block(base_url)}
|
|
335
|
+
<p class="foot"><a href="/admin">Admin sign in →</a></p>"""
|
|
336
|
+
return ui.auth_page("agami", body)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def not_admin_body_html(base_url: str) -> str:
|
|
340
|
+
"""Shown when a valid but non-admin user signs in at /admin/login."""
|
|
341
|
+
body = f"""<div class="consent"><p class="who">You're signed in</p>
|
|
342
|
+
<p class="small">This account isn't an administrator.</p></div>
|
|
343
|
+
{_connect_block(base_url)}
|
|
344
|
+
<p class="foot muted">Only the administrator can manage users here.</p>"""
|
|
345
|
+
return ui.auth_page("Signed in", body)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def not_authorized_body_html(email: str) -> str:
|
|
349
|
+
"""The branded "your identity is real but no admin has added you" page for an un-onboarded
|
|
350
|
+
Google/Microsoft sign-in. Not yet wired into the OIDC rejection (which still returns a JSON OAuth
|
|
351
|
+
error); rendered in previews and ready for the self-onboarding flow to adopt. No connector hint —
|
|
352
|
+
they can't use it yet."""
|
|
353
|
+
body = f"""<div class="consent"><p class="who">Not set up yet</p>
|
|
354
|
+
<p class="small">{ui.esc(email)} isn't authorized for this agami server.</p></div>
|
|
355
|
+
<p class="foot muted">Ask the administrator to add you, then sign in again.</p>"""
|
|
356
|
+
return ui.auth_page("Not authorized", body)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def mcp_landing_body_html(base_url: str) -> str:
|
|
360
|
+
"""The branded body returned when a *browser* hits /mcp (a machine endpoint) unauthenticated."""
|
|
361
|
+
body = f"""<div class="consent"><p class="who">This is an MCP endpoint</p>
|
|
362
|
+
<p class="small">It's meant for Claude, not a browser.</p></div>
|
|
363
|
+
{_connect_block(base_url)}
|
|
364
|
+
<p class="foot"><a href="/admin">Admin sign in →</a></p>"""
|
|
365
|
+
return ui.auth_page("agami · MCP endpoint", body)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
# ---------------------------------------------------------------------------
|
|
369
|
+
# Session auth — a browser session cookie (separate from the MCP bearer JWT)
|
|
370
|
+
# ---------------------------------------------------------------------------
|
|
371
|
+
#
|
|
372
|
+
# `/admin/*` is gated by a signed session cookie, NOT the MCP bearer token. The two share the HS256
|
|
373
|
+
# signing secret + algorithm, so the `purpose` claim keeps them from being interchangeable: a token
|
|
374
|
+
# minted for the query surface (`issue_jwt`, which carries no `purpose`) must never satisfy the admin
|
|
375
|
+
# gate — even for the admin's own user. The gate also requires `sub == AGAMI_ADMIN_USERNAME`, so a
|
|
376
|
+
# valid non-admin can never hold an admin session.
|
|
377
|
+
|
|
378
|
+
_SESSION_COOKIE = "agami_admin_session"
|
|
379
|
+
_SESSION_TTL = timedelta(hours=12)
|
|
380
|
+
_SESSION_PURPOSE = "admin_session"
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _admin_username() -> str | None:
|
|
384
|
+
"""The single admin's username = the **normalized** admin email (the admin-gate). Lowercased+trimmed
|
|
385
|
+
to match how the seed stores it + how OIDC resolves it, so the gate is case-insensitive. Unset ⇒ the
|
|
386
|
+
admin UI is disabled entirely."""
|
|
387
|
+
name = os.environ.get("AGAMI_ADMIN_USERNAME", "").strip().lower()
|
|
388
|
+
return name or None
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _admin_provider() -> str | None:
|
|
392
|
+
"""The admin's pinned OIDC provider (`AGAMI_ADMIN_PROVIDER`), or None — only when it's a provider
|
|
393
|
+
that's actually configured (client id/secret present)."""
|
|
394
|
+
key = os.environ.get("AGAMI_ADMIN_PROVIDER", "").strip().lower()
|
|
395
|
+
if not key:
|
|
396
|
+
return None
|
|
397
|
+
import oidc # lazy: the egress module, server-only
|
|
398
|
+
|
|
399
|
+
return key if key in oidc.available_providers() else None
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _admin_login_provider() -> str | None:
|
|
403
|
+
"""The provider button to render on the admin login: the pinned, configured provider, but ONLY
|
|
404
|
+
when the admin's stored row is actually bound to it. This avoids a dead button — e.g. if
|
|
405
|
+
`AGAMI_ADMIN_PROVIDER` is set after the admin was seeded password-only (the seed is idempotent and
|
|
406
|
+
won't backfill `oidc_provider`), the button would otherwise show but dead-end at "not an admin"."""
|
|
407
|
+
provider = _admin_provider()
|
|
408
|
+
admin = _admin_username()
|
|
409
|
+
if provider is None or admin is None:
|
|
410
|
+
return None
|
|
411
|
+
store = _open_store()
|
|
412
|
+
if store is None:
|
|
413
|
+
return None
|
|
414
|
+
try:
|
|
415
|
+
row = user_store.get_user(store, admin)
|
|
416
|
+
finally:
|
|
417
|
+
store.close()
|
|
418
|
+
return provider if row is not None and row.get("oidc_provider") == provider else None
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def issue_session(username: str) -> str:
|
|
422
|
+
"""A short-TTL HS256 session JWT (sub=username, purpose=admin_session) for the cookie."""
|
|
423
|
+
now = datetime.now(timezone.utc)
|
|
424
|
+
return jwt.encode(
|
|
425
|
+
{
|
|
426
|
+
"sub": username,
|
|
427
|
+
"purpose": _SESSION_PURPOSE,
|
|
428
|
+
"iat": int(now.timestamp()),
|
|
429
|
+
"exp": int((now + _SESSION_TTL).timestamp()),
|
|
430
|
+
},
|
|
431
|
+
_signing_secret(),
|
|
432
|
+
algorithm="HS256",
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def current_admin(request: Request) -> str | None:
|
|
437
|
+
"""The signed-in admin's username, or None: verify the cookie (sig + exp + purpose) AND that its
|
|
438
|
+
subject is THE configured admin. Any failure → None (fail closed → redirect to login)."""
|
|
439
|
+
admin = _admin_username()
|
|
440
|
+
token = request.cookies.get(_SESSION_COOKIE)
|
|
441
|
+
if admin is None or not token:
|
|
442
|
+
return None
|
|
443
|
+
try:
|
|
444
|
+
claims = jwt.decode(
|
|
445
|
+
token, _signing_secret(), algorithms=["HS256"], options={"require": ["exp", "sub"]}
|
|
446
|
+
)
|
|
447
|
+
except Exception:
|
|
448
|
+
return None
|
|
449
|
+
if claims.get("purpose") != _SESSION_PURPOSE:
|
|
450
|
+
return None
|
|
451
|
+
sub = claims.get("sub")
|
|
452
|
+
if not isinstance(sub, str) or sub != admin:
|
|
453
|
+
return None
|
|
454
|
+
return sub
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _set_session(resp: Response, token: str) -> None:
|
|
458
|
+
# HttpOnly (no JS read) + Secure (HTTPS only — deployments are HTTPS) + SameSite=Lax (not sent on
|
|
459
|
+
# cross-site POSTs); scoped to /admin so it never rides along to /mcp or /static.
|
|
460
|
+
resp.set_cookie(
|
|
461
|
+
_SESSION_COOKIE,
|
|
462
|
+
token,
|
|
463
|
+
max_age=int(_SESSION_TTL.total_seconds()),
|
|
464
|
+
httponly=True,
|
|
465
|
+
secure=True,
|
|
466
|
+
samesite="lax",
|
|
467
|
+
path="/admin",
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _clear_session(resp: Response) -> None:
|
|
472
|
+
resp.delete_cookie(_SESSION_COOKIE, path="/admin")
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
# CSRF: a token derived from the session cookie via HMAC(secret, cookie). An attacker who can neither
|
|
476
|
+
# read the HttpOnly cookie nor know the server secret can't forge it — defense-in-depth over SameSite.
|
|
477
|
+
def _csrf_for(session_token: str) -> str:
|
|
478
|
+
return hmac.new(_signing_secret().encode(), session_token.encode(), hashlib.sha256).hexdigest()
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _origin_ok(request: Request) -> bool:
|
|
482
|
+
"""A second, independent CSRF gate: if the browser sent an Origin (or Referer) on this POST, its
|
|
483
|
+
scheme+host MUST match our own. Browsers always attach Origin to cross-site POSTs, so a forged
|
|
484
|
+
request from another site is caught here even before the token check. When neither header is
|
|
485
|
+
present (some same-origin form posts, test clients) we don't fail — the signed CSRF token carries
|
|
486
|
+
the load — so this only ever *adds* protection."""
|
|
487
|
+
from urllib.parse import urlsplit
|
|
488
|
+
|
|
489
|
+
origin = request.headers.get("origin") or request.headers.get("referer")
|
|
490
|
+
if not origin:
|
|
491
|
+
return True
|
|
492
|
+
want = urlsplit(_base_url())
|
|
493
|
+
got = urlsplit(origin)
|
|
494
|
+
return (got.scheme, got.hostname, got.port) == (want.scheme, want.hostname, want.port)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _csrf_ok(request: Request, presented: str) -> bool:
|
|
498
|
+
token = request.cookies.get(_SESSION_COOKIE)
|
|
499
|
+
if not token or not presented:
|
|
500
|
+
return False
|
|
501
|
+
if not _origin_ok(request):
|
|
502
|
+
return False
|
|
503
|
+
return hmac.compare_digest(_csrf_for(token), presented)
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
# ---------------------------------------------------------------------------
|
|
507
|
+
# Request handlers
|
|
508
|
+
# ---------------------------------------------------------------------------
|
|
509
|
+
|
|
510
|
+
# Flash text is server-owned (keyed by a short code in the redirect query) — never echoed user input,
|
|
511
|
+
# so a redirect can't be turned into a reflected-content vector.
|
|
512
|
+
_OK_FLASH = {"added": "User added.", "enabled": "User enabled.", "disabled": "User disabled."}
|
|
513
|
+
_ERR_FLASH = {
|
|
514
|
+
"dup": "A user with that email already exists.",
|
|
515
|
+
"bad_email": "Enter a valid email address.",
|
|
516
|
+
"csrf": "Your session expired — please try again.",
|
|
517
|
+
"self": "You can't change your own status.",
|
|
518
|
+
"bad": "That action isn't allowed.",
|
|
519
|
+
"notfound": "No such user.",
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
async def _form(request: Request) -> dict[str, str]:
|
|
524
|
+
data = await request.form()
|
|
525
|
+
return {k: (v if isinstance(v, str) else "") for k, v in data.items()}
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _base_url() -> str:
|
|
529
|
+
from mcp_http import public_base_url
|
|
530
|
+
|
|
531
|
+
return public_base_url()
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _is_integrity_error(exc: Exception) -> bool:
|
|
535
|
+
# Portable across backends (sqlite3.IntegrityError / psycopg2 UniqueViolation) without importing
|
|
536
|
+
# the driver here — a UNIQUE collision is "duplicate", any other error must surface.
|
|
537
|
+
return any(cls.__name__ == "IntegrityError" for cls in type(exc).__mro__)
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _admin_chrome(store: Any, admin_username: str) -> dict[str, str]:
|
|
541
|
+
"""Avatar label + email for the top-bar account menu (from the admin's own user row)."""
|
|
542
|
+
row = user_store.get_user(store, admin_username) if store is not None else None
|
|
543
|
+
return {
|
|
544
|
+
"admin_label": _full_name(row) if row else admin_username,
|
|
545
|
+
"admin_email": (row or {}).get("email") or "",
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def complete_admin_oidc_login(username: str | None) -> Response:
|
|
550
|
+
"""Finish an admin OIDC login (called by the shared OIDC callback for an `admin_login` state): a
|
|
551
|
+
verified identity that resolves to THE configured admin gets an admin session; anyone else — an
|
|
552
|
+
unresolved identity, or a valid but non-admin user — is refused with the branded page, no session.
|
|
553
|
+
The provider-pin + subject binding were already enforced upstream by `_resolve_oidc_user`."""
|
|
554
|
+
if username is not None and username == _admin_username():
|
|
555
|
+
resp: Response = RedirectResponse("/admin", status_code=302)
|
|
556
|
+
_set_session(resp, issue_session(username))
|
|
557
|
+
return resp
|
|
558
|
+
return HTMLResponse(not_admin_body_html(_base_url()), status_code=403)
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
async def admin_login(request: Request) -> Response:
|
|
562
|
+
"""GET → the admin sign-in page; POST → authenticate, gate on the admin-username, mint a session."""
|
|
563
|
+
if request.method == "GET":
|
|
564
|
+
if current_admin(request) is not None:
|
|
565
|
+
return RedirectResponse("/admin", status_code=302)
|
|
566
|
+
return HTMLResponse(admin_login_body_html(provider=_admin_login_provider()))
|
|
567
|
+
|
|
568
|
+
form = await _form(request)
|
|
569
|
+
# Email is the identity: normalize the typed address (trim + lowercase) so login is
|
|
570
|
+
# case-insensitive and matches the normalized username the seed stored.
|
|
571
|
+
typed = form.get("username", "").strip().lower()
|
|
572
|
+
store = _open_store()
|
|
573
|
+
try:
|
|
574
|
+
principal = (
|
|
575
|
+
user_store.authenticate(store, typed, form.get("password", ""))
|
|
576
|
+
if store is not None
|
|
577
|
+
else None
|
|
578
|
+
)
|
|
579
|
+
finally:
|
|
580
|
+
if store is not None:
|
|
581
|
+
store.close()
|
|
582
|
+
if principal is None:
|
|
583
|
+
# Same generic message for wrong password, unknown user, or disabled — no enumeration oracle.
|
|
584
|
+
# Keep the social button on the re-render so a failed password attempt doesn't hide it.
|
|
585
|
+
return HTMLResponse(
|
|
586
|
+
admin_login_body_html(
|
|
587
|
+
error="Invalid email or password.", provider=_admin_login_provider()
|
|
588
|
+
),
|
|
589
|
+
status_code=401,
|
|
590
|
+
)
|
|
591
|
+
if principal.subject != _admin_username():
|
|
592
|
+
# Valid credentials, but not THE admin: no session minted; a friendly "use via Claude" page.
|
|
593
|
+
return HTMLResponse(not_admin_body_html(_base_url()), status_code=403)
|
|
594
|
+
resp = RedirectResponse("/admin", status_code=302)
|
|
595
|
+
_set_session(resp, issue_session(principal.subject))
|
|
596
|
+
return resp
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
async def admin_logout(request: Request) -> Response:
|
|
600
|
+
resp = RedirectResponse("/admin/login", status_code=302)
|
|
601
|
+
_clear_session(resp)
|
|
602
|
+
return resp
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
async def admin_home(request: Request) -> Response:
|
|
606
|
+
"""The console. `?tab=` picks Dashboard / Users (default) / Activity. Session-gated."""
|
|
607
|
+
import model_store
|
|
608
|
+
|
|
609
|
+
admin = current_admin(request)
|
|
610
|
+
if admin is None:
|
|
611
|
+
return RedirectResponse("/admin/login", status_code=302)
|
|
612
|
+
store = _open_store()
|
|
613
|
+
try:
|
|
614
|
+
chrome = _admin_chrome(store, admin)
|
|
615
|
+
tab = request.query_params.get("tab", "users")
|
|
616
|
+
if tab == "dashboard":
|
|
617
|
+
return HTMLResponse(dashboard_tab_html(**chrome))
|
|
618
|
+
if tab == "activity":
|
|
619
|
+
sessions = model_store.list_sessions(store) if store is not None else []
|
|
620
|
+
return HTMLResponse(activity_tab_html(sessions, **chrome))
|
|
621
|
+
users = user_store.list_users(store) if store is not None else []
|
|
622
|
+
finally:
|
|
623
|
+
if store is not None:
|
|
624
|
+
store.close()
|
|
625
|
+
# current_admin already proved the cookie is present + valid; .get (not bracket) keeps a malformed
|
|
626
|
+
# duplicate-cookie header from turning into an unhandled 500.
|
|
627
|
+
csrf = _csrf_for(request.cookies.get(_SESSION_COOKIE, ""))
|
|
628
|
+
ok = _OK_FLASH.get(request.query_params.get("ok", ""), "")
|
|
629
|
+
err = _ERR_FLASH.get(request.query_params.get("err", ""), "")
|
|
630
|
+
return HTMLResponse(
|
|
631
|
+
users_tab_html(
|
|
632
|
+
users,
|
|
633
|
+
csrf,
|
|
634
|
+
admin_username=admin,
|
|
635
|
+
setup_links=_setup_links(users),
|
|
636
|
+
ok=ok,
|
|
637
|
+
error=err,
|
|
638
|
+
**chrome,
|
|
639
|
+
)
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _setup_links(users: list[dict[str, Any]]) -> dict[str, str]:
|
|
644
|
+
"""Per-pending-user setup links — but only in a **password** deployment (no OIDC configured). When
|
|
645
|
+
an OIDC provider is configured, teammates onboard by signing in with it, so no link is offered."""
|
|
646
|
+
import oidc # lazy: the egress module, server-only
|
|
647
|
+
|
|
648
|
+
if oidc.available_providers():
|
|
649
|
+
return {}
|
|
650
|
+
import onboarding
|
|
651
|
+
|
|
652
|
+
base = _base_url()
|
|
653
|
+
return {
|
|
654
|
+
u["username"]: f"{base}/claim?token={onboarding.mint_setup_token(u['username'])}"
|
|
655
|
+
for u in users
|
|
656
|
+
if onboarding.is_pending(u)
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _valid_email(email: str) -> bool:
|
|
661
|
+
# A deliberately loose check — we're not validating deliverability, just rejecting obvious junk
|
|
662
|
+
# before it becomes a username. Real verification happens when the user first signs in (a later
|
|
663
|
+
# self-onboarding step).
|
|
664
|
+
email = email.strip()
|
|
665
|
+
return "@" in email and "." in email.rsplit("@", 1)[-1] and " " not in email
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
async def admin_create_user(request: Request) -> Response:
|
|
669
|
+
"""Onboard a teammate: a *pending* user (no password, no provider) keyed by their email. They pick
|
|
670
|
+
their sign-in method on first login (a later self-onboarding step). Admin-gated + CSRF-checked."""
|
|
671
|
+
admin = current_admin(request)
|
|
672
|
+
if admin is None:
|
|
673
|
+
return RedirectResponse("/admin/login", status_code=302)
|
|
674
|
+
form = await _form(request)
|
|
675
|
+
if not _csrf_ok(request, form.get("csrf", "")):
|
|
676
|
+
return RedirectResponse("/admin?err=csrf", status_code=302)
|
|
677
|
+
email = (form.get("email") or "").strip()
|
|
678
|
+
if not _valid_email(email):
|
|
679
|
+
return RedirectResponse("/admin?err=bad_email", status_code=302)
|
|
680
|
+
store = _open_store()
|
|
681
|
+
if store is None:
|
|
682
|
+
return RedirectResponse("/admin?err=bad", status_code=302)
|
|
683
|
+
try:
|
|
684
|
+
# Username == the normalized email, so the teammate signs in with the address they know.
|
|
685
|
+
normalized = email.lower()
|
|
686
|
+
user_store.create_user(
|
|
687
|
+
store,
|
|
688
|
+
username=normalized,
|
|
689
|
+
email=normalized,
|
|
690
|
+
first_name=form.get("first_name", ""),
|
|
691
|
+
last_name=form.get("last_name", ""),
|
|
692
|
+
password=None,
|
|
693
|
+
)
|
|
694
|
+
except Exception as exc:
|
|
695
|
+
# A UNIQUE collision is a duplicate → flash, not a 500. Closing the store in `finally` rolls
|
|
696
|
+
# back the failed INSERT so its write lock doesn't strand the connection.
|
|
697
|
+
if _is_integrity_error(exc):
|
|
698
|
+
return RedirectResponse("/admin?err=dup", status_code=302)
|
|
699
|
+
raise
|
|
700
|
+
finally:
|
|
701
|
+
store.close()
|
|
702
|
+
return RedirectResponse("/admin?ok=added", status_code=302)
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
async def admin_set_status(request: Request) -> Response:
|
|
706
|
+
"""Enable/disable a user (the existing active/disabled status flag). Admin-gated + CSRF-checked;
|
|
707
|
+
can't disable self."""
|
|
708
|
+
admin = current_admin(request)
|
|
709
|
+
if admin is None:
|
|
710
|
+
return RedirectResponse("/admin/login", status_code=302)
|
|
711
|
+
form = await _form(request)
|
|
712
|
+
if not _csrf_ok(request, form.get("csrf", "")):
|
|
713
|
+
return RedirectResponse("/admin?err=csrf", status_code=302)
|
|
714
|
+
username = form.get("username", "")
|
|
715
|
+
status = form.get("status", "")
|
|
716
|
+
if status not in ("active", "disabled"):
|
|
717
|
+
return RedirectResponse("/admin?err=bad", status_code=302)
|
|
718
|
+
if username == admin:
|
|
719
|
+
return RedirectResponse("/admin?err=self", status_code=302)
|
|
720
|
+
store = _open_store()
|
|
721
|
+
if store is None:
|
|
722
|
+
# Mirror create: a missing datastore is an error, not a silent "done" (no false success flash).
|
|
723
|
+
return RedirectResponse("/admin?err=bad", status_code=302)
|
|
724
|
+
try:
|
|
725
|
+
changed = user_store.set_status(store, username, status)
|
|
726
|
+
finally:
|
|
727
|
+
store.close()
|
|
728
|
+
if not changed:
|
|
729
|
+
# The username matched no row (e.g. a stale form) — don't flash a success for a no-op.
|
|
730
|
+
return RedirectResponse("/admin?err=notfound", status_code=302)
|
|
731
|
+
return RedirectResponse(
|
|
732
|
+
f"/admin?ok={'disabled' if status == 'disabled' else 'enabled'}", status_code=302
|
|
733
|
+
)
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
# ---------------------------------------------------------------------------
|
|
737
|
+
# Admin console — Model tab (read-only model explorer)
|
|
738
|
+
#
|
|
739
|
+
# A pure projection of the served model (`model_store.load_organization`) + the domain docs
|
|
740
|
+
# (`load_memory`) — the SAME tree every MCP tool reads, so there is zero drift and no second store.
|
|
741
|
+
# Read-only by construction: the only route is a GET (see `routes()`), there is no write path, and
|
|
742
|
+
# `storage_connections[].storage_config` (hosts/credentials) is NEVER rendered. The catalog idiom (a
|
|
743
|
+
# browse tree + one page at a time) keeps a wide model — real tables run to dozens of columns —
|
|
744
|
+
# legible. Builders are split from the handler so previews can render them with sample data.
|
|
745
|
+
# ---------------------------------------------------------------------------
|
|
746
|
+
|
|
747
|
+
# Trust posture is surfaced as an honest read-only badge (agami's differentiator: the model says how
|
|
748
|
+
# much to trust each piece), never as a clickable review control — that editor is Hosted.
|
|
749
|
+
_CONF_LABEL = {"confirmed": "✓ confirmed", "inferred": "~ inferred", "proposed": "⋯ proposed"}
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def _conf_badge(confidence: str | None) -> str:
|
|
753
|
+
c = (confidence or "").lower()
|
|
754
|
+
if c not in _CONF_LABEL:
|
|
755
|
+
return ""
|
|
756
|
+
return f'<span class="badge b-{c}">{_CONF_LABEL[c]}</span>'
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def _human_count(n: int | None) -> str:
|
|
760
|
+
"""A compact row-count, e.g. 6591 -> '≈ 6.6k', 612000 -> '≈ 612k'. None -> '' (unknown)."""
|
|
761
|
+
if n is None:
|
|
762
|
+
return ""
|
|
763
|
+
if n < 1000:
|
|
764
|
+
return f"≈ {n}"
|
|
765
|
+
if n < 1_000_000:
|
|
766
|
+
return f"≈ {n / 1000:.1f}k".replace(".0k", "k")
|
|
767
|
+
return f"≈ {n / 1_000_000:.1f}M".replace(".0M", "M")
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def _model_url(
|
|
771
|
+
datasource: str, *, area: str | None = None, table: str | None = None, view: str | None = None
|
|
772
|
+
) -> str:
|
|
773
|
+
"""An attribute-safe `/admin/model` href. Values are %-encoded (names may contain spaces); the
|
|
774
|
+
`&` separators are written as `&` so the whole string is safe in an HTML attribute."""
|
|
775
|
+
parts = [f"datasource={quote(datasource)}"]
|
|
776
|
+
if area is not None:
|
|
777
|
+
parts.append(f"area={quote(area)}")
|
|
778
|
+
if table is not None:
|
|
779
|
+
parts.append(f"table={quote(table)}")
|
|
780
|
+
if view is not None:
|
|
781
|
+
parts.append(f"view={quote(view)}")
|
|
782
|
+
return "/admin/model?" + "&".join(parts)
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def _area_nav_html(
|
|
786
|
+
a: Any, datasource: str, active_area: str | None, active_table: str | None
|
|
787
|
+
) -> str:
|
|
788
|
+
"""One area node; when it's the active area it expands into links to its tables."""
|
|
789
|
+
head = (
|
|
790
|
+
f'<a class="navitem{" active" if a.name == active_area else ""}" '
|
|
791
|
+
f'href="{_model_url(datasource, area=a.name)}">{ui.esc(a.name)} '
|
|
792
|
+
f'<span class="n">{len(a.tables_defined)}</span></a>'
|
|
793
|
+
)
|
|
794
|
+
if a.name != active_area:
|
|
795
|
+
return head
|
|
796
|
+
leaves = "".join(
|
|
797
|
+
f'<a class="leaf{" active" if t.name == active_table else ""}" '
|
|
798
|
+
f'href="{_model_url(datasource, area=a.name, table=t.name)}">{ui.esc(t.name)}</a>'
|
|
799
|
+
for t in a.tables_defined
|
|
800
|
+
)
|
|
801
|
+
return head + f'<div class="children">{leaves}</div>' if leaves else head
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def _model_tree_html(
|
|
805
|
+
org: Any,
|
|
806
|
+
datasource: str,
|
|
807
|
+
datasources: list[str],
|
|
808
|
+
*,
|
|
809
|
+
active_area: str | None = None,
|
|
810
|
+
active_table: str | None = None,
|
|
811
|
+
active_view: str | None = None,
|
|
812
|
+
) -> str:
|
|
813
|
+
"""The left browse rail: a datasource picker (only when more than one is served), an Overview
|
|
814
|
+
link, the subject areas (the active one expands to its tables), and the Domain-context node."""
|
|
815
|
+
if len(datasources) > 1:
|
|
816
|
+
opts = "".join(
|
|
817
|
+
f'<option value="{ui.esc(d)}"{" selected" if d == datasource else ""}>{ui.esc(d)}'
|
|
818
|
+
"</option>"
|
|
819
|
+
for d in datasources
|
|
820
|
+
)
|
|
821
|
+
# onchange auto-submits for JS users; the Go button is the no-JS fallback (the rest of the
|
|
822
|
+
# admin UI is JS-free, so the picker keeps a working control without JavaScript too).
|
|
823
|
+
picker = (
|
|
824
|
+
'<form class="ds" method="get" action="/admin/model">'
|
|
825
|
+
'<span class="muted">Datasource</span>'
|
|
826
|
+
f'<select name="datasource" onchange="this.form.submit()">{opts}</select>'
|
|
827
|
+
'<button type="submit" class="ds-go">Go</button></form>'
|
|
828
|
+
)
|
|
829
|
+
else:
|
|
830
|
+
picker = (
|
|
831
|
+
f'<div class="ds"><span class="muted">Datasource</span>'
|
|
832
|
+
f"<b>{ui.esc(datasource)}</b></div>"
|
|
833
|
+
)
|
|
834
|
+
overview_cls = "navitem" + (" active" if active_area is None and active_view is None else "")
|
|
835
|
+
areas = "".join(
|
|
836
|
+
_area_nav_html(a, datasource, active_area, active_table) for a in org.subject_areas
|
|
837
|
+
)
|
|
838
|
+
# The cross-area Relationships node only appears when the model actually has org-level
|
|
839
|
+
# (cross-subject-area) relationships — no dead node for a single-area model.
|
|
840
|
+
rel_node = ""
|
|
841
|
+
if org.cross_subject_area_relationships:
|
|
842
|
+
rel_cls = "navitem" + (" active" if active_view == "relationships" else "")
|
|
843
|
+
rel_node = (
|
|
844
|
+
f'<a class="{rel_cls}" href="{_model_url(datasource, view="relationships")}">'
|
|
845
|
+
f'Relationships <span class="n">{len(org.cross_subject_area_relationships)}</span></a>'
|
|
846
|
+
)
|
|
847
|
+
context_cls = "navitem" + (" active" if active_view == "context" else "")
|
|
848
|
+
return (
|
|
849
|
+
f'<aside class="tree">{picker}'
|
|
850
|
+
f'<a class="{overview_cls}" href="{_model_url(datasource)}">Overview</a>'
|
|
851
|
+
f"<h4>Subject areas</h4>{areas}"
|
|
852
|
+
f"<h4>Browse</h4>{rel_node}"
|
|
853
|
+
f'<a class="{context_cls}" href="{_model_url(datasource, view="context")}">Domain context</a>'
|
|
854
|
+
"</aside>"
|
|
855
|
+
)
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
def _model_shell(content: str, tree: str, *, admin_label: str = "", admin_email: str = "") -> str:
|
|
859
|
+
"""Wrap the browse tree + a content pane in the admin shell with the Model tab active."""
|
|
860
|
+
body = f'<div class="explorer">{tree}<main class="content">{content}</main></div>'
|
|
861
|
+
return ui.admin_shell(
|
|
862
|
+
"Model · agami admin", "model", body, admin_label=admin_label, admin_email=admin_email
|
|
863
|
+
)
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
def model_empty_html(datasource: str, datasources: list[str], **chrome: str) -> str:
|
|
867
|
+
"""The clean state when nothing is deployed yet (no served model rows)."""
|
|
868
|
+
content = (
|
|
869
|
+
'<div class="crumbs">Model</div><h1>Model</h1>'
|
|
870
|
+
'<p class="lead">No model deployed yet. Author your semantic model in Claude with the agami '
|
|
871
|
+
"plugin and deploy it — the served subject areas, tables, and metrics will show up here, "
|
|
872
|
+
"read-only.</p>"
|
|
873
|
+
)
|
|
874
|
+
label = datasource or (datasources[0] if datasources else "")
|
|
875
|
+
tree = (
|
|
876
|
+
f'<aside class="tree"><div class="ds"><span class="muted">Datasource</span>'
|
|
877
|
+
f"<b>{ui.esc(label) or '—'}</b></div></aside>"
|
|
878
|
+
)
|
|
879
|
+
return _model_shell(content, tree, **chrome)
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def _glossary_html(key_terminology: dict[str, str]) -> str:
|
|
883
|
+
if not key_terminology:
|
|
884
|
+
return ""
|
|
885
|
+
terms = "".join(
|
|
886
|
+
f'<span class="term"><b>{ui.esc(k)}</b> {ui.esc(v)}</span>'
|
|
887
|
+
for k, v in key_terminology.items()
|
|
888
|
+
)
|
|
889
|
+
return f'<h2 class="sec">Glossary</h2><div class="gloss">{terms}</div>'
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
def _storage_html(connections: list[Any]) -> str:
|
|
893
|
+
# Names + types only — storage_config (hosts/credentials) is deliberately never rendered.
|
|
894
|
+
if not connections:
|
|
895
|
+
return ""
|
|
896
|
+
rows = "".join(
|
|
897
|
+
f'<span class="term"><b>{ui.esc(c.name)}</b> '
|
|
898
|
+
f"{ui.esc(getattr(c, 'storage_type', '') or '')}</span>"
|
|
899
|
+
for c in connections
|
|
900
|
+
)
|
|
901
|
+
return f'<h2 class="sec">Storage connections</h2><div class="gloss">{rows}</div>'
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
def model_overview_html(
|
|
905
|
+
org: Any, version: str | None, datasource: str, datasources: list[str], **chrome: str
|
|
906
|
+
) -> str:
|
|
907
|
+
"""The datasource landing: org header + glossary + storage + the subject-area list."""
|
|
908
|
+
tree = _model_tree_html(org, datasource, datasources)
|
|
909
|
+
table_total = sum(len(a.tables_defined) for a in org.subject_areas)
|
|
910
|
+
ver = ui.esc(version[:8]) if version else f"v{org.version}"
|
|
911
|
+
stats = (
|
|
912
|
+
f'<div class="stat"><div class="k">Subject areas</div>'
|
|
913
|
+
f'<div class="v">{len(org.subject_areas)}</div></div>'
|
|
914
|
+
f'<div class="stat"><div class="k">Tables</div><div class="v">{table_total}</div></div>'
|
|
915
|
+
f'<div class="stat"><div class="k">Version</div>'
|
|
916
|
+
f'<div class="v mono" style="font-size:14px">{ver}</div></div>'
|
|
917
|
+
f'<div class="stat"><div class="k">Fiscal year</div>'
|
|
918
|
+
f'<div class="v" style="font-size:14px">Starts month {org.fiscal_year_start_month}</div></div>'
|
|
919
|
+
)
|
|
920
|
+
areas = "".join(
|
|
921
|
+
f'<a class="trow" href="{_model_url(datasource, area=a.name)}">'
|
|
922
|
+
f'<span class="nm">{ui.esc(a.name)}</span>'
|
|
923
|
+
f'<span class="d">{ui.esc(a.description or "")}</span>'
|
|
924
|
+
f'<span class="meta">{len(a.tables_defined)} tables · {len(a.metrics)} metrics</span>'
|
|
925
|
+
'<span class="chev">›</span></a>'
|
|
926
|
+
for a in org.subject_areas
|
|
927
|
+
)
|
|
928
|
+
content = (
|
|
929
|
+
'<div class="crumbs">Model</div>'
|
|
930
|
+
f'<div class="h1row"><h1>{ui.esc(org.organization)}</h1>'
|
|
931
|
+
'<span class="readonly-pill">Read-only · edit in Claude</span></div>'
|
|
932
|
+
f'<p class="lead">{ui.esc(org.description or "The deployed semantic model.")}</p>'
|
|
933
|
+
f'<div class="statrow">{stats}</div>'
|
|
934
|
+
f"{_glossary_html(org.key_terminology)}"
|
|
935
|
+
f"{_storage_html(org.storage_connections)}"
|
|
936
|
+
f'<h2 class="sec">Subject areas <span class="c">{len(org.subject_areas)}</span></h2>'
|
|
937
|
+
f'<div class="tlist">{areas}</div>'
|
|
938
|
+
)
|
|
939
|
+
# Org-level (cross-area) metrics/entities belong to no single area — surface them here so they
|
|
940
|
+
# aren't silently dropped.
|
|
941
|
+
if org.cross_subject_area_metrics:
|
|
942
|
+
cards = "".join(_metric_card_html(m) for m in org.cross_subject_area_metrics)
|
|
943
|
+
content += f'<h2 class="sec">Cross-area metrics</h2><div class="grid">{cards}</div>'
|
|
944
|
+
if org.cross_subject_area_entities:
|
|
945
|
+
cards = "".join(_entity_card_html(e) for e in org.cross_subject_area_entities)
|
|
946
|
+
content += f'<h2 class="sec">Cross-area entities</h2><div class="grid">{cards}</div>'
|
|
947
|
+
return _model_shell(content, tree, **chrome)
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
def _metric_card_html(m: Any) -> str:
|
|
951
|
+
aliases = ", ".join(m.other_names) if m.other_names else ""
|
|
952
|
+
alias_html = f' <span class="al">· {ui.esc(aliases)}</span>' if aliases else ""
|
|
953
|
+
unit = f' <span class="al">· {ui.esc(m.unit)}</span>' if m.unit else ""
|
|
954
|
+
calc = ui.esc(m.calculation or "")
|
|
955
|
+
return (
|
|
956
|
+
f'<div class="mcard"><div class="nm">{ui.esc(m.name)}{alias_html}{unit}</div>'
|
|
957
|
+
f'<div class="muted" style="font-size:13px">{ui.esc(m.description or "")}</div>'
|
|
958
|
+
f'<span class="calc">{calc}</span></div>'
|
|
959
|
+
)
|
|
960
|
+
|
|
961
|
+
|
|
962
|
+
def _entity_card_html(e: Any) -> str:
|
|
963
|
+
aliases = ", ".join(e.other_names) if e.other_names else ""
|
|
964
|
+
alias_html = f' <span class="al">· {ui.esc(aliases)}</span>' if aliases else ""
|
|
965
|
+
pattern = (
|
|
966
|
+
f' <span class="muted mono" style="font-size:12px">{ui.esc(e.value_pattern)}</span>'
|
|
967
|
+
if e.value_pattern
|
|
968
|
+
else ""
|
|
969
|
+
)
|
|
970
|
+
return (
|
|
971
|
+
f'<div class="mcard"><div class="nm">{ui.esc(e.name)}{alias_html} '
|
|
972
|
+
f"{_conf_badge(e.confidence)}</div>"
|
|
973
|
+
f'<div class="muted" style="font-size:13px">{ui.esc(e.description or "")}{pattern}</div></div>'
|
|
974
|
+
)
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
def model_area_html(
|
|
978
|
+
org: Any, area: Any, datasource: str, datasources: list[str], **chrome: str
|
|
979
|
+
) -> str:
|
|
980
|
+
"""A subject-area landing: its tables (scannable), then metrics + entities as cards."""
|
|
981
|
+
tree = _model_tree_html(org, datasource, datasources, active_area=area.name)
|
|
982
|
+
tables = "".join(
|
|
983
|
+
f'<a class="trow" href="{_model_url(datasource, area=area.name, table=t.name)}">'
|
|
984
|
+
f'<span class="nm">{ui.esc(t.name)}</span>'
|
|
985
|
+
f'<span class="d">{ui.esc(t.description or "")}</span>'
|
|
986
|
+
f'<span class="meta">{len(t.columns)} cols · '
|
|
987
|
+
f"{ui.esc(_human_count(_est_rows_obj(t)))}</span>{_conf_badge(t.confidence)}"
|
|
988
|
+
'<span class="chev">›</span></a>'
|
|
989
|
+
for t in area.tables_defined
|
|
990
|
+
)
|
|
991
|
+
metrics = "".join(_metric_card_html(m) for m in area.metrics)
|
|
992
|
+
entities = "".join(_entity_card_html(e) for e in area.entities)
|
|
993
|
+
window = (
|
|
994
|
+
f"<span>default window · <b>{ui.esc(area.default_time_window)}</b></span>"
|
|
995
|
+
if area.default_time_window
|
|
996
|
+
else ""
|
|
997
|
+
)
|
|
998
|
+
content = (
|
|
999
|
+
f'<div class="crumbs"><a href="{_model_url(datasource)}">{ui.esc(datasource)}</a>'
|
|
1000
|
+
f'<span class="sep">/</span>{ui.esc(area.name)}</div>'
|
|
1001
|
+
f"<h1>{ui.esc(area.name)}</h1>"
|
|
1002
|
+
f'<div class="subline"><span><b>{len(area.tables_defined)}</b> tables</span>'
|
|
1003
|
+
f"<span><b>{len(area.metrics)}</b> metrics</span>"
|
|
1004
|
+
f"<span><b>{len(area.entities)}</b> entities</span>{window}</div>"
|
|
1005
|
+
f'<p class="lead">{ui.esc(area.description or "")}</p>'
|
|
1006
|
+
f'<h2 class="sec">Tables <span class="c">{len(area.tables_defined)}</span></h2>'
|
|
1007
|
+
f'<div class="tlist">{tables}</div>'
|
|
1008
|
+
)
|
|
1009
|
+
if metrics:
|
|
1010
|
+
content += f'<h2 class="sec">Metrics</h2><div class="grid">{metrics}</div>'
|
|
1011
|
+
if entities:
|
|
1012
|
+
content += f'<h2 class="sec">Entities</h2><div class="grid">{entities}</div>'
|
|
1013
|
+
return _model_shell(content, tree, **chrome)
|
|
1014
|
+
|
|
1015
|
+
|
|
1016
|
+
def _est_rows_obj(table: Any) -> int | None:
|
|
1017
|
+
ph = getattr(table, "performance_hints", None)
|
|
1018
|
+
return getattr(ph, "estimated_row_count", None) if ph is not None else None
|
|
1019
|
+
|
|
1020
|
+
|
|
1021
|
+
# --- the table (dataset) page ------------------------------------------------
|
|
1022
|
+
|
|
1023
|
+
_COL_THEAD = (
|
|
1024
|
+
'<thead><tr><th style="width:210px">Column</th><th style="width:120px">Type</th>'
|
|
1025
|
+
'<th>Description</th><th style="width:170px" class="flags">Flags</th></tr></thead>'
|
|
1026
|
+
)
|
|
1027
|
+
|
|
1028
|
+
|
|
1029
|
+
def _col_flags_html(col: Any) -> str:
|
|
1030
|
+
"""Per-column flags — only what carries signal (PK / FK / enum / unit / sensitive / caveat); the
|
|
1031
|
+
redundant per-column 'confirmed/approved' the old view repeated on every row is left out."""
|
|
1032
|
+
flags = []
|
|
1033
|
+
if col.primary_key:
|
|
1034
|
+
flags.append('<span class="badge b-pk">PK</span>')
|
|
1035
|
+
fk = getattr(col, "foreign_key", None)
|
|
1036
|
+
if fk is not None and getattr(fk, "table", None):
|
|
1037
|
+
flags.append(f'<span class="badge b-fk">FK → {ui.esc(fk.table)}</span>')
|
|
1038
|
+
if getattr(col, "choice_field", None):
|
|
1039
|
+
flags.append('<span class="badge b-soft">enum</span>')
|
|
1040
|
+
if col.unit:
|
|
1041
|
+
flags.append(f'<span class="badge b-soft">{ui.esc(str(col.unit))}</span>')
|
|
1042
|
+
if col.sensitive:
|
|
1043
|
+
flags.append('<span class="badge b-sensitive">● sensitive</span>')
|
|
1044
|
+
if col.caveats:
|
|
1045
|
+
flags.append('<span class="badge b-proposed">⚠ caveat</span>')
|
|
1046
|
+
return " ".join(flags)
|
|
1047
|
+
|
|
1048
|
+
|
|
1049
|
+
def _col_rows_html(columns: list[Any]) -> str:
|
|
1050
|
+
"""The <tr>s for a set of columns; a column with caveats gets an inline note row beneath it."""
|
|
1051
|
+
out = ""
|
|
1052
|
+
for col in columns:
|
|
1053
|
+
if col.description:
|
|
1054
|
+
desc = ui.esc(col.description)
|
|
1055
|
+
if getattr(col, "description_source", None) == "ai_unvalidated":
|
|
1056
|
+
desc += ' <span class="aichip" title="AI-described, unvalidated">AI</span>'
|
|
1057
|
+
else:
|
|
1058
|
+
desc = '<span class="dash">—</span>'
|
|
1059
|
+
out += (
|
|
1060
|
+
'<tr class="crow">'
|
|
1061
|
+
f'<td class="cn">{ui.esc(col.name)}</td>'
|
|
1062
|
+
f'<td><span class="ct">{ui.esc(str(col.type))}</span></td>'
|
|
1063
|
+
f'<td class="cd">{desc}</td>'
|
|
1064
|
+
f'<td class="flags">{_col_flags_html(col)}</td></tr>'
|
|
1065
|
+
)
|
|
1066
|
+
if col.caveats:
|
|
1067
|
+
note = "<br>".join(ui.esc(c) for c in col.caveats)
|
|
1068
|
+
out += f'<tr class="noterow"><td colspan="4"><div class="note">{note}</div></td></tr>'
|
|
1069
|
+
return out
|
|
1070
|
+
|
|
1071
|
+
|
|
1072
|
+
def _columns_flat_html(columns: list[Any]) -> str:
|
|
1073
|
+
"""A flat schema table. Narrow tables show in full; wide ones show the first 8 and tuck the rest
|
|
1074
|
+
behind a JS-free 'show all N' <details> — the default stays short without hiding anything."""
|
|
1075
|
+
if len(columns) <= 12:
|
|
1076
|
+
return f'<table class="cols">{_COL_THEAD}<tbody>{_col_rows_html(columns)}</tbody></table>'
|
|
1077
|
+
head = _col_rows_html(columns[:8])
|
|
1078
|
+
rest = _col_rows_html(columns[8:])
|
|
1079
|
+
return (
|
|
1080
|
+
f'<table class="cols">{_COL_THEAD}<tbody>{head}</tbody></table>'
|
|
1081
|
+
f'<details class="showmore"><summary>Show all {len(columns)} columns</summary>'
|
|
1082
|
+
f'<table class="cols"><tbody>{rest}</tbody></table></details>'
|
|
1083
|
+
)
|
|
1084
|
+
|
|
1085
|
+
|
|
1086
|
+
def _columns_grouped_html(table: Any) -> str:
|
|
1087
|
+
"""Collapsible groups from the table's authored `column_groups` (labelled by
|
|
1088
|
+
`column_group_descriptions`); columns in no authored group fall into a trailing 'Other'."""
|
|
1089
|
+
descs = getattr(table, "column_group_descriptions", {}) or {}
|
|
1090
|
+
by_name = {c.name: c for c in table.columns}
|
|
1091
|
+
seen: set[str] = set()
|
|
1092
|
+
blocks = ""
|
|
1093
|
+
for i, (gname, colnames) in enumerate(table.column_groups.items()):
|
|
1094
|
+
cols = [by_name[n] for n in colnames if n in by_name]
|
|
1095
|
+
seen.update(colnames)
|
|
1096
|
+
gloss = ui.esc(descs.get(gname, ""))
|
|
1097
|
+
gloss_html = f'<span class="gdesc">{gloss}</span>' if gloss else ""
|
|
1098
|
+
blocks += (
|
|
1099
|
+
f'<details class="grp"{" open" if i < 2 else ""}><summary>'
|
|
1100
|
+
f'<span class="gname">{ui.esc(gname)}</span>{gloss_html}'
|
|
1101
|
+
f'<span class="gn">{len(cols)}</span></summary>'
|
|
1102
|
+
f'<table class="cols"><tbody>{_col_rows_html(cols)}</tbody></table></details>'
|
|
1103
|
+
)
|
|
1104
|
+
other = [c for c in table.columns if c.name not in seen]
|
|
1105
|
+
if other:
|
|
1106
|
+
blocks += (
|
|
1107
|
+
'<details class="grp"><summary><span class="gname">Other</span>'
|
|
1108
|
+
f'<span class="gn">{len(other)}</span></summary>'
|
|
1109
|
+
f'<table class="cols"><tbody>{_col_rows_html(other)}</tbody></table></details>'
|
|
1110
|
+
)
|
|
1111
|
+
return blocks
|
|
1112
|
+
|
|
1113
|
+
|
|
1114
|
+
def _caveat_callout(caveats: list[str]) -> str:
|
|
1115
|
+
if not caveats:
|
|
1116
|
+
return ""
|
|
1117
|
+
body = "<br>".join(ui.esc(c) for c in caveats)
|
|
1118
|
+
return f'<div class="caveat"><span class="ic">⚠</span><div class="t">{body}</div></div>'
|
|
1119
|
+
|
|
1120
|
+
|
|
1121
|
+
def _table_rels_html(org: Any, area: Any, table_name: str) -> str:
|
|
1122
|
+
"""Relationships touching this table — within-area + the org-level cross-area ones."""
|
|
1123
|
+
rels = list(area.relationships) + list(org.cross_subject_area_relationships)
|
|
1124
|
+
rows = "".join(
|
|
1125
|
+
f'<div class="rel"><span class="mono">{ui.esc(r.from_table)}</span>'
|
|
1126
|
+
f'<span class="arr">→</span><span class="mono">{ui.esc(r.to_table)}</span>'
|
|
1127
|
+
f'<span class="badge b-soft">{ui.esc(str(r.relationship))}</span>'
|
|
1128
|
+
f'<span class="ro">{ui.esc(str(r.join_type))} · {ui.esc(str(r.confidence))}</span></div>'
|
|
1129
|
+
for r in rels
|
|
1130
|
+
if r.from_table == table_name or r.to_table == table_name
|
|
1131
|
+
)
|
|
1132
|
+
if not rows:
|
|
1133
|
+
return ""
|
|
1134
|
+
return f'<h2 class="sec">Relationships</h2><div class="card">{rows}</div>'
|
|
1135
|
+
|
|
1136
|
+
|
|
1137
|
+
def _table_metrics_html(area: Any, table_name: str) -> str:
|
|
1138
|
+
"""Metrics whose `source_tables` include this table."""
|
|
1139
|
+
using = [m for m in area.metrics if table_name in (m.source_tables or [])]
|
|
1140
|
+
if not using:
|
|
1141
|
+
return ""
|
|
1142
|
+
cards = "".join(_metric_card_html(m) for m in using)
|
|
1143
|
+
return f'<h2 class="sec">Used by metrics</h2><div class="grid">{cards}</div>'
|
|
1144
|
+
|
|
1145
|
+
|
|
1146
|
+
def model_table_html(
|
|
1147
|
+
org: Any, area: Any, table: Any, datasource: str, datasources: list[str], **chrome: str
|
|
1148
|
+
) -> str:
|
|
1149
|
+
"""A table (dataset) page — the heart of the explorer: header, caveats, columns
|
|
1150
|
+
(grouped-when-authored else flat), then relationships + metrics that use it."""
|
|
1151
|
+
tree = _model_tree_html(
|
|
1152
|
+
org, datasource, datasources, active_area=area.name, active_table=table.name
|
|
1153
|
+
)
|
|
1154
|
+
schema = (
|
|
1155
|
+
f'<span class="schema">{ui.esc(table.schema_name)}.</span>' if table.schema_name else ""
|
|
1156
|
+
)
|
|
1157
|
+
rows = _human_count(_est_rows_obj(table))
|
|
1158
|
+
grain = ", ".join(table.grain) if table.grain else ""
|
|
1159
|
+
aichip = (
|
|
1160
|
+
' <span class="descsrc">AI-described · unvalidated</span>'
|
|
1161
|
+
if getattr(table, "description_source", None) == "ai_unvalidated"
|
|
1162
|
+
else ""
|
|
1163
|
+
)
|
|
1164
|
+
sql_block = ""
|
|
1165
|
+
if getattr(table, "source_type", None) == "sql" and table.sql:
|
|
1166
|
+
sql_block = (
|
|
1167
|
+
'<h2 class="sec">Defining SQL</h2>'
|
|
1168
|
+
f'<pre class="code" style="white-space:pre-wrap;display:block;padding:12px">'
|
|
1169
|
+
f"{ui.esc(table.sql)}</pre>"
|
|
1170
|
+
)
|
|
1171
|
+
subline = "".join(
|
|
1172
|
+
f"<span>{s}</span>"
|
|
1173
|
+
for s in (
|
|
1174
|
+
f"<b>{len(table.columns)}</b> columns",
|
|
1175
|
+
f"<b>{ui.esc(rows)}</b> rows" if rows else "",
|
|
1176
|
+
f'grain · <b class="mono">{ui.esc(grain)}</b>' if grain else "",
|
|
1177
|
+
ui.esc(table.storage_connection or ""),
|
|
1178
|
+
)
|
|
1179
|
+
if s
|
|
1180
|
+
)
|
|
1181
|
+
columns = (
|
|
1182
|
+
_columns_grouped_html(table) if table.column_groups else _columns_flat_html(table.columns)
|
|
1183
|
+
)
|
|
1184
|
+
content = (
|
|
1185
|
+
f'<div class="crumbs"><a href="{_model_url(datasource)}">{ui.esc(datasource)}</a>'
|
|
1186
|
+
f'<span class="sep">/</span>'
|
|
1187
|
+
f'<a href="{_model_url(datasource, area=area.name)}">{ui.esc(area.name)}</a>'
|
|
1188
|
+
f'<span class="sep">/</span>{ui.esc(table.name)}</div>'
|
|
1189
|
+
f'<div class="h1row"><h1>{schema}{ui.esc(table.name)}</h1>'
|
|
1190
|
+
f"{_conf_badge(table.confidence)}"
|
|
1191
|
+
'<span class="readonly-pill">Read-only · edit in Claude</span></div>'
|
|
1192
|
+
f'<div class="subline">{subline}</div>'
|
|
1193
|
+
f'<p class="desc">{ui.esc(table.description or "")}{aichip}</p>'
|
|
1194
|
+
f"{_caveat_callout(table.caveats)}"
|
|
1195
|
+
f'<h2 class="sec">Columns <span class="c">{len(table.columns)}</span></h2>'
|
|
1196
|
+
f"{columns}{sql_block}"
|
|
1197
|
+
f"{_table_rels_html(org, area, table.name)}"
|
|
1198
|
+
f"{_table_metrics_html(area, table.name)}"
|
|
1199
|
+
)
|
|
1200
|
+
return _model_shell(content, tree, **chrome)
|
|
1201
|
+
|
|
1202
|
+
|
|
1203
|
+
def _qualified(schema: str | None, table: str) -> str:
|
|
1204
|
+
return f"{ui.esc(schema)}.{ui.esc(table)}" if schema else ui.esc(table)
|
|
1205
|
+
|
|
1206
|
+
|
|
1207
|
+
def _cross_rel_row_html(r: Any) -> str:
|
|
1208
|
+
"""One cross-area relationship: schema-qualified from→to, the join columns, cardinality, trust."""
|
|
1209
|
+
on = f"{ui.esc(r.from_column)} = {ui.esc(r.to_column)}" if r.from_column and r.to_column else ""
|
|
1210
|
+
meta = " · ".join(p for p in (on, ui.esc(str(r.join_type)), ui.esc(str(r.confidence))) if p)
|
|
1211
|
+
return (
|
|
1212
|
+
f'<div class="rel"><span class="mono">{_qualified(r.from_schema, r.from_table)}</span>'
|
|
1213
|
+
f'<span class="arr">→</span>'
|
|
1214
|
+
f'<span class="mono">{_qualified(r.to_schema, r.to_table)}</span>'
|
|
1215
|
+
f'<span class="badge b-soft">{ui.esc(str(r.relationship))}</span>'
|
|
1216
|
+
f'<span class="ro">{meta}</span></div>'
|
|
1217
|
+
)
|
|
1218
|
+
|
|
1219
|
+
|
|
1220
|
+
def model_relationships_html(
|
|
1221
|
+
org: Any, datasource: str, datasources: list[str], **chrome: str
|
|
1222
|
+
) -> str:
|
|
1223
|
+
"""The cross-area relationships — the org-level joins that span subject areas — grouped by
|
|
1224
|
+
area-pair, so the model's cross-area topology is readable in one place (within-area joins stay
|
|
1225
|
+
on each table page)."""
|
|
1226
|
+
tree = _model_tree_html(org, datasource, datasources, active_view="relationships")
|
|
1227
|
+
rels = org.cross_subject_area_relationships
|
|
1228
|
+
groups: dict[tuple[str, str], list[Any]] = {}
|
|
1229
|
+
for r in rels:
|
|
1230
|
+
groups.setdefault((r.from_subject_area, r.to_subject_area), []).append(r)
|
|
1231
|
+
# Most-connected area-pairs first, then alphabetical — the same ordering as the topology view.
|
|
1232
|
+
blocks = ""
|
|
1233
|
+
for (fa, ta), items in sorted(groups.items(), key=lambda kv: (-len(kv[1]), kv[0])):
|
|
1234
|
+
rows = "".join(_cross_rel_row_html(r) for r in items)
|
|
1235
|
+
blocks += (
|
|
1236
|
+
f'<details class="grp" open><summary>'
|
|
1237
|
+
f'<span class="gname">{ui.esc(fa)} <span class="arr">→</span> {ui.esc(ta)}</span>'
|
|
1238
|
+
f'<span class="gn">{len(items)}</span></summary>{rows}</details>'
|
|
1239
|
+
)
|
|
1240
|
+
body = blocks if rels else '<p class="lead">No cross-area relationships in this model.</p>'
|
|
1241
|
+
content = (
|
|
1242
|
+
f'<div class="crumbs"><a href="{_model_url(datasource)}">{ui.esc(datasource)}</a>'
|
|
1243
|
+
'<span class="sep">/</span>Relationships</div><h1>Cross-area relationships</h1>'
|
|
1244
|
+
f'<p class="lead">The <b>{len(rels)}</b> org-level joins that span subject areas, grouped by '
|
|
1245
|
+
"area-pair. (Joins within a single area show on each table page.)</p>"
|
|
1246
|
+
f"{body}"
|
|
1247
|
+
)
|
|
1248
|
+
return _model_shell(content, tree, **chrome)
|
|
1249
|
+
|
|
1250
|
+
|
|
1251
|
+
def model_context_html(
|
|
1252
|
+
org: Any, memory: dict[str, str], datasource: str, datasources: list[str], **chrome: str
|
|
1253
|
+
) -> str:
|
|
1254
|
+
"""The Domain-context page — the deployed ORGANIZATION.md rendered as (safe) markdown."""
|
|
1255
|
+
tree = _model_tree_html(org, datasource, datasources, active_view="context")
|
|
1256
|
+
org_md = memory.get("organization")
|
|
1257
|
+
doc = (
|
|
1258
|
+
f'<div class="context">{ui.md(org_md)}</div>'
|
|
1259
|
+
if org_md
|
|
1260
|
+
else '<p class="lead">No domain context (ORGANIZATION.md) deployed for this datasource.</p>'
|
|
1261
|
+
)
|
|
1262
|
+
content = (
|
|
1263
|
+
f'<div class="crumbs"><a href="{_model_url(datasource)}">{ui.esc(datasource)}</a>'
|
|
1264
|
+
'<span class="sep">/</span>Domain context</div><h1>Domain context</h1>'
|
|
1265
|
+
'<p class="lead">The deployed ORGANIZATION.md — the domain notes Claude reads as context. '
|
|
1266
|
+
f"Read-only.</p>{doc}"
|
|
1267
|
+
)
|
|
1268
|
+
return _model_shell(content, tree, **chrome)
|
|
1269
|
+
|
|
1270
|
+
|
|
1271
|
+
async def admin_model(request: Request) -> Response:
|
|
1272
|
+
"""The read-only Model explorer. Session-gated; a pure GET projection of the served model. Query:
|
|
1273
|
+
`?datasource=` (defaults to the first served), `?area=`, `?view=context`."""
|
|
1274
|
+
import model_store
|
|
1275
|
+
|
|
1276
|
+
admin = current_admin(request)
|
|
1277
|
+
if admin is None:
|
|
1278
|
+
return RedirectResponse("/admin/login", status_code=302)
|
|
1279
|
+
store = _open_store()
|
|
1280
|
+
try:
|
|
1281
|
+
chrome = _admin_chrome(store, admin)
|
|
1282
|
+
datasources = model_store.list_datasources(store) if store is not None else []
|
|
1283
|
+
if not datasources:
|
|
1284
|
+
return HTMLResponse(model_empty_html("", [], **chrome))
|
|
1285
|
+
datasource = request.query_params.get("datasource") or datasources[0]
|
|
1286
|
+
if datasource not in datasources: # an unknown/stale datasource param → the first served
|
|
1287
|
+
datasource = datasources[0]
|
|
1288
|
+
org = model_store.load_organization(store, datasource)
|
|
1289
|
+
if org is None:
|
|
1290
|
+
return HTMLResponse(model_empty_html(datasource, datasources, **chrome))
|
|
1291
|
+
view = request.query_params.get("view")
|
|
1292
|
+
if view == "relationships":
|
|
1293
|
+
return HTMLResponse(model_relationships_html(org, datasource, datasources, **chrome))
|
|
1294
|
+
if view == "context":
|
|
1295
|
+
memory = model_store.load_memory(store, datasource)
|
|
1296
|
+
return HTMLResponse(model_context_html(org, memory, datasource, datasources, **chrome))
|
|
1297
|
+
area_name = request.query_params.get("area")
|
|
1298
|
+
if area_name:
|
|
1299
|
+
area = next((a for a in org.subject_areas if a.name == area_name), None)
|
|
1300
|
+
if area is not None:
|
|
1301
|
+
table_name = request.query_params.get("table")
|
|
1302
|
+
if table_name:
|
|
1303
|
+
table = next((t for t in area.tables_defined if t.name == table_name), None)
|
|
1304
|
+
if table is not None:
|
|
1305
|
+
return HTMLResponse(
|
|
1306
|
+
model_table_html(org, area, table, datasource, datasources, **chrome)
|
|
1307
|
+
)
|
|
1308
|
+
return HTMLResponse(model_area_html(org, area, datasource, datasources, **chrome))
|
|
1309
|
+
version = model_store.newest_model_version(store, datasource)
|
|
1310
|
+
return HTMLResponse(model_overview_html(org, version, datasource, datasources, **chrome))
|
|
1311
|
+
finally:
|
|
1312
|
+
if store is not None:
|
|
1313
|
+
store.close()
|
|
1314
|
+
|
|
1315
|
+
|
|
1316
|
+
def routes() -> list:
|
|
1317
|
+
"""The `/admin/*` routes, for the transport to mount. Each is session-gated in the handler (the
|
|
1318
|
+
transport adds these paths to the bearer public-skip — they do their own auth, not the MCP one)."""
|
|
1319
|
+
from starlette.routing import Route
|
|
1320
|
+
|
|
1321
|
+
return [
|
|
1322
|
+
Route("/admin", admin_home, methods=["GET"]),
|
|
1323
|
+
# Read-only model explorer — GET only, by design (no write path can hide behind /admin/model).
|
|
1324
|
+
Route("/admin/model", admin_model, methods=["GET"]),
|
|
1325
|
+
Route("/admin/login", admin_login, methods=["GET", "POST"]),
|
|
1326
|
+
Route("/admin/logout", admin_logout, methods=["GET"]),
|
|
1327
|
+
# The admin OIDC start (handler lives in oauth_server, with the OIDC machinery). The IdP
|
|
1328
|
+
# redirects back to the shared /oauth/oidc/callback, which branches on the state's purpose.
|
|
1329
|
+
Route("/admin/oidc/start", admin_oidc_start, methods=["GET"]),
|
|
1330
|
+
Route("/admin/users", admin_create_user, methods=["POST"]),
|
|
1331
|
+
Route("/admin/users/status", admin_set_status, methods=["POST"]),
|
|
1332
|
+
]
|
|
1333
|
+
|
|
1334
|
+
|
|
1335
|
+
ADMIN_PATHS = (
|
|
1336
|
+
"/admin",
|
|
1337
|
+
"/admin/model",
|
|
1338
|
+
"/admin/login",
|
|
1339
|
+
"/admin/logout",
|
|
1340
|
+
"/admin/oidc/start",
|
|
1341
|
+
"/admin/users",
|
|
1342
|
+
"/admin/users/status",
|
|
1343
|
+
)
|