diff --git a/CHANGELOG.md b/CHANGELOG.md index 7595699..133e03e 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 643c5bf..7cefd47 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 6467a11..c3379b7 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 d6a98e9..e3ade09 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 5d21fbd..ebabdeb 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 44cde12..c7d3e13 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 53bc277..ec7049a 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 93d96ba..167e956 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 b44edc7..cb7c545 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 7ebe3fa..110619c 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 0000000..430c303 --- /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 0000000..cdef01e --- /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)