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 29db54960e
commit 75edae039b
7 changed files with 702 additions and 162 deletions
@@ -0,0 +1,196 @@
{% extends "admin/_layout.html" %}
{% from "_macros.html" import icon, model_avatar %}
{% set section = "models" %}
{% block title %}{{ model.label }} - Models - LLeMbas{% endblock %}
{% block heading %}{{ model.label }}{% endblock %}
{% block admin_content %}
<nav class="crumbs">
<a href="/admin/models">{{ icon("chevron-right", "icon--sm crumbs__back") }} All models</a>
<span class="spacer"></span>
{# Walking the list without going back to it, which is what you want when
tidying up a freshly imported connection. #}
{% if previous %}
<a class="btn btn--sm" href="/admin/models/{{ previous.id }}/edit"
title="{{ previous.label }}">{{ icon("arrow-up", "icon--sm") }} Previous</a>
{% endif %}
<span class="text-xs faint">{{ position_of }} of {{ total }}</span>
{% if next %}
<a class="btn btn--sm" href="/admin/models/{{ next.id }}/edit"
title="{{ next.label }}">Next {{ icon("arrow-down", "icon--sm") }}</a>
{% endif %}
</nav>
{% if saved %}
<div class="alert alert--success">{{ icon("check", "icon--sm") }} <span>{{ saved }}</span></div>
{% endif %}
<section class="card">
<div class="card__header">
<div class="row" style="gap: var(--sp-3); min-width: 0">
{{ model_avatar(model, cls="model-detail__avatar") }}
<div style="min-width: 0">
<strong>{{ model.label }}</strong>
<div><code class="text-xs faint">{{ model.model_id }}</code></div>
<div class="text-xs faint">via {{ model.connection.name }}</div>
</div>
</div>
<div class="btn-row">
{% if model.model_id == default_model %}
<span class="badge badge--gold">{{ icon("star", "icon--sm") }} default model</span>
{% else %}
<form method="post" action="/admin/models/{{ model.id }}/default">
<button class="btn btn--sm" type="submit">
{{ icon("star", "icon--sm") }} Make default
</button>
</form>
{% endif %}
</div>
</div>
<div class="btn-row">
<form method="post" action="/admin/models/{{ model.id }}/image"
enctype="multipart/form-data" class="btn-row">
<input class="input input--file" type="file" name="image"
accept="image/png,image/jpeg,image/webp,image/gif" required
aria-label="Model image">
<button class="btn btn--sm" type="submit">{{ icon("image", "icon--sm") }} Upload image</button>
</form>
{% if model.image_path %}
<form method="post" action="/admin/models/{{ model.id }}/image/delete">
<button class="btn btn--sm btn--danger" type="submit">Remove image</button>
</form>
{% endif %}
</div>
<p class="field__hint">
PNG, JPEG, WEBP or GIF, under 2 MB. Without one, the model gets a generated
badge whose colour is derived from its id.
</p>
</section>
<form method="post" action="/admin/models/{{ model.id }}">
<section class="card">
<h2 class="card__title">Presentation</h2>
<div class="grid grid--2">
<div class="field">
<label class="field__label" for="display-name">Display name</label>
<input class="input" id="display-name" name="display_name"
value="{{ model.display_name }}" placeholder="{{ model.model_id }}">
<p class="field__hint">Shown instead of the raw id. Empty uses the id.</p>
</div>
<div class="field">
<label class="field__label" for="position">Position</label>
<input class="input" id="position" name="position" type="number" min="1"
max="{{ total }}" value="{{ position_of }}">
<p class="field__hint">
Place in the picker, 1{{ total }}. Typing a number is the workable way
to move a model a long distance.
</p>
</div>
</div>
<div class="field">
<label class="field__label" for="description">Description</label>
<textarea class="textarea" id="description" name="description" rows="2"
placeholder="What is this model good at?">{{ model.description }}</textarea>
<p class="field__hint">Shown in the chat settings panel and your users' settings.</p>
</div>
</section>
<section class="card">
<h2 class="card__title">System prompt</h2>
<p class="card__lede">
Used by chats on this model that have no prompt of their own. A chat's own
prompt overrides this; this overrides the instance prompt.
</p>
<div class="field">
<label class="field__label visually-hidden" for="system-prompt">System prompt</label>
<textarea class="textarea" id="system-prompt" name="system_prompt" rows="5"
placeholder="{{ instance_prompt or 'No instance prompt is set.' }}"
>{{ model.system_prompt }}</textarea>
<p class="field__hint">
{% if instance_prompt %}
Leave empty to fall back to the instance prompt shown above.
{% else %}
Leave empty to send no system prompt.
{% endif %}
</p>
</div>
</section>
<section class="card">
<h2 class="card__title">Capabilities</h2>
<p class="card__lede">
Endpoints rarely advertise these reliably, so they are your call.
<strong>reasoning</strong> shows the thinking block,
<strong>vision</strong> lets images be sent to this model, and
<strong>tools</strong> is reserved for a feature not built yet.
</p>
<div class="checkbox-row">
{% for name in capabilities %}
<label class="checkbox">
<input type="checkbox" name="capability" value="{{ name }}"
{{ 'checked' if (model.capabilities_json or {}).get(name) }}>
<span>{{ name }}</span>
</label>
{% endfor %}
</div>
</section>
<section class="card">
<h2 class="card__title">Availability</h2>
<div class="field">
<div class="checkbox-row">
<label class="checkbox">
<input type="checkbox" name="enabled" value="true" {{ 'checked' if model.enabled }}>
<span>Enabled — offered in chats</span>
</label>
<label class="checkbox">
<input type="checkbox" name="pinned" value="true" {{ 'checked' if model.pinned }}>
<span>Pinned — shortcut in the chat sidebar</span>
</label>
</div>
</div>
<div class="field">
<label class="checkbox">
<input type="checkbox" name="public" value="true" {{ 'checked' if model.public }}>
<span>Available to everyone</span>
</label>
<p class="field__hint">
Uncheck to restrict this model to chosen groups. Administrators always
have access.
</p>
</div>
<div class="field">
<span class="field__label">Groups with access</span>
{% if groups %}
<div class="checkbox-row">
{% for group in groups %}
<label class="checkbox">
<input type="checkbox" name="group_ids" value="{{ group.id }}"
{{ 'checked' if group in model.groups }}>
<span>{{ group.name }}</span>
</label>
{% endfor %}
</div>
<p class="field__hint">Ignored while the model is available to everyone.</p>
{% else %}
<p class="field__hint">
No groups yet — <a href="/admin/groups">create one</a> to restrict access.
</p>
{% endif %}
</div>
</section>
<div class="btn-row">
<button class="btn btn--primary" type="submit">Save changes</button>
<a class="btn btn--ghost" href="/admin/models">Back to all models</a>
</div>
</form>
{% endblock %}