From c9cd25efc29ad3cbdcb9e778e565a2eea98734e9 Mon Sep 17 00:00:00 2001
From: Ilker Manap
Date: Mon, 28 Sep 2026 17:55:36 +0200
Subject: [PATCH] Draw one menu on every page
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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)
---
CHANGELOG.md | 11 ++++
internal/web/account.go | 2 +
internal/web/admin_mirror.go | 2 +
internal/web/admin_page.go | 2 +-
internal/web/admin_quotas.go | 2 +
internal/web/admin_quotas_test.go | 3 +-
internal/web/admin_usage.go | 4 +-
internal/web/admin_users.go | 4 +-
internal/web/chat.go | 4 +-
internal/web/dashboard.go | 6 ++
internal/web/nav.go | 75 ++++++++++++++++++++++++
internal/web/nav_test.go | 53 +++++++++++++++++
internal/web/node_logs.go | 2 +
internal/web/static/base.css | 10 ++++
internal/web/templates/account.html | 12 +---
internal/web/templates/admin_mirror.html | 3 +-
internal/web/templates/admin_quotas.html | 3 +-
internal/web/templates/admin_usage.html | 3 +-
internal/web/templates/admin_users.html | 6 +-
internal/web/templates/chat.html | 8 +--
internal/web/templates/dashboard.html | 13 +---
internal/web/templates/nav.html | 23 ++++++++
internal/web/templates/node_logs.html | 2 +-
23 files changed, 209 insertions(+), 44 deletions(-)
create mode 100644 internal/web/nav.go
create mode 100644 internal/web/nav_test.go
create mode 100644 internal/web/templates/nav.html
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 75956996..133e03e7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/internal/web/account.go b/internal/web/account.go
index 643c5bf2..7cefd473 100644
--- a/internal/web/account.go
+++ b/internal/web/account.go
@@ -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()
diff --git a/internal/web/admin_mirror.go b/internal/web/admin_mirror.go
index 6467a117..c3379b74 100644
--- a/internal/web/admin_mirror.go
+++ b/internal/web/admin_mirror.go
@@ -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,
diff --git a/internal/web/admin_page.go b/internal/web/admin_page.go
index d6a98e90..e3ade098 100644
--- a/internal/web/admin_page.go
+++ b/internal/web/admin_page.go
@@ -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))
}
}
diff --git a/internal/web/admin_quotas.go b/internal/web/admin_quotas.go
index 5d21fbd2..ebabdebf 100644
--- a/internal/web/admin_quotas.go
+++ b/internal/web/admin_quotas.go
@@ -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),
diff --git a/internal/web/admin_quotas_test.go b/internal/web/admin_quotas_test.go
index 44cde12a..c7d3e13a 100644
--- a/internal/web/admin_quotas_test.go
+++ b/internal/web/admin_quotas_test.go
@@ -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"`) {
diff --git a/internal/web/admin_usage.go b/internal/web/admin_usage.go
index 53bc2773..ec7049ad 100644
--- a/internal/web/admin_usage.go
+++ b/internal/web/admin_usage.go
@@ -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),
diff --git a/internal/web/admin_users.go b/internal/web/admin_users.go
index 93d96ba8..167e9562 100644
--- a/internal/web/admin_users.go
+++ b/internal/web/admin_users.go
@@ -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,
diff --git a/internal/web/chat.go b/internal/web/chat.go
index b44edc7f..cb7c5451 100644
--- a/internal/web/chat.go
+++ b/internal/web/chat.go
@@ -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)
}
}
diff --git a/internal/web/dashboard.go b/internal/web/dashboard.go
index 7ebe3fa7..110619cc 100644
--- a/internal/web/dashboard.go
+++ b/internal/web/dashboard.go
@@ -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)
}
}
diff --git a/internal/web/nav.go b/internal/web/nav.go
new file mode 100644
index 00000000..430c3033
--- /dev/null
+++ b/internal/web/nav.go
@@ -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
+}
diff --git a/internal/web/nav_test.go b/internal/web/nav_test.go
new file mode 100644
index 00000000..cdef01e7
--- /dev/null
+++ b/internal/web/nav_test.go
@@ -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)
- {{else}}{{template "nav" .Menu}}{{end}}
{{if .HasCharts}}
diff --git a/internal/web/templates/nav.html b/internal/web/templates/nav.html
new file mode 100644
index 00000000..50cd6b70
--- /dev/null
+++ b/internal/web/templates/nav.html
@@ -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"}}
+{{- if .Admin}}
+ Cluster
+ People
+ {{- if .Quotas}}
+ Quotas
+ Usage
+ {{- end}}
+ {{- if .Mirror}}
+ Mirror
+ {{- end}}
+
+{{- end}}
+ Chat
+ Your keys
+
+{{end}}
diff --git a/internal/web/templates/node_logs.html b/internal/web/templates/node_logs.html
index 85f5b820..deb926f3 100644
--- a/internal/web/templates/node_logs.html
+++ b/internal/web/templates/node_logs.html
@@ -7,7 +7,7 @@
{{.Node}}
{{if eq .Source "agent"}}the agent's log{{else}}output of engine {{.Source}}{{end}} · last {{.Count}} lines