A node's page says which llmgw it runs and which model is on each GPU
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
178b168bfd
commit
4b6b3eeddb
6 files changed
+83
-7
No files matched your search
@@ -26,6 +26,12 @@ APIs change between minors without a deprecation period.
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
|
||||
- **A node's page says what it runs.** It now shows the agent's version, marked when the control plane runs another,
|
||||
and the engines with their versions. Each GPU row now names the models loaded on that card, and each engine row its
|
||||
GPUs.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A broken `auth.ldap` no longer stops every control plane.** It is the cluster's setting. A CA file that one
|
||||
|
||||
@@ -62,6 +62,8 @@
|
||||
"node.memory": "Memory",
|
||||
"node.cores_f": "%s cores",
|
||||
"node.no_engines": "No engines are placed on this node.",
|
||||
"node.version_differs": "the control plane runs %s",
|
||||
"node.gpu.models": "Models",
|
||||
"node.engines": "Engines: %s",
|
||||
"node.engines_none": "Engines: none installed",
|
||||
"node.model_files": "%d model file(s) on disk",
|
||||
|
||||
@@ -61,6 +61,8 @@
|
||||
"node.memory": "Bellek",
|
||||
"node.cores_f": "%s çekirdek",
|
||||
"node.no_engines": "Bu düğüme yerleştirilmiş motor yok.",
|
||||
"node.version_differs": "control plane %s çalıştırıyor",
|
||||
"node.gpu.models": "Modeller",
|
||||
"node.engines": "Motorlar: %s",
|
||||
"node.engines_none": "Motorlar: hiçbiri kurulu değil",
|
||||
"node.model_files": "diskte %d model dosyası",
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -20,9 +21,13 @@ type nodePageData struct {
|
||||
OnAdminListener bool
|
||||
Node adminapi.NodeView
|
||||
Engines []nodeEngine
|
||||
Charts *nodeCharts
|
||||
HasHistory bool
|
||||
Back, Logs string
|
||||
// GPUModels are the models on each of the node's GPUs, by index.
|
||||
GPUModels map[int][]string
|
||||
// EngineVersions are the installed engines and their versions, sorted.
|
||||
EngineVersions []string
|
||||
Charts *nodeCharts
|
||||
HasHistory bool
|
||||
Back, Logs string
|
||||
// Self is this page's address, for the in-place refresh.
|
||||
Self string
|
||||
Version, Now string
|
||||
@@ -34,6 +39,7 @@ type nodePageData struct {
|
||||
// nodeEngine is one engine running on the node, and its share of it.
|
||||
type nodeEngine struct {
|
||||
Replica, Model, Shard, State string
|
||||
GPUs []int
|
||||
CPUCores float64
|
||||
MemoryBytes int64
|
||||
// CPUShare is its cores as a percentage of the machine, for the bar.
|
||||
@@ -128,7 +134,15 @@ func NodePage(src Source, history History, window time.Duration, onAdminListener
|
||||
continue
|
||||
}
|
||||
e := nodeEngine{Replica: rep.ID, Model: rep.Model, Shard: sh.ID, State: sh.State,
|
||||
CPUCores: sh.CPUCores, MemoryBytes: sh.MemoryBytes}
|
||||
GPUs: sh.GPUs, CPUCores: sh.CPUCores, MemoryBytes: sh.MemoryBytes}
|
||||
for _, g := range sh.GPUs {
|
||||
if d.GPUModels == nil {
|
||||
d.GPUModels = map[int][]string{}
|
||||
}
|
||||
if !slices.Contains(d.GPUModels[g], rep.Model) {
|
||||
d.GPUModels[g] = append(d.GPUModels[g], rep.Model)
|
||||
}
|
||||
}
|
||||
if l := d.Node.Load; l != nil && l.CPUs > 0 {
|
||||
e.CPUShare = sh.CPUCores * 100 / float64(l.CPUs)
|
||||
}
|
||||
@@ -137,6 +151,10 @@ func NodePage(src Source, history History, window time.Duration, onAdminListener
|
||||
}
|
||||
}
|
||||
sort.Slice(d.Engines, func(i, j int) bool { return d.Engines[i].CPUCores > d.Engines[j].CPUCores })
|
||||
for e, v := range d.Node.EngineVersions {
|
||||
d.EngineVersions = append(d.EngineVersions, e+" "+v)
|
||||
}
|
||||
sort.Strings(d.EngineVersions)
|
||||
|
||||
if history != nil {
|
||||
d.Charts = loadNodeCharts(r.Context(), l, history, id, replicas, d.Node.GPUTotal, window, log)
|
||||
|
||||
@@ -270,3 +270,48 @@ func TestTheClusterPageDrawsItsGPUsByClass(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// versionSource is hotSource with an agent a release behind, a model on its
|
||||
// second GPU, and engines with their versions.
|
||||
type versionSource struct{ hotSource }
|
||||
|
||||
func (versionSource) Nodes() []adminapi.NodeView {
|
||||
n := hotSource{}.Nodes()[0]
|
||||
n.AgentVersion = "v0"
|
||||
n.EngineVersions = map[string]string{"llamacpp": "b10991", "comfyui": "0.39.2"}
|
||||
return []adminapi.NodeView{n}
|
||||
}
|
||||
|
||||
func (versionSource) Replicas(string) []adminapi.ReplicaView {
|
||||
r := fakeSource{}.Replicas("")
|
||||
r[0].Shards[0].GPUs = []int{1}
|
||||
return r
|
||||
}
|
||||
|
||||
func TestANodesPageSaysWhatItRunsAndWhichModelIsOnEachGPU(t *testing.T) {
|
||||
_, body := get(t, nodeMux(versionSource{}, nil), "http://x/admin/nodes/llmgw-node01", nil)
|
||||
model := versionSource{}.Replicas("")[0].Model
|
||||
for _, want := range []string{
|
||||
// The page's control plane runs v1: an agent on v0 is marked.
|
||||
`class="bad" title="the control plane runs v1">llmgw v0</span>`,
|
||||
"Engines: comfyui 0.39.2, llamacpp b10991",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("the page lacks %q", want)
|
||||
}
|
||||
}
|
||||
// GPU 1 holds the model, GPU 0 nothing.
|
||||
rows := strings.Split(body, "<tr>")
|
||||
var gpu0, gpu1 string
|
||||
for _, row := range rows {
|
||||
switch {
|
||||
case strings.HasPrefix(row, "\n <td class=\"num\">0</td>"):
|
||||
gpu0 = row
|
||||
case strings.HasPrefix(row, "\n <td class=\"num\">1</td>"):
|
||||
gpu1 = row
|
||||
}
|
||||
}
|
||||
if !strings.Contains(gpu1, "<td>"+model+"</td>") || strings.Contains(gpu0, model) {
|
||||
t.Errorf("GPU rows:\n0: %.300s\n1: %.300s", gpu0, gpu1)
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
<h1>{{.Node.ID}}</h1>
|
||||
<p class="sub"><span class="state state-{{.Node.State}}">{{t (print "dash.state." .Node.State)}}</span>
|
||||
{{with .Node.OS}} · {{.}}{{end}}{{with .Node.MgmtAddr}} · {{.}}{{end}}
|
||||
{{with .Node.AgentVersion}} · <span{{if ne . $.Version}} class="bad" title="{{t "node.version_differs" $.Version}}"{{end}}>llmgw {{.}}</span>{{end}}
|
||||
· {{if .Node.LastSeenAgo}}{{t "node.last_seen" .Node.LastSeenAgo}}{{else}}{{t "node.never_seen"}}{{end}} · <a href="{{.Logs}}">{{t "dash.nodes.logs"}}</a></p>
|
||||
{{if .OnAdminListener}}<nav><a href="{{.Back}}">{{t "node.nav.cluster"}}</a></nav>{{else}}{{template "nav" .Nav}}{{end}}
|
||||
</header>
|
||||
@@ -39,7 +40,7 @@
|
||||
{{if .Unhealthy}}<p class="note bad">{{join .Unhealthy}}</p>{{end}}
|
||||
{{with .Load}}{{if .GPUs}}
|
||||
<h2>{{t "node.gpus"}}</h2>
|
||||
<table class="rows"><tr><th class="num">GPU</th><th>{{t "node.gpu.busy"}}</th><th>{{t "node.gpu.memory_used"}}</th><th class="num">{{t "node.gpu.temp"}}</th><th class="num">{{t "node.gpu.memory_temp"}}</th><th>{{t "node.gpu.power"}}</th><th class="num">{{t "node.gpu.clock"}}</th><th>{{t "node.gpu.held_back"}}</th></tr>
|
||||
<table class="rows"><tr><th class="num">GPU</th><th>{{t "node.gpu.busy"}}</th><th>{{t "node.gpu.memory_used"}}</th><th class="num">{{t "node.gpu.temp"}}</th><th class="num">{{t "node.gpu.memory_temp"}}</th><th>{{t "node.gpu.power"}}</th><th class="num">{{t "node.gpu.clock"}}</th><th>{{t "node.gpu.models"}}</th><th>{{t "node.gpu.held_back"}}</th></tr>
|
||||
{{range .GPUs}}<tr>
|
||||
<td class="num">{{.Index}}</td>
|
||||
{{/* From the last sample; a dash where the driver or an older agent did
|
||||
@@ -52,6 +53,7 @@
|
||||
<td class="num">{{if .SMClockMHz}}{{num .SMClockMHz 0}} MHz{{else}}—{{end}}</td>
|
||||
{{/* The driver's reasons and the wear are named as the driver and the
|
||||
inventory name them, in English. */}}
|
||||
<td>{{with index $.GPUModels .Index}}{{join .}}{{else}}<span class="note">—</span>{{end}}</td>
|
||||
<td>{{if .Throttle}}<span class="bad">{{join .Throttle}}</span>{{else}}<span class="note">{{t "node.gpu.nothing"}}</span>{{end}}{{if gt .ECCUncorrected 0.0}} <span class="bad">{{t "node.gpu.ecc" (num .ECCUncorrected 0)}}</span>{{end}}{{with .Wear}}{{with .Problems}}<br><span class="bad">{{join .}}</span>{{end}}{{if gt .PCIeReplays 0}} <span class="note">{{t "node.gpu.pcie" (count .PCIeReplays)}}</span>{{end}}{{end}}</td>
|
||||
</tr>{{end}}</table>
|
||||
{{end}}{{end}}
|
||||
@@ -106,16 +108,17 @@
|
||||
|
||||
<h2>{{t "node.runs"}}</h2>
|
||||
{{if .Engines}}
|
||||
<table class="rows"><tr><th>{{t "dash.col.model"}}</th><th>{{t "dash.col.replica"}}</th><th>{{t "dash.col.state"}}</th><th>CPU</th><th class="num">{{t "node.memory"}}</th></tr>
|
||||
<table class="rows"><tr><th>{{t "dash.col.model"}}</th><th>{{t "dash.col.replica"}}</th><th>{{t "dash.col.state"}}</th><th>GPU</th><th>CPU</th><th class="num">{{t "node.memory"}}</th></tr>
|
||||
{{range .Engines}}<tr>
|
||||
<td>{{.Model}}</td>
|
||||
<td><code>{{.Replica}}</code></td>
|
||||
<td class="state state-{{.State}}">{{t (print "dash.state." .State)}}</td>
|
||||
<td>{{if .GPUs}}{{range $i, $g := .GPUs}}{{if $i}}, {{end}}{{$g}}{{end}}{{else}}—{{end}}</td>
|
||||
<td class="load">{{template "minibar" pctbar .CPUShare}}<span class="load-v">{{t "node.cores_f" (num .CPUCores 1)}}</span></td>
|
||||
<td class="num">{{if .MemoryBytes}}{{bytes .MemoryBytes}}{{else}}—{{end}}</td>
|
||||
</tr>{{end}}</table>
|
||||
{{else}}<p class="empty">{{t "node.no_engines"}}</p>{{end}}
|
||||
<p class="note">{{if .Node.Engines}}{{t "node.engines" (join .Node.Engines)}}{{else}}{{t "node.engines_none"}}{{end}}{{if .Node.Models}} · {{t "node.model_files" (len .Node.Models)}}{{end}}.</p>
|
||||
<p class="note">{{if .EngineVersions}}{{t "node.engines" (join .EngineVersions)}}{{else if .Node.Engines}}{{t "node.engines" (join .Node.Engines)}}{{else}}{{t "node.engines_none"}}{{end}}{{if .Node.Models}} · {{t "node.model_files" (len .Node.Models)}}{{end}}.</p>
|
||||
</div>
|
||||
|
||||
{{if .HasHistory}}{{with .Charts}}
|
||||
|
||||
Reference in new issue
Block a user