Keep every admin table to one line per record
Headers never wrap, free text is clipped with the whole on hover, full-width rows are only notes and forms. The cluster page hides columns empty on every row and shortens node names; People shows two memberships and a count. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
7099043c75
commit
235bf33dcd
13 files changed
+146
-38
No files matched your search
@@ -55,6 +55,19 @@ in amber when short, in red when parked or when it does not fit. It is one line
|
||||
per model beside its mode, and nodes drop the part of the name every node
|
||||
shares (node05 for llmgw-node05, the full name on hover).
|
||||
|
||||
### One line per record, in every table
|
||||
|
||||
The same rule across the admin pages. Headers do not wrap; a record is one
|
||||
line; free text is cut to its column with the whole of it on hover rather than
|
||||
given a row of its own; a row across the whole table is only a note or an open
|
||||
form. On the cluster page that means a node is one line (its address and logs
|
||||
beside its name, not under it), a model's description is a column rather than a
|
||||
second row, node names drop the prefix they all share, and the GPUs, classes,
|
||||
fabrics and fallbacks columns appear only when some row has something in them —
|
||||
on a CPU-only cluster they were three columns of dashes pushing the table past
|
||||
the page. On People, a person in many teams shows two and a count, the rest on
|
||||
hover.
|
||||
|
||||
## v0.10.4 — 2026-09-30
|
||||
|
||||
Published first as release candidates v0.10.4-rc.1 to rc.9.
|
||||
|
||||
@@ -45,6 +45,13 @@ type DashboardData struct {
|
||||
|
||||
HasPeople bool
|
||||
|
||||
// NodePrefix is what every node's name starts with, left off in tables
|
||||
// so a row fits on a line; the full name is on hover.
|
||||
NodePrefix string
|
||||
// Columns shown only when some row has something in them: a CPU-only
|
||||
// cluster's nodes table was three columns of dashes wide.
|
||||
AnyGPUs, AnyClasses, AnyFabrics, AnyFallbacks bool
|
||||
|
||||
// Charts are pre-rendered rather than assembled in the template: building
|
||||
// SVG out of template fragments is how a chart ends up unescaped.
|
||||
RequestChart template.HTML
|
||||
@@ -177,6 +184,17 @@ func Dashboard(src Source, usage UsageSource, version string, refresh time.Durat
|
||||
}
|
||||
}
|
||||
d.Unplaced = unplaced(src, d.Models)
|
||||
ids := make([]string, 0, len(d.Nodes))
|
||||
for _, n := range d.Nodes {
|
||||
ids = append(ids, n.ID)
|
||||
d.AnyGPUs = d.AnyGPUs || n.GPUTotal > 0
|
||||
d.AnyClasses = d.AnyClasses || len(n.Classes) > 0
|
||||
d.AnyFabrics = d.AnyFabrics || len(n.Fabrics) > 0
|
||||
}
|
||||
d.NodePrefix = sharedPrefix(ids)
|
||||
for _, m := range d.Models {
|
||||
d.AnyFallbacks = d.AnyFallbacks || len(m.Fallbacks) > 0
|
||||
}
|
||||
d.Summary = src.Summary()
|
||||
if fs, ok := src.(FabricSource); ok {
|
||||
r := fs.Fabrics()
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
||||
)
|
||||
|
||||
// cpuFleet is a CPU-only cluster whose nodes share a name prefix.
|
||||
type cpuFleet struct{ fakeSource }
|
||||
|
||||
func (cpuFleet) Nodes() []adminapi.NodeView {
|
||||
return []adminapi.NodeView{
|
||||
{ID: "llmgw-node01", State: "ready", MgmtAddr: "10.0.0.141", MemoryGB: 8},
|
||||
{ID: "llmgw-node02", State: "ready", MgmtAddr: "10.0.0.142", MemoryGB: 8},
|
||||
}
|
||||
}
|
||||
func (cpuFleet) Summary() adminapi.Summary { return adminapi.Summary{} }
|
||||
func (cpuFleet) Models() []adminapi.ModelView {
|
||||
return []adminapi.ModelView{{Name: "qwen", Min: 1, Max: 1, Ready: 1,
|
||||
Description: "A sentence long enough that it once had a row of its own under the model."}}
|
||||
}
|
||||
func (cpuFleet) Replicas(string) []adminapi.ReplicaView {
|
||||
return []adminapi.ReplicaView{{ID: "qwen-1", Model: "qwen", State: "ready",
|
||||
Shards: []adminapi.ShardView{{Node: "llmgw-node02"}}}}
|
||||
}
|
||||
|
||||
func TestTheClusterPageKeepsEachRecordOnOneLine(t *testing.T) {
|
||||
h := Dashboard(cpuFleet{}, nil, "v1", time.Second, true, NavExtras{}, nil)
|
||||
_, body := get(t, h, "http://127.0.0.1:9090/", nil)
|
||||
|
||||
for _, gone := range []string{"<th>Classes</th>", "<th>Fabrics</th>", `<th class="num">GPUs</th>`, "<th>Fallbacks</th>"} {
|
||||
if strings.Contains(body, gone) {
|
||||
t.Errorf("%s is shown with nothing in it on any row", gone)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(body, `title="llmgw-node01"><code>node01</code>`) {
|
||||
t.Error("node names should drop what every node shares, keeping the whole on hover")
|
||||
}
|
||||
if !strings.Contains(body, `<code title="llmgw-node02">node02</code>`) {
|
||||
t.Error("the replicas table should name nodes the same way")
|
||||
}
|
||||
if strings.Contains(body, `class="url-row"`) || !strings.Contains(body, `<td class="clip" title="A sentence long enough`) {
|
||||
t.Error("a model's description should be a clipped cell, not a row of its own")
|
||||
}
|
||||
|
||||
// A cluster with GPUs still gets its GPU column.
|
||||
_, gpu := get(t, Dashboard(fakeSource{}, nil, "v1", time.Second, true, NavExtras{}, nil), "http://127.0.0.1:9090/", nil)
|
||||
if !strings.Contains(gpu, `<th class="num">GPUs</th>`) || !strings.Contains(gpu, "<th>Fabrics</th>") {
|
||||
t.Error("columns with something in them were hidden")
|
||||
}
|
||||
}
|
||||
@@ -837,3 +837,20 @@ a.open-held[aria-expanded=false]::before { content: "▸ "; color: var(--muted);
|
||||
.use .runs-now.short { color: var(--warn); }
|
||||
.use .runs-now.bad { color: var(--bad); }
|
||||
.use .runs-now .node { white-space: nowrap; }
|
||||
|
||||
/* One line per record, in every table. A header never wraps; a cell of free
|
||||
* text is cut to its column with the whole of it on hover, instead of a row of
|
||||
* its own or a line break that makes one record look like two. */
|
||||
th { white-space: nowrap; }
|
||||
table.rows td { white-space: nowrap; }
|
||||
td.clip { max-width: 22rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
table.rows td.clip { color: var(--muted); font-size: .85rem; }
|
||||
/* A row across the whole table is a note or an open form, not a record: it
|
||||
* wraps as prose does. */
|
||||
table.rows td[colspan] { white-space: normal; }
|
||||
table.rows td.where { max-width: none; }
|
||||
table.rows .more { cursor: help; margin-left: .2rem; }
|
||||
table.explore td:first-child { max-width: 34rem; overflow: hidden; text-overflow: ellipsis; }
|
||||
table.explore .from { margin-left: .5rem; font-size: .78rem; color: var(--muted); }
|
||||
.clip-inline { display: inline-block; max-width: 24rem; overflow: hidden; text-overflow: ellipsis;
|
||||
vertical-align: bottom; font-size: .8rem; color: var(--muted); margin-left: .4rem; }
|
||||
@@ -84,7 +84,7 @@ Tell whoever you asked for access that you have signed up.</p>
|
||||
{{end}}
|
||||
{{if .Fields}}
|
||||
<h3>Settings to fill in</h3>
|
||||
<table class="compact narrow-table connect-fields">{{range .Fields}}
|
||||
<table class="rows compact narrow-table connect-fields">{{range .Fields}}
|
||||
<tr><th>{{.Label}}</th><td><code>{{.Value}}</code></td><td><button type="button" class="link copy" data-value="{{.Value}}">copy</button></td></tr>{{end}}
|
||||
</table>
|
||||
{{end}}
|
||||
@@ -149,7 +149,7 @@ document.addEventListener("click", (e) => {
|
||||
|
||||
<h2>Keys</h2>
|
||||
{{if .Keys}}
|
||||
<table><tr><th>Name</th><th>Key</th><th>Spends against</th><th>Created</th><th>Last used</th><th>State</th><th></th></tr>
|
||||
<table class="rows"><tr><th>Name</th><th>Key</th><th>Spends against</th><th>Created</th><th>Last used</th><th>State</th><th></th></tr>
|
||||
{{range .Keys}}<tr>
|
||||
<td>{{.Name}}</td>
|
||||
<td><code>{{.Prefix}}</code></td>
|
||||
@@ -192,7 +192,7 @@ old one to stop moving, then revoke it.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .Rows}}
|
||||
<table class="compact narrow-table"><tr><th>Limit</th><th class="num">Value</th></tr>
|
||||
<table class="rows compact narrow-table"><tr><th>Limit</th><th class="num">Value</th></tr>
|
||||
{{range .Rows}}<tr><td>{{.Name}}</td><td class="num">{{.Value}}</td></tr>{{end}}</table>
|
||||
{{else}}<p class="empty">No limits here.</p>{{end}}
|
||||
{{else}}<p class="empty">Nowhere to spend yet.</p>{{end}}
|
||||
|
||||
@@ -34,7 +34,7 @@ reachable from this machine.</p>
|
||||
|
||||
<div id="held">
|
||||
{{if .Rows}}
|
||||
<table class="compact held">
|
||||
<table class="rows compact held">
|
||||
<tr><th>Model</th><th>Files</th><th class="num">Size</th><th>Runs</th><th></th></tr>
|
||||
{{range .Groups}}{{$grouped := gt .Models 1}}
|
||||
{{if $grouped}}
|
||||
@@ -239,13 +239,13 @@ document.addEventListener('click', function (e) {
|
||||
|
||||
{{if not .Repo}}{{with .Explore}}
|
||||
{{if .Rows}}
|
||||
<table class="explore">
|
||||
<table class="rows explore">
|
||||
<tr><th>Repository</th><th class="num">Size</th><th>Architecture</th><th class="num">Context</th>
|
||||
<th class="num">Downloads</th><th class="num">Likes</th><th class="num">Updated</th></tr>
|
||||
{{range .Rows}}<tr class="explore-row">
|
||||
<td><a href="{{.Href}}" class="open-repo" data-repo="{{.ID}}" aria-expanded="false"><code>{{.ID}}</code></a>
|
||||
{{if .New}}<span class="tag new">new</span>{{end}}{{if .Gated.Is}}<span class="tag">gated</span>{{end}}
|
||||
{{if or .BaseModel .License}}<div class="from">{{with .BaseModel}}from {{.}}{{end}}{{if and .BaseModel .License}} · {{end}}{{.License}}</div>{{end}}</td>
|
||||
{{if or .BaseModel .License}}<span class="from" title="{{with .BaseModel}}from {{.}}{{end}}{{if and .BaseModel .License}} · {{end}}{{.License}}">{{with .BaseModel}}from {{.}}{{end}}{{if and .BaseModel .License}} · {{end}}{{.License}}</span>{{end}}</td>
|
||||
<td class="num">{{.ParamsLabel}}</td>
|
||||
<td>{{if .Architecture}}<span{{if not .Verdict.Servable}} class="cannot"{{end}} title="{{.Verdict.Reason}}">{{.Architecture}}{{if .Verdict.Checked}}{{if .Verdict.Servable}} ✓{{else}} ✗{{end}}{{end}}</span>{{else}}—{{end}}</td>
|
||||
<td class="num">{{.ContextLabel}}</td>
|
||||
@@ -399,7 +399,7 @@ version and the old weights stay where they are.</p>
|
||||
</details>
|
||||
{{end}}
|
||||
{{if .RepoSets}}
|
||||
<table class="repo-files">
|
||||
<table class="rows repo-files">
|
||||
<tr><th>What</th><th class="num">Size</th><th>Published checksum</th><th></th></tr>
|
||||
{{range .RepoSets}}<tr>
|
||||
<td><code>{{.Name}}</code>
|
||||
@@ -409,7 +409,7 @@ version and the old weights stay where they are.</p>
|
||||
<td>
|
||||
{{if not .Verdict.Servable}}
|
||||
<span class="state state-failed" title="{{.Verdict.Reason}}">cannot be served</span>
|
||||
<div class="note">{{.Verdict.Reason}}</div>
|
||||
<span class="clip-inline" title="{{.Verdict.Reason}}">{{.Verdict.Reason}}</span>
|
||||
{{else}}
|
||||
{{if .Verdict.Architecture}}<span class="opt" title="{{.Verdict.Reason}}">{{.Verdict.Architecture}}{{if .Verdict.Checked}} ✓{{end}}</span>{{end}}
|
||||
<form method="post" action="/admin/mirror/fetch" class="inline">
|
||||
@@ -462,7 +462,7 @@ computes once it has it.</p>
|
||||
{{with .View.Reason}}<p class="note error-row">{{.}}</p>{{end}}
|
||||
|
||||
{{if .Replicas}}
|
||||
<table class="compact replicas">
|
||||
<table class="rows compact replicas">
|
||||
<tr><th>Replica</th><th>Node</th><th>State</th><th class="num">Age</th><th class="num">CPU</th><th class="num">Memory</th></tr>
|
||||
{{range .Replicas}}{{$rep := .}}{{range $i, $s := .Nodes}}<tr>
|
||||
<td>{{if eq $i 0}}<code>{{$rep.ID}}</code>{{end}}</td>
|
||||
|
||||
@@ -34,7 +34,7 @@ budgets bind.</p>
|
||||
{{/* The tree is the interface. A quota belongs to a branch, so the form to set
|
||||
one opens on that branch rather than asking somebody to name it again in a
|
||||
box at the bottom. */}}
|
||||
<table class="quota-tree">
|
||||
<table class="rows quota-tree">
|
||||
<tr><th>Where</th><th class="num">Requests/min</th><th class="num">Tokens/min</th>
|
||||
<th class="num">Budget</th><th>Spent</th><th></th></tr>
|
||||
{{range .Rows}}
|
||||
@@ -119,7 +119,7 @@ up — hover it to see where. A value set here is in normal type.</p>
|
||||
<p class="note">A person's own limit covers everything they do, across all their
|
||||
keys, projects and the chat page. {{.Personal}}</p>
|
||||
{{if .People}}
|
||||
<table class="quota-tree people-limits">
|
||||
<table class="rows quota-tree people-limits">
|
||||
<tr><th>Person</th><th class="num">Requests/min</th><th class="num">Tokens/min</th>
|
||||
<th class="num">Budget</th><th>Spent</th><th></th></tr>
|
||||
{{range .People}}
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
</div>
|
||||
|
||||
{{if .Rows}}
|
||||
<table>
|
||||
<table class="rows">
|
||||
<tr><th>Group</th><th class="num">Requests</th><th class="num">Tokens</th><th class="num">Spend</th><th class="num">Errors</th></tr>
|
||||
{{range .Rows}}
|
||||
<tr>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
|
||||
{{if .Pending}}
|
||||
<h2>Waiting for approval</h2>
|
||||
<table><tr><th>Email</th><th>Name</th><th>Signed up</th><th>Approve, and put them in</th><th></th></tr>
|
||||
<table class="rows"><tr><th>Email</th><th>Name</th><th>Signed up</th><th>Approve, and put them in</th><th></th></tr>
|
||||
{{range .Pending}}<tr>
|
||||
<td>{{.Email}}</td><td>{{.Name}}</td><td>{{ago .CreatedAt}}</td>
|
||||
<td>
|
||||
@@ -149,14 +149,14 @@ project in the team — an account can only be approved into a project.</p>
|
||||
</div>
|
||||
|
||||
{{with index $.Members (printf "project:%s" $p.ID)}}
|
||||
<table class="compact"><tr><th>Member</th><th>State</th><th>Last sign-in</th><th></th></tr>
|
||||
<table class="rows compact"><tr><th>Member</th><th>State</th><th>Last sign-in</th><th></th></tr>
|
||||
{{range .}}{{template "member-row" (dict "U" . "D" $ "Scope" (printf "project:%s" $p.ID) "Open" $p.ID)}}{{end}}
|
||||
</table>
|
||||
{{else}}<p class="empty">No members of its own. Members of its team or organization can use it too.</p>{{end}}
|
||||
{{template "add-member" (dict "Kind" "project" "ID" $p.ID "D" $ "Open" $p.ID)}}
|
||||
|
||||
{{with index $.Keys $p.ID}}
|
||||
<table class="compact"><tr><th>Key</th><th>Owner</th><th>State</th><th>Last used</th><th></th></tr>
|
||||
<table class="rows compact"><tr><th>Key</th><th>Owner</th><th>State</th><th>Last used</th><th></th></tr>
|
||||
{{range .}}<tr>
|
||||
<td>{{.Name}} <code>{{.Prefix}}</code></td>
|
||||
<td>{{or .OwnerEmail "issued by an operator"}}</td>
|
||||
@@ -210,7 +210,7 @@ project in the team — an account can only be approved into a project.</p>
|
||||
|
||||
<h2 id="accounts">All accounts</h2>
|
||||
{{if .Accounts}}
|
||||
<table id="account-list"><tr><th>Person</th><th>State</th><th>Member of</th><th>Last sign-in</th><th>Add to</th><th></th></tr>
|
||||
<table class="rows" id="account-list"><tr><th>Person</th><th>State</th><th>Member of</th><th>Last sign-in</th><th>Add to</th><th></th></tr>
|
||||
{{range .Accounts}}{{template "account-row" (dict "U" . "D" $)}}{{end}}
|
||||
</table>
|
||||
<p class="note">Someone who is a member of nothing uses the gateway on their own:
|
||||
@@ -298,7 +298,7 @@ access than it had, so they need a shell on the control plane —
|
||||
<td>{{$u.Email}}{{if $u.IsAdmin}} <span class="tag">admin</span>{{end}}</td>
|
||||
<td class="state state-{{$u.State}}">{{$u.State}}</td>
|
||||
<td class="where">
|
||||
{{range $u.Memberships}}<span class="chip-m">{{.Path}}{{if eq .Role "manager"}} <b class="chip-role">manager</b>{{end}}
|
||||
{{range $i, $m := $u.Memberships}}{{if lt $i 2}}{{with $m}}<span class="chip-m">{{.Path}}{{if eq .Role "manager"}} <b class="chip-role">manager</b>{{end}}
|
||||
{{if eq (printf "%s" .Scope) "org"}}
|
||||
<form method="post" action="/admin/users/{{$u.ID}}/role" class="inline">
|
||||
<input type="hidden" name="csrf" value="{{$d.CSRF}}">
|
||||
@@ -313,8 +313,12 @@ access than it had, so they need a shell on the control plane —
|
||||
<input type="hidden" name="csrf" value="{{$d.CSRF}}">
|
||||
<input type="hidden" name="scope" value="{{.Scope}}:{{.ScopeID}}">
|
||||
<button type="submit" class="chip-x" aria-label="Take out of {{.Path}}" title="Take out of {{.Path}}">×</button>
|
||||
</form></span>
|
||||
</form></span>{{end}}{{end}}
|
||||
{{else}}<span class="opt">on their own</span>{{end}}
|
||||
{{/* Two, then a count: a person in six teams was six chips wrapping the
|
||||
row into a paragraph. The rest are named on hover and in their own
|
||||
scopes' member lists, where taking them out is done. */}}
|
||||
{{if gt (len $u.Memberships) 2}}<span class="opt more" title="{{range $i, $m := $u.Memberships}}{{if ge $i 2}}{{$m.Path}} {{end}}{{end}}">+{{sub (len $u.Memberships) 2}}</span>{{end}}
|
||||
</td>
|
||||
<td>{{ago $u.LastLoginAt}}</td>
|
||||
<td>
|
||||
@@ -365,7 +369,7 @@ access than it had, so they need a shell on the control plane —
|
||||
<details class="edit"><summary>{{len (index $d.Members $k)}} member{{if ne (len (index $d.Members $k)) 1}}s{{end}}</summary>
|
||||
<div class="scope-members">
|
||||
{{with index $d.Members $k}}
|
||||
<table class="compact">
|
||||
<table class="rows compact">
|
||||
{{range .}}{{template "member-row" (dict "U" . "D" $d "Scope" $k "Open" "")}}{{end}}
|
||||
</table>
|
||||
{{else}}<p class="note">Nobody is a member of {{.Name}} itself; a member here could use every project in it.</p>{{end}}
|
||||
|
||||
@@ -51,23 +51,23 @@
|
||||
|
||||
<h2>Nodes</h2>
|
||||
{{if .Nodes}}
|
||||
<table class="nodes"><tr><th>Node</th><th>State</th><th>CPU</th><th>RAM</th><th class="num">GPUs</th><th class="num">Disk</th><th>Engines</th><th>Classes</th><th>Fabrics</th><th class="num">Models</th><th class="num">Replicas</th><th>Last seen</th></tr>
|
||||
<table class="nodes rows"><tr><th>Node</th><th>State</th><th>CPU</th><th>RAM</th>{{if .AnyGPUs}}<th class="num">GPUs</th>{{end}}<th class="num">Disk</th><th>Engines</th>{{if .AnyClasses}}<th>Classes</th>{{end}}{{if .AnyFabrics}}<th>Fabrics</th>{{end}}<th class="num">Models</th><th class="num">Replicas</th><th>Seen</th></tr>
|
||||
{{range .Nodes}}<tr>
|
||||
<td><a href="{{if $.OnAdminListener}}/nodes/{{.ID}}{{else}}/admin/nodes/{{.ID}}{{end}}"><code>{{.ID}}</code></a>{{if .MgmtAddr}} <span class="note">{{.MgmtAddr}}</span>{{end}}
|
||||
<td><a href="{{if $.OnAdminListener}}/nodes/{{.ID}}{{else}}/admin/nodes/{{.ID}}{{end}}" title="{{.ID}}"><code>{{shortNode .ID $.NodePrefix}}</code></a>{{if .MgmtAddr}} <span class="note">{{.MgmtAddr}}</span>{{end}}
|
||||
<a class="note" href="{{if $.OnAdminListener}}/nodes/{{.ID}}/logs{{else}}/admin/nodes/{{.ID}}/logs{{end}}">logs</a></td>
|
||||
<td class="state state-{{.State}}">{{.State}}</td>
|
||||
{{/* A dash, not an empty bar, when the agent has not measured it lately:
|
||||
unknown is not idle. */}}
|
||||
<td class="load">{{with .Load}}{{template "minibar" pctbar .CPUPercent}}<span class="load-v">{{printf "%.0f" .CPUPercent}}%{{if .CPUs}} <span class="note">of {{.CPUs}}</span>{{end}}</span>{{else}}—{{end}}</td>
|
||||
<td class="load">{{with .Load}}{{template "minibar" pctbar .MemPercent}}<span class="load-v">{{bytes .MemUsed}} <span class="note">of {{bytes .MemTotal}}</span></span>{{else}}{{if .MemoryGB}}{{.MemoryGB}}G{{else}}—{{end}}{{end}}</td>
|
||||
<td class="num">{{if .GPUTotal}}{{.GPUFree}}/{{.GPUTotal}}{{else}}—{{end}}</td>
|
||||
{{if $.AnyGPUs}}<td class="num">{{if .GPUTotal}}{{.GPUFree}}/{{.GPUTotal}}{{else}}—{{end}}</td>{{end}}
|
||||
{{/* Free out of total, because the first is meaningless without the
|
||||
second, and an em dash where nothing was reported: an agent that
|
||||
was never asked about its disk must not read as a full one. */}}
|
||||
<td class="num">{{if .DiskTotalGB}}{{.DiskFreeGB}}G/{{.DiskTotalGB}}G{{else}}—{{end}}</td>
|
||||
<td>{{if .Engines}}{{join .Engines}}{{else}}<span class="note">none yet</span>{{end}}</td>
|
||||
<td>{{join .Classes}}</td>
|
||||
<td>{{join .Fabrics}}</td>
|
||||
{{if $.AnyClasses}}<td>{{join .Classes}}</td>{{end}}
|
||||
{{if $.AnyFabrics}}<td>{{join .Fabrics}}</td>{{end}}
|
||||
<td class="num">{{if .Models}}{{len .Models}}{{else}}—{{end}}</td>
|
||||
<td class="num">{{.Replicas}}</td>
|
||||
<td>{{or .LastSeenAgo "—"}}</td>
|
||||
@@ -80,18 +80,18 @@
|
||||
<h2>Models</h2>
|
||||
|
||||
{{if .Models}}
|
||||
<table><tr><th>Model</th><th>Aliases</th><th class="num">Ready</th><th class="num">Wanted</th><th>Profiles</th><th>Fallbacks</th></tr>
|
||||
<table class="rows"><tr><th>Model</th><th>Aliases</th><th class="num">Ready</th><th class="num">Wanted</th><th>Profiles</th>{{if .AnyFallbacks}}<th>Fallbacks</th>{{end}}<th>What it is for</th></tr>
|
||||
{{range .Models}}<tr>
|
||||
<td><code>{{.Name}}</code></td>
|
||||
<td>{{join .Aliases}}</td>
|
||||
<td class="num">{{.Ready}}</td>
|
||||
<td class="num">{{.Min}}–{{.Max}}</td>
|
||||
<td>{{join .Profiles}}</td>
|
||||
<td>{{join .Fallbacks}}</td>
|
||||
{{if $.AnyFallbacks}}<td>{{join .Fallbacks}}</td>{{end}}
|
||||
{{/* Cut to the column, the whole sentence on hover: a row of its own for
|
||||
each description made the table twice as long as the catalog. */}}
|
||||
<td class="clip"{{with .Description}} title="{{.}}"{{end}}>{{or .Description "—"}}</td>
|
||||
</tr>
|
||||
{{/* Under the row rather than in a column: a sentence in a table cell either
|
||||
wraps every other column into a stripe or gets cut off. */}}
|
||||
{{if .Description}}<tr><td colspan="6" class="url-row">{{.Description}}</td></tr>{{end}}
|
||||
{{end}}</table>
|
||||
{{else}}<p class="empty">The catalog is empty.</p>{{end}}
|
||||
|
||||
@@ -106,7 +106,7 @@
|
||||
|
||||
<h2>Replicas</h2>
|
||||
{{if .Replicas}}
|
||||
<table><tr><th>Replica</th><th>Model</th><th>State</th><th>Pool</th><th>Endpoint</th><th class="num">Age</th><th>Nodes</th></tr>
|
||||
<table class="rows"><tr><th>Replica</th><th>Model</th><th>State</th><th>Pool</th><th>Endpoint</th><th class="num">Age</th><th>Nodes</th></tr>
|
||||
{{range .Replicas}}<tr>
|
||||
<td><code>{{.ID}}</code></td>
|
||||
<td>{{.Model}}</td>
|
||||
@@ -114,7 +114,7 @@
|
||||
<td>{{or .Pool "—"}}</td>
|
||||
<td>{{if .Endpoint}}<code>{{.Endpoint}}</code>{{else}}—{{end}}</td>
|
||||
<td class="num">{{duration .AgeSec}}</td>
|
||||
<td>{{range .Shards}}<code>{{.Node}}</code> {{end}}</td>
|
||||
<td>{{range .Shards}}<code title="{{.Node}}">{{shortNode .Node $.NodePrefix}}</code> {{end}}</td>
|
||||
</tr>{{if .Err}}<tr><td colspan="7" class="note">{{.Err}}</td></tr>{{end}}
|
||||
{{end}}</table>
|
||||
{{else}}<p class="empty">No replicas.</p>{{end}}
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
<h2>Fabric</h2>
|
||||
<p class="sub">{{if .Node.Fabrics}}On {{join .Node.Fabrics}} · <a href="{{if $.OnAdminListener}}/fabrics{{else}}/admin/fabrics{{end}}">the map →</a>{{else}}<span class="bad">On no fabric: no port a declared fabric names can carry traffic, so no multi-node replica is placed here.</span>{{end}}</p>
|
||||
{{if .Node.IBPorts}}
|
||||
<table><tr><th>Adapter</th><th>State</th><th class="num">Rate</th><th>Fabric</th><th>Physically on</th><th>Errors since boot</th><th class="num">Sent</th><th class="num">Received</th></tr>
|
||||
<table class="rows"><tr><th>Adapter</th><th>State</th><th class="num">Rate</th><th>Fabric</th><th>Physically on</th><th>Errors since boot</th><th class="num">Sent</th><th class="num">Received</th></tr>
|
||||
{{range .Node.IBPorts}}<tr>
|
||||
<td><code>{{.HCA}}/{{.Port}}</code>{{if eq .LinkLayer "Ethernet"}} <span class="tag">RoCE</span>{{end}}</td>
|
||||
<td class="{{if .Active}}state-ready{{else}}bad{{end}}">{{.State}}{{with .PhysState}} <span class="note">{{.}}</span>{{end}}</td>
|
||||
@@ -60,13 +60,13 @@ the adapter came up: a number that grows is the one to look at.</p>
|
||||
{{if or .Node.NVLinkDomain .Node.IMEX}} · IMEX {{if eq .Node.IMEX "active"}}<span class="state-ready">active</span>, {{.Node.IMEXChannels}} channel{{if ne .Node.IMEXChannels 1}}s{{end}}{{else}}<span class="bad">{{or .Node.IMEX "not installed"}}</span>{{end}}{{end}}
|
||||
</p>
|
||||
<div class="gpu-wiring">
|
||||
<table class="nvl-matrix" aria-label="NVLink between GPUs">
|
||||
<table class="rows nvl-matrix" aria-label="NVLink between GPUs">
|
||||
<tr><th></th>{{range .Node.GPUs}}<th>{{.Index}}</th>{{end}}</tr>
|
||||
{{range $a := .Node.GPUs}}<tr><th>GPU {{$a.Index}}</th>
|
||||
{{range $b := $.Node.GPUs}}{{if eq $a.Index $b.Index}}<td class="self"></td>{{else if hasInt $a.NVLinkPeers $b.Index}}<td class="nv" title="GPU {{$a.Index}} ↔ GPU {{$b.Index}} over NVLink">NV</td>{{else}}<td class="pcie" title="GPU {{$a.Index}} ↔ GPU {{$b.Index}} over PCIe">·</td>{{end}}{{end}}
|
||||
</tr>{{end}}
|
||||
</table>
|
||||
<table class="compact gpu-list">
|
||||
<table class="rows compact gpu-list">
|
||||
<tr><th>GPU</th><th>Name</th><th class="num">NUMA</th><th>Nearest adapter</th><th>Multi-node NVLink</th></tr>
|
||||
{{range .Node.GPUs}}<tr>
|
||||
<td>{{.Index}}</td><td>{{.Name}}</td><td class="num">{{.NUMA}}</td>
|
||||
@@ -82,7 +82,7 @@ traffic between nodes should go: PIX and PXB are under the same PCIe switch, SYS
|
||||
|
||||
<h2>What runs here</h2>
|
||||
{{if .Engines}}
|
||||
<table><tr><th>Model</th><th>Replica</th><th>State</th><th>CPU</th><th class="num">Memory</th></tr>
|
||||
<table class="rows"><tr><th>Model</th><th>Replica</th><th>State</th><th>CPU</th><th class="num">Memory</th></tr>
|
||||
{{range .Engines}}<tr>
|
||||
<td>{{.Model}}</td>
|
||||
<td><code>{{.Replica}}</code></td>
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
{{end}}
|
||||
|
||||
{{if .Rows}}
|
||||
<table class="compact">
|
||||
<table class="rows compact">
|
||||
<tr><th>{{if eq .By "user"}}Person{{else if eq .By "project"}}Project{{else}}Team{{end}}</th>
|
||||
<th class="num">Requests</th><th class="num">Tokens</th><th class="num">Spend</th><th class="num">Errors</th></tr>
|
||||
{{range .Rows}}<tr>
|
||||
@@ -56,7 +56,7 @@ people do elsewhere — in another organization, or on their own — is not show
|
||||
|
||||
<h2>Teams, projects and limits</h2>
|
||||
{{if .Quotas}}
|
||||
<table class="quota-tree">
|
||||
<table class="rows quota-tree">
|
||||
<tr><th>Where</th><th class="num">Requests/min</th><th class="num">Tokens/min</th>
|
||||
<th class="num">Budget</th><th>Spent · 30 days</th></tr>
|
||||
{{range .Quotas}}
|
||||
@@ -79,7 +79,7 @@ people do elsewhere — in another organization, or on their own — is not show
|
||||
|
||||
<h2>Members</h2>
|
||||
{{if .Members}}
|
||||
<table class="compact">
|
||||
<table class="rows compact">
|
||||
<tr><th>Person</th><th>Member of</th><th>State</th></tr>
|
||||
{{range .Members}}<tr>
|
||||
<td>{{.Email}}</td>
|
||||
|
||||
@@ -109,6 +109,8 @@ var funcs = template.FuncMap{
|
||||
return p
|
||||
},
|
||||
"ago": ago,
|
||||
// shortNode is a node's name without what every node's name starts with.
|
||||
"shortNode": strings.TrimPrefix,
|
||||
"age": age,
|
||||
// pctbar is a percentage as the minibar template takes it: whole, and
|
||||
// held to 0..100 so a measurement a little over does not draw past its box.
|
||||
|
||||
Reference in new issue
Block a user