Draw one menu on every page

Each page wrote its own nav: the same links in a different order, some
missing, the current page sometimes listed and sometimes not. A shared
"nav" template now draws it everywhere — Cluster, People, Quotas, Usage,
Mirror for an administrator, then Chat, Your keys and Sign out — with
the current page marked by aria-current. Pages rendered outside the
account pages (the dashboard, node logs) get it through the request
context their admin wrapper sets. The admin listener's own page keeps
its metrics/pprof/api links.

The chat and key pages said "project proj-3kq7…"; they now show the
project's path, and the key page no longer repeats the email.

A test renders several admin pages and requires the same menu on each.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ilker ManapandClaude Opus 5.5 committed 2026-09-28 17:55:36 +02:00
1 parent c22c3a309b
commit c9cd25efc2
23 files changed
+209 -44

No files matched your search

+11
View File
@@ -19,6 +19,17 @@ is only released once its **exit criterion** is met, not when its code lands.
Until v1.0.0 the minor version is the breaking one: configuration formats and Go
APIs change between minors without a deprecation period.
## Unreleased
### One menu on every page
Each page drew its own menu, and no two agreed: different links, in a different
order, the page you were on sometimes listed and sometimes not. Every signed-in
page now draws the same one — Cluster, People, Quotas, Usage, Mirror on the
left for an administrator, Chat, Your keys and Sign out on the right for
everyone — with the current page marked. The chat and key pages name the
person's project by its path rather than its id.
## v0.9.2 — 2026-09-28
### A new control plane serves a model by itself
+2
View File
@@ -217,6 +217,7 @@ func (p *AccountPages) logout(w http.ResponseWriter, r *http.Request) {
type limitRow struct{ Name, Value string }
type profileData struct {
Nav Nav
User store.User
Keys []store.Key
Limits []limitRow
@@ -313,6 +314,7 @@ func (p *AccountPages) render(w http.ResponseWriter, r *http.Request,
sess store.Session, user store.User, d profileData,
) {
d.User = user
d.Nav = p.navFor(r, user, "keys")
d.MinPassword = account.MinPasswordLen
d.BaseURL = baseURL(r)
d.ExampleModel = p.exampleModel()
+2
View File
@@ -81,6 +81,7 @@ type mirrorRow struct {
}
type adminMirrorData struct {
Nav Nav
Addr string
Base string
Rows []mirrorRow
@@ -261,6 +262,7 @@ func (p *AccountPages) renderMirror(w http.ResponseWriter, r *http.Request,
m MirrorAdmin, msg, errMsg string,
) {
d := adminMirrorData{
Nav: navFrom(r, "mirror"),
Addr: m.Addr(),
Message: msg,
Error: errMsg,
+1 -1
View File
@@ -25,6 +25,6 @@ func (p *AccountPages) AdminPage(dashboard http.HandlerFunc) http.HandlerFunc {
return
}
p.log.Info("admin viewed the cluster", "user", user.ID, "email", user.Email)
dashboard(w, r)
dashboard(w, p.withNav(r, user))
}
}
+2
View File
@@ -68,6 +68,7 @@ type quotaRow struct {
}
type adminQuotasData struct {
Nav Nav
Rows []quotaRow
// BudgetsBind is false when no model has a price, which makes every budget
// on this page a number that cannot move. Shown, because a limit that binds
@@ -197,6 +198,7 @@ func (p *AccountPages) renderQuotas(w http.ResponseWriter, r *http.Request,
) {
const window = 30 * 24 * time.Hour
d := adminQuotasData{
Nav: navFrom(r, "quotas"),
Since: "30 days", Message: msg, Error: errMsg,
CSRF: csrfFor(r), Version: p.version,
Now: time.Now().UTC().Format(time.RFC3339),
+2 -1
View File
@@ -277,7 +277,8 @@ func TestOnlyTheClickedBranchOpens(t *testing.T) {
// One form at a time. A disclosure on every row would put a form in the
// markup for each branch of a page whose job is to be read as a tree.
if n := strings.Count(body, `name="csrf"`); n != 1 {
// The menu's sign-out form carries a token too; it is not a quota form.
if n := strings.Count(body, `name="csrf"`) - strings.Count(body, `action="/logout"`); n != 1 {
t.Errorf("expected exactly one form, found %d", n)
}
if !strings.Contains(body, `href="/admin/quotas?edit=project:proj-1"`) {
+3 -1
View File
@@ -61,6 +61,7 @@ type usageRow struct {
}
type adminUsageData struct {
Nav Nav
Groups []usageGroup
Windows []struct {
Key, Label string
@@ -107,7 +108,8 @@ func (p *AccountPages) renderUsage(w http.ResponseWriter, r *http.Request,
win := windowFor(r.URL.Query().Get("window"))
d := adminUsageData{
By: string(by), Window: win.Key,
Nav: navFrom(r, "usage"),
By: string(by), Window: win.Key,
Priced: priced != nil && priced.AnythingPriced(),
Version: p.version,
Now: time.Now().UTC().Format(time.RFC3339),
+3 -1
View File
@@ -60,6 +60,7 @@ type keyRow struct {
}
type adminUsersData struct {
Nav Nav
Pending []store.User
Accounts []userRow
Tree []store.TreeOrg
@@ -342,7 +343,7 @@ func (p *AccountPages) adminOnly(
http.NotFound(w, r)
return
}
next(w, r, user)
next(w, p.withNav(r, user), user)
}
}
@@ -385,6 +386,7 @@ func (p *AccountPages) renderUsers(w http.ResponseWriter, r *http.Request,
) {
ctx := r.Context()
d := adminUsersData{
Nav: navFrom(r, "people"),
Message: msg, Error: errMsg, Open: open, AdminID: admin.ID,
CSRF: csrfFor(r),
Version: p.version,
+3 -1
View File
@@ -21,6 +21,7 @@ type ChatModel struct {
}
type chatData struct {
Nav Nav
User store.User
Models []ChatModel
CSRF string
@@ -33,6 +34,7 @@ func Chat(user store.User, models []ChatModel, version string) http.HandlerFunc
return func(w http.ResponseWriter, r *http.Request) {
noStore(w)
render(w, "chat", chatData{
Nav: navFrom(r, "chat"),
User: user, Models: models, CSRF: csrfFor(r),
Version: version, Now: time.Now().UTC().Format(time.RFC3339),
})
@@ -73,6 +75,6 @@ func (p *AccountPages) Guard(
return
}
}
next(w, r, user)
next(w, p.withNav(r, user), user)
}
}
+6
View File
@@ -14,6 +14,8 @@ import (
// DashboardData is the whole page.
type DashboardData struct {
// Menu is the shared top menu; Nav below is what the cluster page adds.
Menu Nav
Nodes []adminapi.NodeView
Models []adminapi.ModelView
Replicas []adminapi.ReplicaView
@@ -139,6 +141,7 @@ func Dashboard(src Source, usage UsageSource, version string, refresh time.Durat
}
return func(w http.ResponseWriter, r *http.Request) {
d := DashboardData{
Menu: navFrom(r, "cluster"),
OnAdminListener: onAdminListener,
Nav: nav,
Fleet: LoadFleetCharts(r.Context(), nav.History, nav.Window, log),
@@ -173,6 +176,9 @@ func Dashboard(src Source, usage UsageSource, version string, refresh time.Durat
d.addCharts(r.Context(), usage, log)
d.addPeopleAndModels(r.Context(), usage, log)
}
// Only ever shown to an administrator, whatever wrapped it.
d.Menu.Admin = true
d.Menu.Mirror = d.Menu.Mirror || nav.Mirror
render(w, "dashboard", d)
}
}
+75
View File
@@ -0,0 +1,75 @@
package web
import (
"context"
"net/http"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
// Nav is the menu at the top of every signed-in page.
//
// One menu, drawn by one template, because each page used to write its own:
// the same links in a different order, some missing, and the page you were on
// sometimes listed and sometimes not. Moving between pages then read as moving
// between different programs.
type Nav struct {
// Current is the page being shown, so its link is marked rather than
// offered: "cluster", "people", "quotas", "usage", "mirror", "chat", "keys".
Current string
// Admin shows the administration links.
Admin bool
// Mirror and Quotas are whether those pages were mounted at all; usage is
// mounted with quotas.
Mirror, Quotas bool
// CSRF signs the sign-out form.
CSRF string
// Project is where the signed-in person's keys live, as a path. Carried
// here because every page that says whose session it is wants it, and the
// id it replaces ("project proj-3kq7…") told nobody anything.
Project string
}
// projectPaths is the part of the store that can name a project.
type projectPaths interface {
AllProjects(ctx context.Context) ([]store.ProjectPath, error)
}
type navKey struct{}
// navFor builds the menu for a signed-in person.
func (p *AccountPages) navFor(r *http.Request, user store.User, current string) Nav {
n := Nav{
Current: current,
Admin: user.IsAdmin,
Mirror: p.hasMirror,
Quotas: p.hasQuotas,
CSRF: csrfFor(r),
Project: user.ProjectID,
}
if pp, ok := p.keys.(projectPaths); ok && user.ProjectID != "" {
if all, err := pp.AllProjects(r.Context()); err == nil {
for _, x := range all {
if x.ID == user.ProjectID {
n.Project = x.Path
}
}
}
}
return n
}
// withNav carries the menu to a page rendered by a handler that has no
// session of its own — the dashboard and the node logs are built without the
// account pages and wrapped by them.
func (p *AccountPages) withNav(r *http.Request, user store.User) *http.Request {
return r.WithContext(context.WithValue(r.Context(), navKey{}, p.navFor(r, user, "")))
}
// navFrom reads the menu withNav stored, marked for the page being drawn.
// Zero when there is none, which draws no admin links.
func navFrom(r *http.Request, current string) Nav {
n, _ := r.Context().Value(navKey{}).(Nav)
n.Current = current
return n
}
+53
View File
@@ -0,0 +1,53 @@
package web
import (
"io"
"log/slog"
"net/http"
"regexp"
"strings"
"testing"
"gitea.mynodes.xyz/ilker/llmgw/internal/mirror"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
var menuRe = regexp.MustCompile(`(?s)<nav class="menu".*?</nav>`)
// Every admin page drew its own menu, and no two agreed: different links, in a
// different order, the current page sometimes there and sometimes not.
func TestEveryPageDrawsTheSameMenu(t *testing.T) {
a := fakeAccounts{user: store.User{ID: "u1", Email: "a@example.com", IsAdmin: true}}
p := NewAccountPages(a, nil, nil, "v0", slog.New(slog.NewTextHandler(io.Discard, nil)))
mux := http.NewServeMux()
p.RegisterAdminUsers(mux, &fakeAdmin{})
p.RegisterAdminQuotas(mux, oneTeam(), pricedYes{})
p.RegisterAdminMirror(mux, MirrorDeps{Mirror: &fakeMirror{models: []mirror.Status{}}})
pages := map[string]string{
"/admin/users": "People", "/admin/quotas": "Quotas", "/admin/mirror": "Mirror",
}
var first string
for path, current := range pages {
body := mirrorGet(t, mux, path).Body.String()
menu := menuRe.FindString(body)
if menu == "" {
t.Fatalf("%s draws no menu", path)
}
if !strings.Contains(menu, `aria-current="page">`+current+`<`) {
t.Errorf("%s does not mark itself in the menu:\n%s", path, menu)
}
plain := strings.ReplaceAll(menu, ` aria-current="page"`, "")
if first == "" {
first = plain
} else if plain != first {
t.Errorf("%s draws a different menu:\n%s\nfrom:\n%s", path, plain, first)
}
}
for _, link := range []string{"/admin", "/admin/users", "/admin/quotas", "/admin/usage",
"/admin/mirror", "/chat", "/profile", "/logout"} {
if !strings.Contains(first, `"`+link+`"`) {
t.Errorf("the menu lacks %s", link)
}
}
}
+2
View File
@@ -17,6 +17,7 @@ type LogSource interface {
}
type nodeLogsData struct {
Nav Nav
adminapi.LogsView
OnAdminListener bool
// Base is where this page lives, which differs between the admin
@@ -41,6 +42,7 @@ func NodeLogsPage(src Source, onAdminListener bool, version string) http.Handler
return func(w http.ResponseWriter, r *http.Request) {
node := r.PathValue("id")
d := nodeLogsData{
Nav: navFrom(r, "cluster"),
OnAdminListener: onAdminListener,
Base: "/admin/nodes/" + node + "/logs",
Back: "/admin",
+10
View File
@@ -362,3 +362,13 @@ tr[hidden] { display: none; }
table.held td.file { max-width: 22rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
table.held td.actions > * + * { margin-left: .7rem; }
table.held .tag { margin: 0 .3rem 0 0; text-transform: none; letter-spacing: 0; }
/* The shared top menu. The page you are on is marked by weight and an
underline, not by colour alone. */
nav.menu { align-items: center; gap: .35rem 1.1rem; }
nav.menu a { font-size: .9rem; }
nav.menu a[aria-current="page"] { color: var(--fg); font-weight: 600;
text-decoration: underline; text-decoration-thickness: 2px; text-underline-offset: .35rem; }
nav.menu .menu-gap { flex: 1 1 auto; min-width: .5rem; }
nav.menu form.inline button.link { font-size: .9rem; text-decoration: none; color: var(--muted); }
nav.menu form.inline button.link:hover { color: var(--fg); text-decoration: underline; }
+2 -10
View File
@@ -51,16 +51,8 @@ Tell whoever you asked for access that you have signed up.</p>
</head><body><div class="wrap">
<header>
<h1>Your keys</h1>
<p class="sub">{{.User.Name}} · {{.User.Email}} · project {{.User.ProjectID}}</p>
<nav>
<a href="/chat">chat</a>
<a href="/">about this gateway</a>
{{if .User.IsAdmin}}<a href="/admin">cluster</a>{{end}}
<form method="post" action="/logout" class="inline">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<button type="submit" class="link">Sign out</button>
</form>
</nav>
<p class="sub">{{.User.Email}}{{if ne .User.Name .User.Email}} ({{.User.Name}}){{end}} · {{or .Nav.Project "no project"}}</p>
{{template "nav" .Nav}}
</header>
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
+1 -2
View File
@@ -12,8 +12,7 @@
{{if .Unreachable}}{{.Addr}} did not answer{{else}}{{.Present}} held ·
{{.Fetching}} downloading · {{.Failed}} failed · {{.Addr}}{{end}}
</p>
<nav><a href="/admin">cluster</a> <a href="/admin/users">people</a>
<a href="/profile">your keys</a></nav>
{{template "nav" .Nav}}
</header>
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
+1 -2
View File
@@ -6,8 +6,7 @@
<header>
<h1>Quotas</h1>
<p class="sub">What each organization, team and project may use · spend over the last {{.Since}}</p>
<nav><a href="/admin">cluster</a> <a href="/admin/users">people</a>
<a href="/admin/usage">usage</a> <a href="/profile">your keys</a></nav>
{{template "nav" .Nav}}
</header>
{{if not .BudgetsBind}}
+1 -2
View File
@@ -6,8 +6,7 @@
<header>
<h1>Usage over time</h1>
<p class="sub">Who is using the cluster, and when</p>
<nav><a href="/admin">cluster</a> <a href="/admin/users">people</a>
<a href="/admin/quotas">quotas</a> <a href="/profile">your keys</a></nav>
{{template "nav" .Nav}}
</header>
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
+1 -5
View File
@@ -11,11 +11,7 @@
<header>
<h1>People</h1>
<p class="sub">{{len .Pending}} waiting for approval · {{len .Accounts}} accounts · {{len .Projects}} projects</p>
<nav><a href="/admin">cluster</a>
{{if .HasMirror}} <a href="/admin/mirror">mirror</a>
{{end}}{{if .HasQuotas}} <a href="/admin/quotas">quotas</a>
<a href="/admin/usage">usage</a>
{{end}} <a href="/profile">your keys</a></nav>
{{template "nav" .Nav}}
</header>
<div id="people" hx-boost="true" hx-target="#people" hx-select="#people"
+2 -6
View File
@@ -9,12 +9,8 @@
</head><body><div class="wrap">
<header>
<h1>Chat</h1>
<p class="sub">{{.User.Email}} · project {{.User.ProjectID}}</p>
<nav>
<a href="/profile">your keys</a>
{{if .User.IsAdmin}}<a href="/admin">cluster</a>{{end}}
<a href="/">about this gateway</a>
</nav>
<p class="sub">{{.User.Email}} · {{or .Nav.Project "no project"}}</p>
{{template "nav" .Nav}}
</header>
{{if .Models}}
+2 -11
View File
@@ -7,20 +7,11 @@
<header>
<h1>Cluster</h1>
<p class="sub">{{.NodeCount}} nodes · {{.ReadyReplicas}} of {{.TotalReplicas}} replicas ready · {{.ModelCount}} models</p>
<nav>
{{if .OnAdminListener}}
{{if .OnAdminListener}}<nav>
<a href="/metrics">metrics</a>
<a href="/debug/pprof/">pprof</a>
<a href="/api/v1/nodes">api</a>
{{else}}
<a href="/admin/users">people</a>
<a href="/admin/usage">usage</a>
{{if .Nav.Mirror}} <a href="/admin/mirror">mirror</a>
{{end}}
<a href="/profile">your keys</a>
<a href="/chat">chat</a>
{{end}}
</nav>
</nav>{{else}}{{template "nav" .Menu}}{{end}}
</header>
{{if .HasCharts}}
+23
View File
@@ -0,0 +1,23 @@
{{/* The one menu every signed-in page draws. The order never changes and the
page you are on is marked, not offered, so moving between pages reads as
moving within one program. */}}
{{define "nav"}}<nav class="menu" aria-label="Main">
{{- if .Admin}}
<a href="/admin"{{if eq .Current "cluster"}} aria-current="page"{{end}}>Cluster</a>
<a href="/admin/users"{{if eq .Current "people"}} aria-current="page"{{end}}>People</a>
{{- if .Quotas}}
<a href="/admin/quotas"{{if eq .Current "quotas"}} aria-current="page"{{end}}>Quotas</a>
<a href="/admin/usage"{{if eq .Current "usage"}} aria-current="page"{{end}}>Usage</a>
{{- end}}
{{- if .Mirror}}
<a href="/admin/mirror"{{if eq .Current "mirror"}} aria-current="page"{{end}}>Mirror</a>
{{- end}}
<span class="menu-gap" aria-hidden="true"></span>
{{- end}}
<a href="/chat"{{if eq .Current "chat"}} aria-current="page"{{end}}>Chat</a>
<a href="/profile"{{if eq .Current "keys"}} aria-current="page"{{end}}>Your keys</a>
<form method="post" action="/logout" class="inline">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<button type="submit" class="link">Sign out</button>
</form>
</nav>{{end}}
+1 -1
View File
@@ -7,7 +7,7 @@
<header>
<h1>{{.Node}}</h1>
<p class="sub">{{if eq .Source "agent"}}the agent's log{{else}}output of engine <code>{{.Source}}</code>{{end}} · last {{.Count}} lines</p>
<nav><a href="{{.Back}}">cluster</a></nav>
{{if .OnAdminListener}}<nav><a href="{{.Back}}">cluster</a></nav>{{else}}{{template "nav" .Nav}}{{end}}
</header>
<p>