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:
1 parent
c22c3a309b
commit
c9cd25efc2
23 files changed
+209
-44
No files matched your search
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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"`) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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; }
|
||||
@@ -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}}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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}}
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user