Skip to content

Commit 52d3f09

Browse files
Alexey Panfilovclaude
andcommitted
feat(adminapi): split admin UI into Routing + Analytics tabs
The usage dashboard was eating half the main page and pushing the model browser below the fold. Move analytics behind a dedicated tab: * Routing & Models at / — what the admin page used to be before Stage A.3, minus the usage section that was inlined in A.3. * Analytics at /analytics — the Usage & Cost dashboard (totals cards, daily chart, by-model/role tables, expensive turns). A shared partial (partials_layout.html) holds the common <head> CSS and the tab strip so both pages stay visually consistent without duplicating the style block. Each handler passes ActiveTab so the correct tab is highlighted. The /usage endpoint (htmx partial) is unchanged — it's what /analytics lazy-loads into its frame on page load. Linking to /usage directly still works; it just returns the section without the layout chrome. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent be9240e commit 52d3f09

6 files changed

Lines changed: 99 additions & 57 deletions

File tree

internal/adminapi/handlers.go

100644100755
Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,11 @@ type uiFilters struct {
6666
}
6767

6868
type indexData struct {
69-
Routing uiRouting
70-
Slots []uiSlot // OpenRouter-backed slots only (for per-model assign buttons)
71-
Models []uiModel
72-
Filters uiFilters
69+
ActiveTab string // "routing" or "analytics" — drives tab highlight in layout
70+
Routing uiRouting
71+
Slots []uiSlot // OpenRouter-backed slots only (for per-model assign buttons)
72+
Models []uiModel
73+
Filters uiFilters
7374
}
7475

7576
// --- Handlers ---
@@ -83,6 +84,16 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
8384
}
8485
}
8586

87+
func (s *Server) handleAnalytics(w http.ResponseWriter, r *http.Request) {
88+
// Analytics page is a thin frame — the usage section lazy-loads via /usage.
89+
// Still needs ActiveTab so the tab bar highlights the right entry.
90+
w.Header().Set("Content-Type", "text/html; charset=utf-8")
91+
if err := render(w, viewAnalytics, map[string]any{"ActiveTab": "analytics"}); err != nil {
92+
s.logger.Error("render analytics", "err", err)
93+
http.Error(w, "render error", http.StatusInternalServerError)
94+
}
95+
}
96+
8697
func (s *Server) handleModels(w http.ResponseWriter, r *http.Request) {
8798
data := s.buildIndexData(r)
8899
w.Header().Set("Content-Type", "text/html; charset=utf-8")
@@ -285,10 +296,11 @@ func (s *Server) buildIndexData(r *http.Request) indexData {
285296
}
286297
}
287298
return indexData{
288-
Routing: s.buildRouting(),
289-
Slots: s.openRouterSlots(),
290-
Models: models,
291-
Filters: filters,
299+
ActiveTab: "routing",
300+
Routing: s.buildRouting(),
301+
Slots: s.openRouterSlots(),
302+
Models: models,
303+
Filters: filters,
292304
}
293305
}
294306

internal/adminapi/server.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,4 +112,5 @@ func (s *Server) registerRoutes(mux *http.ServeMux) {
112112
mux.Handle("/routing/", authed(http.HandlerFunc(s.handleRoleSet))) // POST /routing/{role}/set
113113
mux.Handle("/refresh", authed(http.HandlerFunc(s.handleRefresh)))
114114
mux.Handle("/usage", authed(http.HandlerFunc(s.handleUsage)))
115+
mux.Handle("/analytics", authed(http.HandlerFunc(s.handleAnalytics)))
115116
}

internal/adminapi/templates.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const (
1717
viewModelsBrowser = "models_browser"
1818
viewModelsContent = "models_content"
1919
viewUsage = "usage"
20+
viewAnalytics = "analytics"
2021
)
2122

2223
// tmpls is the parsed template set. Entries are keyed by view name and
@@ -25,6 +26,7 @@ var tmpls = func() map[string]*template.Template {
2526
out := map[string]*template.Template{}
2627
// Shared partials are parsed into each view so {{template "..."}} calls work.
2728
partials := []string{
29+
"templates/partials_layout.html",
2830
"templates/partials_routing.html",
2931
"templates/partials_models_row.html",
3032
"templates/partials_models_browser.html",
@@ -38,6 +40,7 @@ var tmpls = func() map[string]*template.Template {
3840
viewModelsBrowser: "templates/partials_models_browser.html",
3941
viewModelsContent: "templates/partials_models_browser.html",
4042
viewUsage: "templates/usage.html",
43+
viewAnalytics: "templates/analytics.html",
4144
}
4245
funcs := template.FuncMap{
4346
"priceUSD": func(v float64) string {
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{{define "analytics"}}<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<title>Analytics — Personal Assistant</title>
5+
{{template "admin_head" .}}
6+
</head>
7+
<body>
8+
<h1>Personal Assistant · Analytics</h1>
9+
{{template "admin_tabs" .}}
10+
11+
<section class="admin-section" id="usage-section-wrap"
12+
hx-get="/usage?period=7d" hx-trigger="load" hx-swap="innerHTML">
13+
<div class="card"><div class="card__body" style="color:var(--text-secondary);">Loading usage…</div></div>
14+
</section>
15+
</body>
16+
</html>{{end}}

internal/adminapi/templates/index.html

Lines changed: 2 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,64 +1,17 @@
11
{{define "index"}}<!DOCTYPE html>
22
<html lang="en">
33
<head>
4-
<meta charset="utf-8">
5-
<meta name="viewport" content="width=device-width,initial-scale=1">
64
<title>Admin — Personal Assistant</title>
7-
<link rel="stylesheet" href="/static/dzarlax.css">
8-
<script src="/static/dzarlax.js" defer></script>
9-
<script src="/static/htmx.min.js" defer></script>
10-
<style>
11-
body { padding: 1.5rem 2rem; margin: 0; max-width: none; }
12-
h1 { margin-bottom: 1rem; }
13-
.admin-section { margin-bottom: 2rem; }
14-
.filters { display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: center; margin-bottom: 1rem; }
15-
.filters label { display: inline-flex; gap: 0.25rem; align-items: center; }
16-
.model-id { font-family: var(--font-mono, monospace); font-size: 0.875rem; }
17-
.caps-badges .badge { margin-right: 0.25rem; }
18-
.assign-buttons { margin-top: 0.25rem; }
19-
.assign-buttons .btn { margin-right: 0.25rem; margin-bottom: 0.25rem; }
20-
.price-cell { white-space: nowrap; font-variant-numeric: tabular-nums; }
21-
.preset-banner { padding: 0.75rem 1rem; border-radius: var(--radius, 8px); background: var(--surface-2, #E8E6E3); margin-bottom: 1rem; display: flex; justify-content: space-between; align-items: center; gap: 1rem; flex-wrap: wrap; }
22-
.preset-banner .preset-meta { color: var(--text-secondary, rgba(0,0,0,0.6)); font-size: 0.875rem; margin-top: 0.25rem; }
23-
.preset-banner details { margin-top: 0.5rem; font-size: 0.85em; }
24-
.preset-banner details > summary { cursor: pointer; color: var(--text-secondary, rgba(0,0,0,0.6)); user-select: none; list-style: none; }
25-
.preset-banner details > summary::-webkit-details-marker { display: none; }
26-
.preset-banner details > summary::before { content: "▸ "; display: inline-block; transition: transform 0.15s; }
27-
.preset-banner details[open] > summary::before { content: "▾ "; }
28-
.preset-banner details .formula { margin-top: 0.5rem; padding: 0.5rem 0.75rem; background: rgba(0,0,0,0.04); border-left: 3px solid var(--accent, #888); border-radius: 4px; line-height: 1.45; font-size: 0.9em; }
29-
.preset-banner details .formula code { background: rgba(0,0,0,0.06); padding: 0 0.25rem; border-radius: 3px; }
30-
.suggest-btn { margin-left: 0.5rem; }
31-
.table-wrap { overflow-x: auto; }
32-
.table-wrap table { width: 100%; }
33-
.usage-toolbar { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem; flex-wrap: wrap; }
34-
.usage-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 0.75rem; margin-bottom: 1.25rem; }
35-
.usage-card { padding: 0.75rem 1rem; border-radius: var(--radius, 8px); background: var(--surface-2, #E8E6E3); }
36-
.usage-card__label { font-size: 0.8em; color: var(--text-secondary, rgba(0,0,0,0.6)); text-transform: uppercase; letter-spacing: 0.05em; }
37-
.usage-card__value { font-size: 1.5em; font-weight: 600; font-variant-numeric: tabular-nums; margin-top: 0.25rem; }
38-
.usage-card__sub { font-size: 0.8em; color: var(--text-secondary, rgba(0,0,0,0.6)); margin-top: 0.25rem; }
39-
.usage-chart-wrap { margin-bottom: 1.5rem; padding: 0.75rem; background: rgba(0,0,0,0.03); border-radius: var(--radius, 8px); }
40-
.usage-chart-title { font-size: 0.85em; margin-bottom: 0.5rem; color: var(--text-secondary, rgba(0,0,0,0.7)); }
41-
.usage-chart { width: 100%; height: auto; display: block; }
42-
.usage-chart-legend { font-size: 0.85em; display: flex; gap: 1.5rem; margin-top: 0.5rem; color: var(--text-secondary, rgba(0,0,0,0.7)); }
43-
.usage-chart-legend__swatch { display: inline-block; width: 10px; height: 10px; margin-right: 0.25rem; border-radius: 2px; vertical-align: middle; }
44-
.usage-section-h { font-size: 0.95em; margin-top: 1.5rem; margin-bottom: 0.5rem; font-weight: 600; }
45-
.usage-turns td { vertical-align: top; }
46-
.usage-turn__q { color: var(--text-primary, #000); margin-bottom: 0.25rem; font-size: 0.9em; }
47-
.usage-turn__a { color: var(--text-secondary, rgba(0,0,0,0.7)); font-size: 0.9em; }
48-
</style>
5+
{{template "admin_head" .}}
496
</head>
507
<body>
518
<h1>Personal Assistant · Admin</h1>
9+
{{template "admin_tabs" .}}
5210

5311
<section class="admin-section" id="routing-section">
5412
{{template "routing_section" .Routing}}
5513
</section>
5614

57-
<section class="admin-section" id="usage-section-wrap"
58-
hx-get="/usage?period=7d" hx-trigger="load" hx-swap="innerHTML">
59-
<div class="card"><div class="card__body" style="color:var(--text-secondary);">Loading usage…</div></div>
60-
</section>
61-
6215
<section class="admin-section" id="models-section">
6316
<div class="card">
6417
<div class="card__header" style="display:flex;justify-content:space-between;align-items:center;">
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
{{define "admin_head"}}
2+
<meta charset="utf-8">
3+
<meta name="viewport" content="width=device-width,initial-scale=1">
4+
<link rel="stylesheet" href="/static/dzarlax.css">
5+
<script src="/static/dzarlax.js" defer></script>
6+
<script src="/static/htmx.min.js" defer></script>
7+
<style>
8+
body { padding: 1.5rem 2rem; margin: 0; max-width: none; }
9+
h1 { margin-bottom: 1rem; }
10+
.admin-tabs { display: flex; gap: 0.25rem; margin-bottom: 1.5rem; border-bottom: 1px solid rgba(0,0,0,0.1); }
11+
.admin-tabs__tab { padding: 0.5rem 1rem; color: var(--text-secondary, rgba(0,0,0,0.6)); text-decoration: none; border-bottom: 2px solid transparent; margin-bottom: -1px; }
12+
.admin-tabs__tab:hover { color: var(--text-primary, #000); }
13+
.admin-tabs__tab--active { color: var(--text-primary, #000); border-bottom-color: var(--accent, #3b82f6); font-weight: 600; }
14+
.admin-section { margin-bottom: 2rem; }
15+
.filters { display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: center; margin-bottom: 1rem; }
16+
.filters label { display: inline-flex; gap: 0.25rem; align-items: center; }
17+
.model-id { font-family: var(--font-mono, monospace); font-size: 0.875rem; }
18+
.caps-badges .badge { margin-right: 0.25rem; }
19+
.assign-buttons { margin-top: 0.25rem; }
20+
.assign-buttons .btn { margin-right: 0.25rem; margin-bottom: 0.25rem; }
21+
.price-cell { white-space: nowrap; font-variant-numeric: tabular-nums; }
22+
.preset-banner { padding: 0.75rem 1rem; border-radius: var(--radius, 8px); background: var(--surface-2, #E8E6E3); margin-bottom: 1rem; display: flex; justify-content: space-between; align-items: center; gap: 1rem; flex-wrap: wrap; }
23+
.preset-banner .preset-meta { color: var(--text-secondary, rgba(0,0,0,0.6)); font-size: 0.875rem; margin-top: 0.25rem; }
24+
.preset-banner details { margin-top: 0.5rem; font-size: 0.85em; }
25+
.preset-banner details > summary { cursor: pointer; color: var(--text-secondary, rgba(0,0,0,0.6)); user-select: none; list-style: none; }
26+
.preset-banner details > summary::-webkit-details-marker { display: none; }
27+
.preset-banner details > summary::before { content: "▸ "; display: inline-block; transition: transform 0.15s; }
28+
.preset-banner details[open] > summary::before { content: "▾ "; }
29+
.preset-banner details .formula { margin-top: 0.5rem; padding: 0.5rem 0.75rem; background: rgba(0,0,0,0.04); border-left: 3px solid var(--accent, #888); border-radius: 4px; line-height: 1.45; font-size: 0.9em; }
30+
.preset-banner details .formula code { background: rgba(0,0,0,0.06); padding: 0 0.25rem; border-radius: 3px; }
31+
.suggest-btn { margin-left: 0.5rem; }
32+
.table-wrap { overflow-x: auto; }
33+
.table-wrap table { width: 100%; }
34+
.usage-toolbar { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 1rem; flex-wrap: wrap; }
35+
.usage-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 0.75rem; margin-bottom: 1.25rem; }
36+
.usage-card { padding: 0.75rem 1rem; border-radius: var(--radius, 8px); background: var(--surface-2, #E8E6E3); }
37+
.usage-card__label { font-size: 0.8em; color: var(--text-secondary, rgba(0,0,0,0.6)); text-transform: uppercase; letter-spacing: 0.05em; }
38+
.usage-card__value { font-size: 1.5em; font-weight: 600; font-variant-numeric: tabular-nums; margin-top: 0.25rem; }
39+
.usage-card__sub { font-size: 0.8em; color: var(--text-secondary, rgba(0,0,0,0.6)); margin-top: 0.25rem; }
40+
.usage-chart-wrap { margin-bottom: 1.5rem; padding: 0.75rem; background: rgba(0,0,0,0.03); border-radius: var(--radius, 8px); }
41+
.usage-chart-title { font-size: 0.85em; margin-bottom: 0.5rem; color: var(--text-secondary, rgba(0,0,0,0.7)); }
42+
.usage-chart { width: 100%; height: auto; display: block; }
43+
.usage-chart-legend { font-size: 0.85em; display: flex; gap: 1.5rem; margin-top: 0.5rem; color: var(--text-secondary, rgba(0,0,0,0.7)); }
44+
.usage-chart-legend__swatch { display: inline-block; width: 10px; height: 10px; margin-right: 0.25rem; border-radius: 2px; vertical-align: middle; }
45+
.usage-section-h { font-size: 0.95em; margin-top: 1.5rem; margin-bottom: 0.5rem; font-weight: 600; }
46+
.usage-turns td { vertical-align: top; }
47+
.usage-turn__q { color: var(--text-primary, #000); margin-bottom: 0.25rem; font-size: 0.9em; }
48+
.usage-turn__a { color: var(--text-secondary, rgba(0,0,0,0.7)); font-size: 0.9em; }
49+
</style>
50+
{{end}}
51+
52+
{{define "admin_tabs"}}
53+
<nav class="admin-tabs">
54+
<a class="admin-tabs__tab {{if eq .ActiveTab "routing"}}admin-tabs__tab--active{{end}}" href="/">Routing & Models</a>
55+
<a class="admin-tabs__tab {{if eq .ActiveTab "analytics"}}admin-tabs__tab--active{{end}}" href="/analytics">Analytics</a>
56+
</nav>
57+
{{end}}

0 commit comments

Comments
 (0)