The usage and organization pages drew their choices as filled green chips, the newer pages as outlined pills. They are all the outlined pill now. The NVLink page's GPU squares were styled as .chip, the class those choices already used, which shrank the usage page's filters into 1.7rem squares wherever the stylesheet loaded. They are .gpu-chip. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
177 lines
6.5 KiB
Go
177 lines
6.5 KiB
Go
package web
|
|
|
|
import (
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/control/nvlink"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/control/topology"
|
|
"gitea.mynodes.xyz/ilker/llmgw/internal/promq"
|
|
)
|
|
|
|
// nodeMux mounts the page where it lives, so the path carries the node.
|
|
func nodeMux(src Source, h History) http.Handler {
|
|
mux := http.NewServeMux()
|
|
if h == nil {
|
|
mux.HandleFunc("GET /admin/nodes/{id}", NodePage(src, nil, time.Hour, false, "v1", nil))
|
|
} else {
|
|
mux.HandleFunc("GET /admin/nodes/{id}", NodePage(src, h, time.Hour, false, "v1", nil))
|
|
}
|
|
return mux
|
|
}
|
|
|
|
// busySource is fakeSource with a node that has been measured.
|
|
type busySource struct{ fakeSource }
|
|
|
|
func (busySource) Nodes() []adminapi.NodeView {
|
|
n := fakeSource{}.Nodes()[0]
|
|
n.Load = &adminapi.LoadView{CPUPercent: 62, CPUs: 8, Load1: 3.25, Load5: 2, Load15: 1,
|
|
MemUsed: 6 << 30, MemTotal: 8 << 30, MemPercent: 75}
|
|
n.IBPorts = []adminapi.IBPortView{
|
|
{HCA: "mlx5_0", Port: 1, State: "ACTIVE", PhysState: "LinkUp", RateGbps: 400, Active: true,
|
|
Fabric: "ib-a", TxBytes: 4 << 30},
|
|
{HCA: "mlx5_1", Port: 1, State: "DOWN", PhysState: "Polling", Errors: map[string]uint64{"link_downed": 7}},
|
|
}
|
|
return []adminapi.NodeView{n}
|
|
}
|
|
|
|
func (busySource) Replicas(string) []adminapi.ReplicaView {
|
|
r := fakeSource{}.Replicas("")
|
|
r[0].Shards[0].CPUCores, r[0].Shards[0].MemoryBytes = 4, 5<<30
|
|
return r
|
|
}
|
|
|
|
func TestANodesPageShowsHowBusyItIsAndWhatIsUsingIt(t *testing.T) {
|
|
h := &fakeHistory{byExpr: map[string][]promq.Series{
|
|
`llmgw_node_cpu_utilization_percent{node="llmgw-node01"} / 100`: {ts("llmgw-node01", 0.2, 0.6)},
|
|
}}
|
|
code, body := get(t, nodeMux(busySource{}, h), "http://x/admin/nodes/llmgw-node01", nil)
|
|
if code != http.StatusOK {
|
|
t.Fatalf("status %d", code)
|
|
}
|
|
for _, want := range []string{
|
|
"62%", "CPU · 8 cores", "RAM in use", "3.25",
|
|
// The engine and its share: four of eight cores is half the machine.
|
|
"<td>served</td>", "4.0 cores", "width: 50%",
|
|
`hx-get="/admin/nodes/llmgw-node01"`,
|
|
} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("missing %q", want)
|
|
}
|
|
}
|
|
// Every chart is about this node, and nothing else.
|
|
for _, q := range h.asked {
|
|
if !strings.Contains(q, `"llmgw-node01"`) && !strings.Contains(q, `served-1`) {
|
|
t.Errorf("a query not scoped to the node: %s", q)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAnUnknownNodeIsNotFound(t *testing.T) {
|
|
h := &fakeHistory{}
|
|
code, _ := get(t, nodeMux(busySource{}, h), "http://x/admin/nodes/nope", nil)
|
|
if code != http.StatusNotFound {
|
|
t.Errorf("status %d", code)
|
|
}
|
|
// Nothing was asked of the metrics store for a name nobody knows.
|
|
if len(h.asked) != 0 {
|
|
t.Errorf("queried for an unknown node: %v", h.asked)
|
|
}
|
|
}
|
|
|
|
func TestANodeNotMeasuredLatelySaysSoRatherThanZero(t *testing.T) {
|
|
_, body := get(t, nodeMux(fakeSource{}, nil), "http://x/admin/nodes/llmgw-node01", nil)
|
|
if !strings.Contains(body, "not measured lately") {
|
|
t.Error("an unmeasured node should say so")
|
|
}
|
|
if !strings.Contains(body, "No metrics store is configured") {
|
|
t.Error("without a metrics store the page should say where history comes from")
|
|
}
|
|
}
|
|
|
|
func TestANodesPageShowsItsFabricPorts(t *testing.T) {
|
|
_, body := get(t, nodeMux(busySource{}, nil), "http://x/admin/nodes/llmgw-node01", nil)
|
|
for _, want := range []string{
|
|
"<h2>Fabric</h2>", "On ib-a ·", "<code>mlx5_0/1</code>", "400 Gb/s", "4.0 GiB",
|
|
`<td class="bad">DOWN <span class="note">Polling</span></td>`, "link_downed 7",
|
|
} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("missing %q", want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// topoSource is busySource with a mapped fabric.
|
|
type topoSource struct{ busySource }
|
|
|
|
func (topoSource) Topologies() []adminapi.TopologyView {
|
|
return []adminapi.TopologyView{{ID: "sm-0x01", Key: "sm:0x01", Label: "fabric of subnet manager 0x01", Declared: []string{"ib-a"}}}
|
|
}
|
|
|
|
func (s topoSource) Topology(id string) (adminapi.TopologyView, bool) {
|
|
if id != "sm-0x01" {
|
|
return adminapi.TopologyView{}, false
|
|
}
|
|
t := s.Topologies()[0]
|
|
t.Map = &topology.View{Tiers: 2, Switches: []topology.SwitchView{{GUID: "0xs1", Name: "leaf-01</script><script>alert(1)", Tier: 1}}}
|
|
return t, true
|
|
}
|
|
|
|
func TestAFabricsPageCarriesItsMapSafely(t *testing.T) {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("GET /admin/fabrics", FabricsPage(topoSource{}, false, "v1"))
|
|
mux.HandleFunc("GET /admin/fabrics/{id}", FabricsPage(topoSource{}, false, "v1"))
|
|
mux.HandleFunc("GET /admin/fabrics/{id}/topology.json", FabricTopologyJSON(topoSource{}))
|
|
|
|
code, body := get(t, mux, "http://x/admin/fabrics", nil)
|
|
if code != 200 {
|
|
t.Fatalf("status %d", code)
|
|
}
|
|
for _, want := range []string{`id="fabric-map"`, `data-src="/admin/fabrics/sm-0x01/topology.json"`, "fabric.js?v=", `id="fabric-data"`} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("missing %q", want)
|
|
}
|
|
}
|
|
// A switch named to break out of the script it is carried in cannot.
|
|
if strings.Contains(body, "</script><script>alert(1)") {
|
|
t.Error("a switch description closed the data script")
|
|
}
|
|
if code, _ := get(t, mux, "http://x/admin/fabrics/nope", nil); code != 404 {
|
|
t.Errorf("unknown fabric: %d", code)
|
|
}
|
|
if code, body := get(t, mux, "http://x/admin/fabrics/sm-0x01/topology.json", nil); code != 200 || !strings.Contains(body, `"tiers":2`) {
|
|
t.Errorf("json: %d %s", code, body)
|
|
}
|
|
}
|
|
|
|
type nvlSource struct{ busySource }
|
|
|
|
func (nvlSource) NVLink() nvlink.Report {
|
|
return nvlink.Report{
|
|
Domains: []nvlink.Domain{{Key: "76b1ad76-7c24/7", ClusterUUID: "76b1ad76-7c24", CliqueID: "7", GPUs: 8, Joined: 7, Usable: 1,
|
|
Members: []nvlink.Member{
|
|
{Node: "t1", Usable: true, IMEX: "active", IMEXChannels: 1, GPUs: []nvlink.GPU{{Index: 0, Joined: true, State: "Completed"}}},
|
|
{Node: "t2", IMEX: "inactive", GPUs: []nvlink.GPU{{Index: 3, State: "In Progress"}}},
|
|
}}},
|
|
Problems: []nvlink.Problem{{Level: nvlink.Fail, Domain: "76b1ad76-7c24/7", Message: "t2: nvidia-imex is inactive"}},
|
|
}
|
|
}
|
|
|
|
func TestTheNVLinkPageDrawsEachDomainAsARack(t *testing.T) {
|
|
_, body := get(t, NVLinkPage(nvlSource{}, false, "v1"), "http://x/admin/nvlink", nil)
|
|
for _, want := range []string{`class="rack troubled"`, "76b1ad76·7", "1/2</b><span>trays usable", `class="gpu-chip joined"`,
|
|
`class="gpu-chip lost"`, `class="tray out"`, "t2: nvidia-imex is inactive", `class="imex up"`} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("missing %q", want)
|
|
}
|
|
}
|
|
// And the cluster page points at it.
|
|
_, body = get(t, Dashboard(nvlSource{}, fakeUsage{}, "v1", time.Second, false, NavExtras{}, nil), "http://x/admin", nil)
|
|
if !strings.Contains(body, "see the racks") {
|
|
t.Error("the cluster page does not lead to the racks")
|
|
}
|
|
}
|