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:
Jaroslav Beneš
2026-07-21 13:43:00 +02:00
parent 7b67568f2c
commit 085dca5ec4
8 changed files with 709 additions and 163 deletions
+130 -14
View File
@@ -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 ----------------------------------------------------