Skip to content

Commit d0360c7

Browse files
authored
Make model catalog more compact (#6)
1 parent 8a2fae8 commit d0360c7

5 files changed

Lines changed: 308 additions & 56 deletions

File tree

internal/adminapi/adminapi_test.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,73 @@ func TestTemplatesParse(t *testing.T) {
176176
}
177177
}
178178

179+
func TestModelsBrowserRendersCardAndTableModes(t *testing.T) {
180+
slotInfos := []uiSlotInfo{{Name: "default-or", Provider: "openrouter"}}
181+
base := indexData{
182+
Routing: uiRouting{AllSlots: slotInfos},
183+
Slots: []uiSlot{{Name: "default-or", ModelID: "qwen/qwen3.5-plus"}},
184+
Models: []uiModel{{
185+
ID: "qwen/qwen3.5-plus",
186+
PromptPrice: 1.0,
187+
CompletionPrice: 3.0,
188+
ContextLength: 128000,
189+
Tools: true,
190+
StatusLabel: "Candidate",
191+
PrimaryReason: "tool calling",
192+
}},
193+
CatalogProvider: "openrouter",
194+
}
195+
196+
t.Run("cards default", func(t *testing.T) {
197+
data := base
198+
data.Filters.View = "cards"
199+
var buf bytes.Buffer
200+
if err := render(&buf, viewModelsBrowser, data); err != nil {
201+
t.Fatal(err)
202+
}
203+
html := buf.String()
204+
if !strings.Contains(html, "catalog-grid") || !strings.Contains(html, "catalog-card") {
205+
t.Fatalf("card view missing catalog cards: %s", html)
206+
}
207+
if strings.Contains(html, "catalog-audit-table") {
208+
t.Fatalf("card view should not render audit table")
209+
}
210+
})
211+
212+
t.Run("table audit", func(t *testing.T) {
213+
data := base
214+
data.Filters.View = "table"
215+
var buf bytes.Buffer
216+
if err := render(&buf, viewModelsBrowser, data); err != nil {
217+
t.Fatal(err)
218+
}
219+
html := buf.String()
220+
if !strings.Contains(html, "catalog-audit-table") {
221+
t.Fatalf("table view missing audit table: %s", html)
222+
}
223+
if strings.Contains(html, "catalog-grid") || strings.Contains(html, "catalog-card") {
224+
t.Fatalf("table view should not render card layout")
225+
}
226+
})
227+
228+
t.Run("preset state is preserved except clear", func(t *testing.T) {
229+
data := base
230+
data.Filters.View = "cards"
231+
data.Filters.ActivePreset = "default"
232+
var buf bytes.Buffer
233+
if err := render(&buf, viewModelsBrowser, data); err != nil {
234+
t.Fatal(err)
235+
}
236+
html := buf.String()
237+
if !strings.Contains(html, `name="preset" value="default"`) {
238+
t.Fatalf("active preset should be submitted with filters and view toggle: %s", html)
239+
}
240+
if !strings.Contains(html, `hx-get="/models?provider=openrouter&view=cards&full=1"`) {
241+
t.Fatalf("clear preset should refresh browser without preset: %s", html)
242+
}
243+
})
244+
}
245+
179246
func signedTGInitData(t *testing.T, token string, userID int64, authTime time.Time) string {
180247
t.Helper()
181248
userJSON, err := json.Marshal(tgAdminUser{ID: userID, FirstName: "Alex"})

internal/adminapi/handlers.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ type uiFilters struct {
9494
ValueLeaderHint string // e.g. "85% quality @ 30% price" (preset path only)
9595
Sort string // active sort column: "prompt", "completion", "score", "context", "id"
9696
SortDir string // "asc" or "desc"
97+
View string // "cards" (default) or "table"
9798
}
9899

99100
type uiModelSection struct {
@@ -369,6 +370,10 @@ func (s *Server) buildIndexData(r *http.Request) indexData {
369370
if catalogProv != "gemini" {
370371
catalogProv = "openrouter"
371372
}
373+
viewMode := q.Get("view")
374+
if viewMode != "table" {
375+
viewMode = "cards"
376+
}
372377

373378
ctx5, cancel5 := context.WithTimeout(r.Context(), 5*time.Second)
374379
defer cancel5()
@@ -417,6 +422,7 @@ func (s *Server) buildIndexData(r *http.Request) indexData {
417422
Tools: requiresTools(preset),
418423
Vision: preset == "multimodal",
419424
Reasoning: preset == "complex",
425+
View: viewMode,
420426
}
421427
// Knee-point value leader: best quality/price among frontier models
422428
// with quality ≥ 50% of top. Skipped when the top model is already
@@ -468,6 +474,7 @@ func (s *Server) buildIndexData(r *http.Request) indexData {
468474
Reasoning: q.Get("reasoning") != "",
469475
Sort: sortCol,
470476
SortDir: sortDir,
477+
View: viewMode,
471478
}
472479

473480
models := make([]uiModel, 0, len(allCaps))

internal/adminapi/templates/partials_layout.html

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,39 @@
2020
.admin-section { margin-bottom: 2rem; }
2121
.filters { display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: center; margin-bottom: 1rem; }
2222
.filters label { display: inline-flex; gap: 0.25rem; align-items: center; }
23+
.catalog-toolbar { display: flex; justify-content: space-between; align-items: center; gap: 0.75rem; margin-bottom: 0.75rem; padding-bottom: 0.75rem; border-bottom: 1px solid rgba(0,0,0,0.1); flex-wrap: wrap; }
24+
.catalog-tabs, .catalog-view-toggle { display: inline-flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
25+
.catalog-sort { width: 10rem; }
26+
.catalog-sort-dir { width: 6.5rem; }
2327
.model-id { font-family: var(--font-mono, monospace); font-size: 0.875rem; }
2428
.caps-badges .badge { margin-right: 0.25rem; }
25-
.assign-buttons { margin-top: 0.25rem; }
29+
.assign-buttons { display: flex; gap: 0.25rem; flex-wrap: wrap; margin-top: 0.25rem; }
2630
.assign-buttons .btn { margin-right: 0.25rem; margin-bottom: 0.25rem; }
31+
.model-actions { margin-top: 0.75rem; display: grid; gap: 0.55rem; }
32+
.model-actions__override { display: flex; gap: 0.35rem; align-items: center; flex-wrap: wrap; }
33+
.model-actions__override .form-input { width: 12rem; min-width: 9rem; font-size: 0.8rem; }
34+
.catalog-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(310px, 1fr)); gap: 0.75rem; align-items: stretch; }
35+
.catalog-card-section { margin-bottom: 1rem; }
36+
.catalog-section-head { display: flex; justify-content: space-between; gap: 1rem; align-items: end; padding: 0.35rem 0 0.5rem; }
37+
.catalog-section-head span, .catalog-section-head p { color: var(--text-secondary); font-size: 0.82rem; margin: 0; }
38+
.catalog-card { border: 1px solid rgba(0,0,0,0.10); border-radius: 8px; padding: 0.85rem; background: var(--surface, #fff); display: flex; flex-direction: column; min-width: 0; }
39+
.catalog-card__header { display: flex; justify-content: space-between; align-items: flex-start; gap: 0.75rem; }
40+
.catalog-card__title { min-width: 0; }
41+
.catalog-card__title .model-id { overflow-wrap: anywhere; line-height: 1.25; }
42+
.catalog-card__chips { display: flex; gap: 0.3rem; flex-wrap: wrap; margin-top: 0.4rem; }
43+
.catalog-card__price { text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; }
44+
.catalog-card__price span { font-weight: 700; display: block; }
45+
.catalog-card__price small { color: var(--text-secondary); font-size: 0.72rem; }
46+
.catalog-card__why { margin-top: 0.65rem; font-size: 0.86rem; color: var(--text); }
47+
.catalog-card__reasons, .catalog-card__warnings { display: flex; gap: 0.35rem; flex-wrap: wrap; margin-top: 0.45rem; font-size: 0.78rem; }
48+
.catalog-card__reasons { color: var(--text-secondary); }
49+
.catalog-card__warnings { color: var(--warning, #9a5b00); }
50+
.catalog-metrics { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 0.35rem; margin-top: 0.75rem; }
51+
.catalog-metrics div { min-width: 0; padding: 0.45rem 0.5rem; border-radius: 6px; background: var(--surface-2, rgba(0,0,0,0.04)); }
52+
.catalog-metrics span { display: block; color: var(--text-secondary); font-size: 0.68rem; text-transform: uppercase; }
53+
.catalog-metrics strong { display: block; margin-top: 0.12rem; font-size: 0.82rem; overflow-wrap: anywhere; }
54+
.catalog-card__telemetry { margin-top: 0.55rem; color: var(--text-secondary); font-size: 0.78rem; }
55+
.catalog-empty { text-align: center; padding: 2rem; color: var(--text-secondary); border: 1px dashed rgba(0,0,0,0.16); border-radius: 8px; }
2756
.price-cell { white-space: nowrap; font-variant-numeric: tabular-nums; }
2857
.preset-banner { padding: 0.75rem 1rem; border-radius: var(--radius); background: var(--surface-2); margin-bottom: 1rem; display: flex; justify-content: space-between; align-items: center; gap: 1rem; flex-wrap: wrap; }
2958
.preset-banner .preset-meta { color: var(--text-secondary); font-size: 0.875rem; margin-top: 0.25rem; }
@@ -61,6 +90,16 @@
6190
.admin-section { margin-bottom: 1.25rem; }
6291
.filters { gap: 0.5rem; }
6392
.filters input[type="text"], .filters input[type="search"] { flex: 1 1 100%; min-width: 0; }
93+
.catalog-toolbar { align-items: stretch; }
94+
.catalog-tabs, .catalog-view-toggle { width: 100%; }
95+
.catalog-view-toggle .btn, .catalog-tabs .btn { flex: 1 1 auto; }
96+
.catalog-sort, .catalog-sort-dir { flex: 1 1 8rem; width: auto; }
97+
.catalog-grid { grid-template-columns: 1fr; }
98+
.catalog-card { padding: 0.75rem; }
99+
.catalog-card__header { flex-direction: column; gap: 0.5rem; }
100+
.catalog-card__price { text-align: left; }
101+
.catalog-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
102+
.model-actions__override .form-input { width: 100%; }
64103
.usage-chart-wrap { padding: 0.5rem 0.6rem; }
65104
.usage-breakdown { grid-template-columns: 1fr; gap: 1rem; }
66105
.preset-banner { padding: 0.6rem 0.75rem; flex-direction: column; align-items: flex-start; }

internal/adminapi/templates/partials_models_browser.html

Lines changed: 61 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,63 @@
11
{{define "models_browser"}}
22
<div id="models-browser">
3-
<div class="catalog-tabs" style="display:flex;gap:0.5rem;margin-bottom:0.75rem;border-bottom:1px solid rgba(0,0,0,0.1);">
3+
<div class="catalog-toolbar">
4+
<div class="catalog-tabs">
45
<button type="button" class="btn btn--small {{if eq .CatalogProvider "openrouter"}}btn--primary{{else}}btn--ghost{{end}}"
5-
hx-get="/models?provider=openrouter&full=1"
6+
hx-get="/models?provider=openrouter&full=1&view={{.Filters.View}}{{if .Filters.ActivePreset}}&preset={{.Filters.ActivePreset}}{{end}}"
67
hx-target="#models-browser"
78
hx-swap="outerHTML">OpenRouter</button>
89
<button type="button" class="btn btn--small {{if eq .CatalogProvider "gemini"}}btn--primary{{else}}btn--ghost{{end}}"
9-
hx-get="/models?provider=gemini&full=1"
10+
hx-get="/models?provider=gemini&full=1&view={{.Filters.View}}{{if .Filters.ActivePreset}}&preset={{.Filters.ActivePreset}}{{end}}"
1011
hx-target="#models-browser"
1112
hx-swap="outerHTML">Gemini</button>
13+
</div>
14+
<div class="catalog-view-toggle" role="group" aria-label="Catalog view">
15+
<button type="button" class="btn btn--small {{if ne .Filters.View "table"}}btn--primary{{else}}btn--ghost{{end}}"
16+
hx-get="/models?full=1"
17+
hx-target="#models-browser"
18+
hx-swap="outerHTML"
19+
hx-include="#models-filters"
20+
hx-vals='{"view":"cards"}'>Cards</button>
21+
<button type="button" class="btn btn--small {{if eq .Filters.View "table"}}btn--primary{{else}}btn--ghost{{end}}"
22+
hx-get="/models?full=1"
23+
hx-target="#models-browser"
24+
hx-swap="outerHTML"
25+
hx-include="#models-filters"
26+
hx-vals='{"view":"table"}'>Audit table</button>
27+
</div>
1228
</div>
1329
<form id="models-filters" class="filters"
1430
hx-get="/models"
1531
hx-target="#models-content"
1632
hx-swap="outerHTML"
1733
hx-trigger="input delay:300ms, change">
1834
<input type="hidden" name="provider" value="{{.CatalogProvider}}">
35+
<input type="hidden" name="view" value="{{.Filters.View}}">
36+
{{if .Filters.ActivePreset}}<input type="hidden" name="preset" value="{{.Filters.ActivePreset}}">{{end}}
1937
<input class="form-input" type="search" name="q" placeholder="Search model id…"
2038
value="{{.Filters.Search}}" style="flex:1;min-width:12rem;">
2139
<label><input type="checkbox" name="free" {{if .Filters.Free}}checked{{end}}> Free</label>
2240
<label><input type="checkbox" name="vision" {{if .Filters.Vision}}checked{{end}}> Vision</label>
2341
<label><input type="checkbox" name="tools" {{if .Filters.Tools}}checked{{end}}> Tools</label>
2442
<label><input type="checkbox" name="reasoning" {{if .Filters.Reasoning}}checked{{end}}> Reasoning</label>
43+
<select class="form-input catalog-sort" name="sort" title="Sort models">
44+
<option value="prompt" {{if eq .Filters.Sort "prompt"}}selected{{end}}>Prompt price</option>
45+
<option value="completion" {{if eq .Filters.Sort "completion"}}selected{{end}}>Completion price</option>
46+
<option value="effective" {{if eq .Filters.Sort "effective"}}selected{{end}}>Effective price</option>
47+
<option value="score" {{if eq .Filters.Sort "score"}}selected{{end}}>Intel</option>
48+
<option value="coding" {{if eq .Filters.Sort "coding"}}selected{{end}}>Coding</option>
49+
<option value="agentic" {{if eq .Filters.Sort "agentic"}}selected{{end}}>Agentic</option>
50+
<option value="speed" {{if eq .Filters.Sort "speed"}}selected{{end}}>Speed</option>
51+
<option value="ttft" {{if eq .Filters.Sort "ttft"}}selected{{end}}>TTFT</option>
52+
<option value="context" {{if eq .Filters.Sort "context"}}selected{{end}}>Context</option>
53+
<option value="market" {{if eq .Filters.Sort "market"}}selected{{end}}>Market</option>
54+
<option value="value" {{if eq .Filters.Sort "value"}}selected{{end}}>Value</option>
55+
<option value="id" {{if eq .Filters.Sort "id"}}selected{{end}}>Model id</option>
56+
</select>
57+
<select class="form-input catalog-sort-dir" name="dir" title="Sort direction">
58+
<option value="asc" {{if eq .Filters.SortDir "asc"}}selected{{end}}>Asc</option>
59+
<option value="desc" {{if eq .Filters.SortDir "desc"}}selected{{end}}>Desc</option>
60+
</select>
2561
</form>
2662
{{template "models_content" .}}
2763
</div>
@@ -62,21 +98,29 @@
6298
</details>
6399
</div>
64100
<button class="btn btn--ghost btn--small"
65-
hx-get="/models"
66-
hx-target="#models-content"
67-
hx-swap="outerHTML"
68-
hx-include="#models-filters">
101+
hx-get="/models?provider={{.CatalogProvider}}&view={{.Filters.View}}&full=1"
102+
hx-target="#models-browser"
103+
hx-swap="outerHTML">
69104
× Clear preset
70105
</button>
71106
</div>
72107
{{end}}
73108

74-
<div class="table-wrap">
75-
<table>
76-
<thead>
77-
<tr>
78-
{{- $s := .Filters.Sort}}{{- $d := .Filters.SortDir}}
79-
<th hx-get="/models" hx-target="#models-content" hx-swap="outerHTML" hx-include="#models-filters"
109+
{{if eq .Filters.View "table"}}
110+
{{template "models_table" .}}
111+
{{else}}
112+
{{template "models_cards" .}}
113+
{{end}}
114+
</div>
115+
{{end}}
116+
117+
{{define "models_table"}}
118+
<div class="table-wrap catalog-audit-table">
119+
<table>
120+
<thead>
121+
<tr>
122+
{{- $s := .Filters.Sort}}{{- $d := .Filters.SortDir}}
123+
<th hx-get="/models" hx-target="#models-content" hx-swap="outerHTML" hx-include="#models-filters"
80124
hx-vals='{"sort":"id","dir":"{{if and (eq $s "id") (eq $d "asc")}}desc{{else}}asc{{end}}"}'
81125
style="cursor:pointer" title="Model identifier on OpenRouter (provider/model)">Model{{if eq $s "id"}} {{if eq $d "asc"}}↑{{else}}↓{{end}}{{end}}</th>
82126
<th hx-get="/models" hx-target="#models-content" hx-swap="outerHTML" hx-include="#models-filters"
@@ -119,10 +163,9 @@
119163
<th hx-get="/models" hx-target="#models-content" hx-swap="outerHTML" hx-include="#models-filters"
120164
hx-vals='{"sort":"value","dir":"{{if and (eq $s "value") (eq $d "desc")}}asc{{else}}desc{{end}}"}'
121165
style="cursor:pointer" title="Quality per $ (higher = more bang per buck). Role-specific in Suggest mode (uses role's Pareto axes); generic agentic/prompt in browse mode.">Value{{if eq $s "value"}} {{if eq $d "asc"}}↑{{else}}↓{{end}}{{end}}</th>
122-
</tr>
123-
</thead>
124-
{{template "models_tbody" .}}
125-
</table>
126-
</div>
166+
</tr>
167+
</thead>
168+
{{template "models_tbody" .}}
169+
</table>
127170
</div>
128171
{{end}}

0 commit comments

Comments
 (0)