Split the model admin into a list and a page per model
/admin/models rendered a full edit form for every model. With eight that
was merely long; with a hundred it was unusable, which is the report.
The list is now compact rows only -- avatar, name, badges, position,
reorder buttons, Edit link -- with search across id and display name,
filter tabs (All / Enabled / Disabled / Pinned / Restricted, each with a
count), a connection filter, and pagination at 40. Filters are links, so
a filtered view is a real URL you can keep. Editing moved to
/admin/models/{id}/edit, one model per page, with Previous/Next links so
a freshly imported connection can be tidied without returning to the
list each time.
Measured with 128 models: the list is 73 KB showing 40 rows over 4
pages, and a detail page is 17 KB. The old page would have rendered all
128 forms into one response.
Reordering needed rethinking at that size too. Up/down is fine for
nudging a model one place but hopeless for moving it sixty, so the
detail page has a position field you type into; the value is clamped and
a non-numeric one is ignored rather than throwing. The move buttons take
a `back` field so they return to whatever filtered, paginated view they
were pressed on instead of dumping you at page 1.
Also adds a select-all checkbox for the bulk bar, scoped to a container
selector rather than the page so a future list can carry more than one.
212 tests, ruff clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+130
-14
@@ -47,20 +47,104 @@ def _renumber(db: DBSession) -> None:
|
||||
db.commit()
|
||||
|
||||
|
||||
# --- The admin page ----------------------------------------------------------
|
||||
# --- Listing -----------------------------------------------------------------
|
||||
PAGE_SIZE = 40
|
||||
|
||||
# Filters offered as tabs above the list. Each is a predicate over a Model.
|
||||
FILTERS: dict[str, tuple[str, object]] = {
|
||||
"all": ("All", lambda m: True),
|
||||
"enabled": ("Enabled", lambda m: m.enabled),
|
||||
"disabled": ("Disabled", lambda m: not m.enabled),
|
||||
"pinned": ("Pinned", lambda m: m.pinned),
|
||||
"restricted": ("Restricted", lambda m: not m.public),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/admin/models")
|
||||
async def models_page(request: Request, db: Db, user: AdminUser, saved: str = ""):
|
||||
models = _ordered(db)
|
||||
async def models_page(
|
||||
request: Request,
|
||||
db: Db,
|
||||
user: AdminUser,
|
||||
saved: str = "",
|
||||
q: str = "",
|
||||
filter: str = "all",
|
||||
connection: str = "",
|
||||
page: int = 1,
|
||||
):
|
||||
"""The model list.
|
||||
|
||||
Compact rows only -- editing happens on a page of its own. A connection can
|
||||
advertise a hundred models, and a list that renders a full form for each of
|
||||
them is unusable at that size.
|
||||
"""
|
||||
everything = _ordered(db)
|
||||
|
||||
predicate = FILTERS.get(filter, FILTERS["all"])[1]
|
||||
needle = q.strip().lower()
|
||||
|
||||
matching = [
|
||||
model
|
||||
for model in everything
|
||||
if predicate(model)
|
||||
and (not connection or model.connection_id == connection)
|
||||
and (
|
||||
not needle
|
||||
or needle in model.model_id.lower()
|
||||
or needle in (model.display_name or "").lower()
|
||||
)
|
||||
]
|
||||
|
||||
pages = max(1, -(-len(matching) // PAGE_SIZE))
|
||||
page = max(1, min(page, pages))
|
||||
start = (page - 1) * PAGE_SIZE
|
||||
visible = matching[start : start + PAGE_SIZE]
|
||||
|
||||
return render(
|
||||
request,
|
||||
"admin/models.html",
|
||||
{
|
||||
"models": models,
|
||||
"groups": list(db.scalars(select(Group).order_by(Group.name))),
|
||||
"models": visible,
|
||||
"total": len(everything),
|
||||
"matched": len(matching),
|
||||
"page": page,
|
||||
"pages": pages,
|
||||
"page_start": start,
|
||||
"connections": list(db.scalars(select(Connection).order_by(Connection.name))),
|
||||
"default_model": settings_store.get(db, "default_model") or "",
|
||||
"instance_prompt": settings_store.get(db, "system_prompt") or "",
|
||||
"counts": {
|
||||
key: sum(1 for m in everything if test(m)) for key, (_, test) in FILTERS.items()
|
||||
},
|
||||
"filters": {key: label for key, (label, _) in FILTERS.items()},
|
||||
"active_filter": filter if filter in FILTERS else "all",
|
||||
"q": q,
|
||||
"connection_id": connection,
|
||||
"saved": saved,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/admin/models/{model_id}/edit")
|
||||
async def model_detail(
|
||||
request: Request, db: Db, user: AdminUser, model_id: str, saved: str = ""
|
||||
):
|
||||
"""Everything about one model, on its own page."""
|
||||
model = _model(db, model_id)
|
||||
ordered = _ordered(db)
|
||||
index = next((i for i, m in enumerate(ordered) if m.id == model.id), 0)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"admin/model_detail.html",
|
||||
{
|
||||
"model": model,
|
||||
"groups": list(db.scalars(select(Group).order_by(Group.name))),
|
||||
"capabilities": CAPABILITIES,
|
||||
"default_model": settings_store.get(db, "default_model") or "",
|
||||
"instance_prompt": settings_store.get(db, "system_prompt") or "",
|
||||
"position_of": index + 1,
|
||||
"total": len(ordered),
|
||||
"previous": ordered[index - 1] if index > 0 else None,
|
||||
"next": ordered[index + 1] if index + 1 < len(ordered) else None,
|
||||
"saved": saved,
|
||||
},
|
||||
)
|
||||
@@ -107,6 +191,7 @@ async def update_model(
|
||||
enabled: bool = Form(False),
|
||||
pinned: bool = Form(False),
|
||||
public: bool = Form(False),
|
||||
position: str = Form(""),
|
||||
group_ids: list[str] = Form(default=[]),
|
||||
capability: list[str] = Form(default=[]),
|
||||
) -> Response:
|
||||
@@ -130,13 +215,34 @@ async def update_model(
|
||||
model.groups = list(db.scalars(select(Group).where(Group.id.in_(group_ids or []))))
|
||||
|
||||
db.commit()
|
||||
|
||||
# Typing a position is the only workable way to reorder a long list; the
|
||||
# up/down buttons are for nudging a model one place.
|
||||
if position.strip():
|
||||
try:
|
||||
wanted = max(1, int(position)) - 1
|
||||
except ValueError:
|
||||
wanted = None
|
||||
if wanted is not None:
|
||||
ordered = [m for m in _ordered(db) if m.id != model.id]
|
||||
ordered.insert(min(wanted, len(ordered)), model)
|
||||
for index, item in enumerate(ordered):
|
||||
item.position = index
|
||||
db.commit()
|
||||
|
||||
log.info("model %s updated by %s", model.model_id, user.email)
|
||||
return RedirectResponse("/admin/models?saved=Model+saved.", status_code=303)
|
||||
return RedirectResponse(
|
||||
f"/admin/models/{model.id}/edit?saved=Saved.", status_code=303
|
||||
)
|
||||
|
||||
|
||||
@router.post("/admin/models/{model_id}/move")
|
||||
async def move_model(
|
||||
db: Db, user: AdminUser, model_id: str, direction: str = Form(...)
|
||||
db: Db,
|
||||
user: AdminUser,
|
||||
model_id: str,
|
||||
direction: str = Form(...),
|
||||
back: str = Form(""),
|
||||
) -> Response:
|
||||
"""Swap a model with its neighbour."""
|
||||
model = _model(db, model_id)
|
||||
@@ -153,17 +259,21 @@ async def move_model(
|
||||
item.position = position
|
||||
db.commit()
|
||||
|
||||
return RedirectResponse("/admin/models", status_code=303)
|
||||
# Back to whichever filtered, paginated view the button was pressed on.
|
||||
return RedirectResponse(back or "/admin/models", status_code=303)
|
||||
|
||||
|
||||
@router.post("/admin/models/{model_id}/default")
|
||||
async def set_default_model(db: Db, user: AdminUser, model_id: str) -> Response:
|
||||
async def set_default_model(
|
||||
db: Db, user: AdminUser, model_id: str, back: str = Form("")
|
||||
) -> Response:
|
||||
"""Make a model the instance default for new chats."""
|
||||
model = _model(db, model_id)
|
||||
settings_store.update(db, {"default_model": model.model_id})
|
||||
log.info("default model set to %s by %s", model.model_id, user.email)
|
||||
return RedirectResponse(
|
||||
f"/admin/models?saved={model.label}+is+now+the+default.", status_code=303
|
||||
back or f"/admin/models/{model.id}/edit?saved=Now+the+default+model.",
|
||||
status_code=303,
|
||||
)
|
||||
|
||||
|
||||
@@ -177,7 +287,9 @@ async def upload_model_image(
|
||||
try:
|
||||
filename = uploads.save_model_image(payload, image.content_type or "")
|
||||
except uploads.UploadError as exc:
|
||||
return RedirectResponse(f"/admin/models?saved={exc}", status_code=303)
|
||||
return RedirectResponse(
|
||||
f"/admin/models/{model.id}/edit?saved={exc}", status_code=303
|
||||
)
|
||||
|
||||
# Remove the old file rather than orphaning it in the uploads directory.
|
||||
if model.image_path:
|
||||
@@ -185,7 +297,9 @@ async def upload_model_image(
|
||||
|
||||
model.image_path = filename
|
||||
db.commit()
|
||||
return RedirectResponse("/admin/models?saved=Image+updated.", status_code=303)
|
||||
return RedirectResponse(
|
||||
f"/admin/models/{model.id}/edit?saved=Image+updated.", status_code=303
|
||||
)
|
||||
|
||||
|
||||
@router.post("/admin/models/{model_id}/image/delete")
|
||||
@@ -195,7 +309,9 @@ async def delete_model_image(db: Db, user: AdminUser, model_id: str) -> Response
|
||||
uploads.delete_model_image(model.image_path)
|
||||
model.image_path = ""
|
||||
db.commit()
|
||||
return RedirectResponse("/admin/models?saved=Image+removed.", status_code=303)
|
||||
return RedirectResponse(
|
||||
f"/admin/models/{model.id}/edit?saved=Image+removed.", status_code=303
|
||||
)
|
||||
|
||||
|
||||
# --- Serving model images ----------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user