Five minutes at five-second steps is sixty samples on a chart a hundred and thirty dots wide, and resampling by count drew them on its right half. Points are now put in the column of their time, held across the columns between samples, a series that began late starting where it began. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
617 lines
20 KiB
Go
617 lines
20 KiB
Go
package cli
|
||
|
||
import (
|
||
"encoding/json"
|
||
"io"
|
||
"math"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"os"
|
||
"strings"
|
||
"sync"
|
||
"testing"
|
||
"time"
|
||
|
||
tea "github.com/charmbracelet/bubbletea"
|
||
|
||
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
|
||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/modelserve"
|
||
"gitea.mynodes.xyz/ilker/llmgw/internal/modelplan"
|
||
"gitea.mynodes.xyz/ilker/llmgw/internal/promq"
|
||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||
"gitea.mynodes.xyz/ilker/llmgw/internal/version"
|
||
)
|
||
|
||
type seen struct {
|
||
mu sync.Mutex
|
||
reqs []string
|
||
}
|
||
|
||
func (s *seen) last() string {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
if len(s.reqs) == 0 {
|
||
return ""
|
||
}
|
||
return s.reqs[len(s.reqs)-1]
|
||
}
|
||
|
||
// adminStub answers every change with 204 and records it as "METHOD path body".
|
||
func adminStub(t *testing.T) (*seen, string) {
|
||
s := &seen{}
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method == http.MethodGet {
|
||
_, _ = w.Write([]byte("[]"))
|
||
return
|
||
}
|
||
b, _ := io.ReadAll(r.Body)
|
||
s.mu.Lock()
|
||
s.reqs = append(s.reqs, r.Method+" "+r.URL.Path+" "+strings.TrimSpace(string(b)))
|
||
s.mu.Unlock()
|
||
w.WriteHeader(http.StatusNoContent)
|
||
}))
|
||
t.Cleanup(srv.Close)
|
||
return s, srv.URL
|
||
}
|
||
|
||
// pressRun presses keys and runs, one level deep, whatever the last one
|
||
// returns that is not a tick, the way the program would.
|
||
func pressRun(m tea.Model, keys ...string) tea.Model {
|
||
m = press(m, keys[:len(keys)-1]...)
|
||
var msg tea.Msg = tea.KeyMsg{Type: tea.KeyEnter}
|
||
if k := keys[len(keys)-1]; k != "enter" {
|
||
msg = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(k)}
|
||
}
|
||
m, cmd := m.Update(msg)
|
||
if cmd != nil {
|
||
if done, ok := cmd().(actionDoneMsg); ok {
|
||
m, _ = m.Update(done)
|
||
}
|
||
}
|
||
return m
|
||
}
|
||
|
||
func consoleWith(t *testing.T, menu int) (consoleModel, *seen) {
|
||
s, url := adminStub(t)
|
||
m := newConsoleModel(url, "/nonexistent/server.yaml")
|
||
m.loaded, m.menu, m.inPanel = true, menu, true
|
||
m.data.models = []adminapi.ModelView{{Name: "qwen", Min: 1, Max: 1, Class: "standard", Profiles: []string{"cpu"}}}
|
||
m.data.nodes = []adminapi.NodeView{{ID: "n1", State: "ready"}}
|
||
m.data.replicas = []adminapi.ReplicaView{{ID: "qwen-1", Model: "qwen"}}
|
||
return m, s
|
||
}
|
||
|
||
func TestAModelsReplicasAreSetFromItsRow(t *testing.T) {
|
||
m, s := consoleWith(t, cModels)
|
||
var tm tea.Model = m
|
||
tm = press(tm, "enter")
|
||
if !strings.Contains(tm.View(), "Replicas (scale)") {
|
||
t.Fatalf("no action menu:\n%s", tm.View())
|
||
}
|
||
tm = press(tm, "down", "enter")
|
||
f := tm.(consoleModel).formD
|
||
if f == nil {
|
||
t.Fatal("no form")
|
||
}
|
||
f.fields[1].text.SetValue("3")
|
||
// A max below the min is refused in the form, and nothing is sent.
|
||
f.fields[0].text.SetValue("5")
|
||
tm = press(tm, "tab", "tab", "tab", "enter")
|
||
if f.err == "" || s.last() != "" {
|
||
t.Fatalf("a max below the min went through: %q %q", f.err, s.last())
|
||
}
|
||
f.fields[0].text.SetValue("2")
|
||
tm = pressRun(tm, "enter")
|
||
var body adminapi.ScaleRequest
|
||
got := s.last()
|
||
if !strings.HasPrefix(got, "POST /api/v1/models/qwen/scale ") ||
|
||
json.Unmarshal([]byte(strings.SplitN(got, " ", 3)[2]), &body) != nil || body.Min != 2 || body.Max != 3 {
|
||
t.Fatalf("sent %q", got)
|
||
}
|
||
if !strings.Contains(tm.View(), "keeps 2 to 3") {
|
||
t.Errorf("the outcome was not shown:\n%s", tm.View())
|
||
}
|
||
}
|
||
|
||
func TestANodeIsCordonedAndAModelRemovedOnlyWhenConfirmed(t *testing.T) {
|
||
m, s := consoleWith(t, cNodes)
|
||
var tm tea.Model = m
|
||
tm = pressRun(tm, "enter", "down", "enter")
|
||
if got := s.last(); got != `POST /api/v1/nodes/n1/state {"state":"cordoned"}` {
|
||
t.Fatalf("sent %q", got)
|
||
}
|
||
|
||
m, s = consoleWith(t, cModels)
|
||
tm = m
|
||
tm = press(tm, "enter")
|
||
n := len(tm.(consoleModel).menuD.items)
|
||
keys := []string{}
|
||
for range n - 1 {
|
||
keys = append(keys, "down")
|
||
}
|
||
tm = press(tm, append(keys, "enter")...)
|
||
if tm.(consoleModel).confirmD == nil {
|
||
t.Fatal("removing did not ask first")
|
||
}
|
||
tm = press(tm, "n")
|
||
if s.last() != "" {
|
||
t.Fatalf("removed though the answer was no: %q", s.last())
|
||
}
|
||
tm = press(tm, "enter")
|
||
tm = press(tm, append(keys, "enter")...)
|
||
_ = pressRun(tm, "y")
|
||
if got := s.last(); got != "DELETE /api/v1/models/qwen " {
|
||
t.Fatalf("sent %q", got)
|
||
}
|
||
}
|
||
|
||
func TestAQuotaIsAddedWithOnlyWhatIsGiven(t *testing.T) {
|
||
m, s := consoleWith(t, cQuotas)
|
||
var tm tea.Model = press(m, "a")
|
||
f := tm.(consoleModel).formD
|
||
f.fields[0].text.SetValue("team:acme/ml")
|
||
f.fields[4].text.SetValue("2")
|
||
for range len(f.fields) {
|
||
tm = press(tm, "tab")
|
||
}
|
||
_ = pressRun(tm, "enter")
|
||
if got := s.last(); got != `PUT /api/v1/quotas {"scope":"team:acme/ml","guarantee":{},"limit":{"gpus":2}}` {
|
||
t.Fatalf("sent %q", got)
|
||
}
|
||
}
|
||
|
||
func TestPeopleAreManagedFromTheConsoleAgainstItsStore(t *testing.T) {
|
||
dir := t.TempDir()
|
||
cfgPath := dir + "/server.yaml"
|
||
yaml := "auth:\n store: " + dir + "/llmgw.db\n"
|
||
if err := os.WriteFile(cfgPath, []byte(yaml), 0o600); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if _, err := config.Load(cfgPath); err != nil {
|
||
t.Fatalf("the test's configuration: %v", err)
|
||
}
|
||
m := newConsoleModel("127.0.0.1:1", cfgPath)
|
||
defer m.st.close()
|
||
m.loaded, m.inPanel = true, true
|
||
|
||
refresh := func(tm tea.Model) tea.Model {
|
||
cm := tm.(consoleModel)
|
||
msg := cm.fetchPeople()()
|
||
tm, _ = tm.Update(msg)
|
||
return tm
|
||
}
|
||
submit := func(tm tea.Model, vals ...string) tea.Model {
|
||
f := tm.(consoleModel).formD
|
||
if f == nil {
|
||
t.Fatal("no form")
|
||
}
|
||
for i, v := range vals {
|
||
f.fields[i].text.SetValue(v)
|
||
}
|
||
f.focus = len(f.fields)
|
||
tm = pressRun(tm, "enter")
|
||
if v := tm.View(); !strings.Contains(v, "✓") {
|
||
t.Fatalf("did not work:\n%s", v)
|
||
}
|
||
return refresh(press(tm, "x"))
|
||
}
|
||
|
||
m.menu = cOrgs
|
||
var tm tea.Model = m
|
||
tm = submit(press(tm, "a"), "Acme")
|
||
// Into the organization: a team, then a project in it.
|
||
tm = submit(press(tm, "enter", "enter"), "ml")
|
||
cm := tm.(consoleModel)
|
||
if len(cm.people.orgs) != 2 || cm.people.orgs[1].path != "Acme/ml" {
|
||
t.Fatalf("tree: %+v", cm.people.orgs)
|
||
}
|
||
tm = submit(press(tm, "down", "enter", "enter"), "training")
|
||
|
||
cm = tm.(consoleModel)
|
||
cm.menu, cm.row = cAccounts, 0
|
||
tm = submit(press(cm, "a"), "ayse@example.com", "a-long-s3cret-pass", "Acme/ml/training")
|
||
cm = tm.(consoleModel)
|
||
if len(cm.people.users) != 1 || cm.people.users[0].State != store.UserActive ||
|
||
!strings.Contains(strings.Join(cm.people.where[cm.people.users[0].ID], ","), "training") {
|
||
t.Fatalf("account: %+v %v", cm.people.users, cm.people.where)
|
||
}
|
||
|
||
cm.menu, cm.row = cKeys, 0
|
||
tm = press(cm, "a")
|
||
f := tm.(consoleModel).formD
|
||
f.fields[0].text.SetValue("ci")
|
||
f.fields[1].text.SetValue("Acme/ml/training")
|
||
f.focus = len(f.fields)
|
||
tm = pressRun(tm, "enter")
|
||
if v := tm.View(); !strings.Contains(v, "sk-") {
|
||
t.Fatalf("the new key was not shown:\n%s", v)
|
||
}
|
||
tm = refresh(press(tm, "x"))
|
||
if n := len(tm.(consoleModel).people.keys); n != 1 {
|
||
t.Fatalf("%d keys", n)
|
||
}
|
||
// Limits on the project, read back in its row.
|
||
cm = tm.(consoleModel)
|
||
cm.menu, cm.row = cOrgs, 2
|
||
tm = press(cm, "enter")
|
||
items := tm.(consoleModel).menuD.items
|
||
i := 0
|
||
for ; items[i].label != "Limits…"; i++ {
|
||
}
|
||
keys := []string{}
|
||
for range i {
|
||
keys = append(keys, "down")
|
||
}
|
||
tm = submit(press(tm, append(keys, "enter")...), "60")
|
||
if c := limitsCell(tm.(consoleModel).people.limits[store.LimitOwner{Scope: store.ScopeProject,
|
||
ID: tm.(consoleModel).people.orgs[2].id}.CounterKey()]); c != "60 rpm" {
|
||
t.Fatalf("limits: %q", c)
|
||
}
|
||
}
|
||
|
||
func TestTheSidebarFitsAShortScreen(t *testing.T) {
|
||
m, _ := consoleWith(t, cOrgs)
|
||
m.inPanel = false
|
||
for _, h := range []int{20, 30, 50} {
|
||
m.height = h
|
||
if n := len(strings.Split(m.View(), "\n")); n > h {
|
||
t.Errorf("height %d: %d lines drawn", h, n)
|
||
}
|
||
if !strings.Contains(m.View(), "Organizations") {
|
||
t.Errorf("height %d: the current entry is not shown", h)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestAModelIsFoundOnTheRegistryAndServedFromTheConsole(t *testing.T) {
|
||
reg := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
switch {
|
||
case r.URL.Path == "/api/models":
|
||
if r.URL.Query().Get("search") != "qwen" || r.URL.Query().Get("filter") != "gguf" {
|
||
_, _ = w.Write([]byte(`[]`))
|
||
return
|
||
}
|
||
_, _ = w.Write([]byte(`[{"id":"bartowski/Qwen2.5-3B-Instruct-GGUF","downloads":1200,"gguf":{"total":3000000000,"architecture":"qwen2"}}]`))
|
||
case r.URL.Path == "/api/models/bartowski/Qwen2.5-3B-Instruct-GGUF":
|
||
_, _ = w.Write([]byte(`{"id":"bartowski/Qwen2.5-3B-Instruct-GGUF","sha":"abc"}`))
|
||
case strings.HasPrefix(r.URL.Path, "/api/models/bartowski/Qwen2.5-3B-Instruct-GGUF/tree/"):
|
||
_, _ = w.Write([]byte(`[{"type":"file","path":"Qwen2.5-3B-Instruct-Q4_K_M.gguf","lfs":{"oid":"x","size":2000000000}},
|
||
{"type":"file","path":"Qwen2.5-3B-Instruct-Q8_0.gguf","lfs":{"oid":"y","size":3000000000}},
|
||
{"type":"file","path":"mmproj-f16.gguf","size":10}]`))
|
||
default:
|
||
http.NotFound(w, r)
|
||
}
|
||
}))
|
||
defer reg.Close()
|
||
dir := t.TempDir()
|
||
cfgPath := dir + "/server.yaml"
|
||
if err := os.WriteFile(cfgPath, []byte("mirror:\n api: http://127.0.0.1:1\n registry: "+reg.URL+"\n"), 0o600); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// The control plane's plan of the repository's files.
|
||
plan := modelserve.Plan{Repo: "bartowski/Qwen2.5-3B-Instruct-GGUF", Revision: "abc", Format: "gguf", Context: 16384,
|
||
Servable: true, Artifacts: []modelserve.PlannedArtifact{
|
||
{Artifact: modelplan.Artifact{Name: "Qwen2.5-3B-Instruct-Q4_K_M.gguf", Format: "gguf", Bytes: 2 << 30}},
|
||
{Artifact: modelplan.Artifact{Name: "Qwen2.5-3B-Instruct-Q8_0.gguf", Format: "gguf", Bytes: 3 << 30}},
|
||
{Artifact: modelplan.Artifact{Name: "Qwen2.5-3B-Instruct-F16.gguf", Format: "gguf", Bytes: 6 << 30}},
|
||
}}
|
||
gpu := modelplan.Option{Format: "gguf", Engine: "llamacpp", Class: "6000-24g", GPUsPerNode: 1, Nodes: 1,
|
||
NeedGB: 5, HaveGB: 21.6, Hosts: 1, FreeNow: true}
|
||
cpu := modelplan.Option{Format: "gguf", Engine: "llamacpp", CPU: true, NeedGB: 5, HaveGB: 27, Hosts: 2, FreeNow: true}
|
||
for _, o := range []struct {
|
||
art string
|
||
o modelplan.Option
|
||
}{{"Qwen2.5-3B-Instruct-Q8_0.gguf", gpu}, {"Qwen2.5-3B-Instruct-Q4_K_M.gguf", gpu},
|
||
{"Qwen2.5-3B-Instruct-Q8_0.gguf", cpu}, {"Qwen2.5-3B-Instruct-F16.gguf", cpu}} {
|
||
o.o.Artifact = o.art
|
||
plan.Options = append(plan.Options, o.o)
|
||
}
|
||
for i := range plan.Artifacts {
|
||
for j := range plan.Options {
|
||
if plan.Options[j].Artifact == plan.Artifacts[i].Name {
|
||
plan.Artifacts[i].Best = &plan.Options[j]
|
||
break
|
||
}
|
||
}
|
||
}
|
||
var started modelserve.Request
|
||
admin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
if r.URL.Path == "/api/v1/plan" && r.URL.Query().Get("repo") == plan.Repo {
|
||
_ = json.NewEncoder(w).Encode(plan)
|
||
return
|
||
}
|
||
if r.Method == http.MethodPost && r.URL.Path == "/api/v1/models/serve" {
|
||
_ = json.NewDecoder(r.Body).Decode(&started)
|
||
w.WriteHeader(http.StatusAccepted)
|
||
_ = json.NewEncoder(w).Encode(modelserve.Job{ID: "serve-1", State: modelserve.Running})
|
||
return
|
||
}
|
||
_, _ = w.Write([]byte("[]"))
|
||
}))
|
||
defer admin.Close()
|
||
m := newConsoleModel(admin.URL, cfgPath)
|
||
m.loaded, m.menu, m.inPanel = true, cServe, true
|
||
var tm tea.Model = m
|
||
|
||
run := func(tm tea.Model, cmd tea.Cmd) tea.Model {
|
||
if cmd != nil {
|
||
tm, _ = tm.Update(cmd())
|
||
}
|
||
return tm
|
||
}
|
||
tm, _ = tm.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("/")})
|
||
tm = press(tm, "q", "w", "e", "n")
|
||
tm, cmd := tm.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||
tm = run(tm, cmd)
|
||
if v := tm.View(); !strings.Contains(v, "bartowski/Qwen2.5-3B-Instruct-GGUF") || !strings.Contains(v, "3.0B") {
|
||
t.Fatalf("no result:\n%s", v)
|
||
}
|
||
tm, cmd = tm.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||
tm = run(tm, cmd)
|
||
d := tm.(consoleModel).menuD
|
||
if d == nil || len(d.items) != 3 || !strings.Contains(d.items[0].label, "★") ||
|
||
!strings.Contains(d.items[0].label, "Q8_0") || !strings.Contains(d.items[0].label, "1 × 6000-24g") ||
|
||
!strings.Contains(d.items[2].label, "CPU") {
|
||
t.Fatalf("files offered: %+v", d)
|
||
}
|
||
tm = press(tm, "enter")
|
||
f := tm.(consoleModel).formD
|
||
if f == nil || f.fields[0].value() != "qwen2.5-3b-instruct" || len(f.fields[4].options) != 2 {
|
||
t.Fatalf("form: %+v", f)
|
||
}
|
||
f.focus = len(f.fields)
|
||
tm = pressRun(tm, "enter")
|
||
// Started on the control plane; the console stays.
|
||
if act := tm.(consoleModel).action; act.kind != "" {
|
||
t.Fatalf("the console left to run %v", act)
|
||
}
|
||
if started.Repo != plan.Repo || started.File != "Qwen2.5-3B-Instruct-Q8_0.gguf" || started.GPUClass != "6000-24g" ||
|
||
started.GPUsPerNode != 1 || !started.Fallbacks || started.Name != "qwen2.5-3b-instruct" {
|
||
t.Fatalf("started %+v", started)
|
||
}
|
||
if v := tm.View(); !strings.Contains(v, "serve-1") {
|
||
t.Errorf("the start was not shown:\n%s", v)
|
||
}
|
||
}
|
||
|
||
func TestTheManualHasEveryPageAndFindsInThem(t *testing.T) {
|
||
v := newDocViewer()
|
||
for _, k := range []string{"console", "install", "reference", "commands", "changes"} {
|
||
if findTopic(v.topics, k) < 0 {
|
||
t.Errorf("no %s page", k)
|
||
}
|
||
}
|
||
cmds := commandsManual()
|
||
for _, c := range []string{"llmgw ctl model update", "llmgw users approve", "llmgw docs"} {
|
||
if !strings.Contains(cmds, "# "+c) {
|
||
t.Errorf("the commands page lacks %s", c)
|
||
}
|
||
}
|
||
// Every line fits the width it was laid out for.
|
||
for _, tp := range v.topics {
|
||
for _, l := range renderMarkdown(tp.src(), 80) {
|
||
if !strings.HasPrefix(l.plain, " ") && len([]rune(l.plain)) > 80 {
|
||
t.Errorf("%s: a line of %d: %q", tp.key, len([]rune(l.plain)), l.plain)
|
||
break
|
||
}
|
||
}
|
||
}
|
||
v.show(findTopic(v.topics, "console"))
|
||
v.query = "drain"
|
||
v.find(80, 0, 1)
|
||
if l := v.lines(80)[v.scroll+2]; !strings.Contains(strings.ToLower(l.plain), "drain") {
|
||
t.Errorf("found at %q", l.plain)
|
||
}
|
||
v.openTOC(80)
|
||
if v.toc == nil || len(v.toc.items) < 5 {
|
||
t.Fatal("no contents")
|
||
}
|
||
}
|
||
|
||
func TestQuestionMarkOpensTheConsolesPage(t *testing.T) {
|
||
m, _ := consoleWith(t, cNodes)
|
||
tm := press(m, "?")
|
||
cm := tm.(consoleModel)
|
||
if cm.menu != cManual || !cm.detail || cm.docs.topics[cm.docs.open].key != "console" {
|
||
t.Fatalf("menu %d detail %v", cm.menu, cm.detail)
|
||
}
|
||
if !strings.Contains(tm.View(), "The console") {
|
||
t.Error("the page is not shown")
|
||
}
|
||
}
|
||
|
||
func TestNextFindsTheNextMatchAndComesRound(t *testing.T) {
|
||
v := newDocViewer()
|
||
v.show(findTopic(v.topics, "console"))
|
||
v.query = "revok"
|
||
lines := v.lines(80)
|
||
var hits []int
|
||
for i, l := range lines {
|
||
if strings.Contains(strings.ToLower(l.plain), "revok") {
|
||
hits = append(hits, i)
|
||
}
|
||
}
|
||
if len(hits) < 2 {
|
||
t.Fatalf("hits %v", hits)
|
||
}
|
||
v.find(80, 0, 1)
|
||
seen := []int{v.scroll + 2}
|
||
for range hits {
|
||
v.key(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")}, 80, 30)
|
||
seen = append(seen, v.scroll+2)
|
||
}
|
||
if seen[0] != hits[0] || seen[1] != hits[1] || seen[len(hits)] != hits[0] {
|
||
t.Errorf("hits %v, went %v", hits, seen)
|
||
}
|
||
}
|
||
|
||
func TestTabAndArrowsGoRoundAFormFromApplyWithoutFalling(t *testing.T) {
|
||
m, _ := consoleWith(t, cQuotas)
|
||
var tm tea.Model = press(m, "a")
|
||
n := len(tm.(consoleModel).formD.fields)
|
||
// Round the form twice each way, through Apply.
|
||
for range 2 * (n + 1) {
|
||
tm = press(tm, "tab")
|
||
}
|
||
for range 2 * (n + 1) {
|
||
tm, _ = tm.Update(tea.KeyMsg{Type: tea.KeyUp})
|
||
tm, _ = tm.Update(tea.KeyMsg{Type: tea.KeyShiftTab})
|
||
}
|
||
for range 2 * (n + 1) {
|
||
tm = press(tm, "down")
|
||
}
|
||
if f := tm.(consoleModel).formD; f == nil || f.focus < 0 || f.focus > n {
|
||
t.Fatalf("focus %+v", f)
|
||
}
|
||
}
|
||
|
||
func TestEveryScreenShowsTheVersionTopRight(t *testing.T) {
|
||
m, _ := consoleWith(t, cNodes)
|
||
first := strings.Split(m.View(), "\n")[0]
|
||
if !strings.Contains(first, version.Version) {
|
||
t.Errorf("the console's top line: %q", first)
|
||
}
|
||
d := docsModel{v: newDocViewer(), width: 100, height: 30}
|
||
d.v.show(0)
|
||
if first := strings.Split(d.View(), "\n")[0]; !strings.Contains(first, version.Version) {
|
||
t.Errorf("the manual's top line: %q", first)
|
||
}
|
||
}
|
||
|
||
func TestJobsAreFollowedFromTheirOwnView(t *testing.T) {
|
||
m, _ := consoleWith(t, cJobs)
|
||
m.width = 160
|
||
m.data.jobs = []modelserve.Job{{ID: "serve-9", Model: "phi-4", State: modelserve.Running, Started: time.Now(),
|
||
Steps: []modelserve.Step{{Name: "registry", State: modelserve.Done, Detail: "phi-4-Q8_0.gguf"},
|
||
{Name: "mirror", State: modelserve.Running, Detail: "fetching: 3.1 of 14.6 GB (21%)"}}}}
|
||
v := m.View()
|
||
if !strings.Contains(v, "Jobs (1)") || !strings.Contains(v, "fetching: 3.1 of 14.6 GB") {
|
||
t.Fatalf("%s", v)
|
||
}
|
||
tm := press(m, "enter")
|
||
if v := tm.View(); !strings.Contains(v, "serve-9") || !strings.Contains(v, "phi-4-Q8_0.gguf") {
|
||
t.Errorf("steps not shown:\n%s", v)
|
||
}
|
||
}
|
||
|
||
func TestTheMonitorDrawsFromPrometheusAndWithoutIt(t *testing.T) {
|
||
pts := func(vals ...float64) []promq.Series {
|
||
s := promq.Series{Name: "x"}
|
||
for _, v := range vals {
|
||
s.Points = append(s.Points, promq.Point{Value: v})
|
||
}
|
||
return []promq.Series{s}
|
||
}
|
||
var asked adminapi.MetricsRequest
|
||
admin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
if r.URL.Path != "/api/v1/metrics/range" {
|
||
_, _ = w.Write([]byte("[]"))
|
||
return
|
||
}
|
||
_ = json.NewDecoder(r.Body).Decode(&asked)
|
||
res := adminapi.MetricsResult{Configured: true, Series: map[string][]promq.Series{
|
||
"cpu": pts(10, 40, 80), "mem": pts(50, 55), "rps": pts(1, 2, 3), "rpsby": pts(1, 2, 3), "tokout": pts(100, 200),
|
||
"netin": pts(1e6, 2e6),
|
||
}}
|
||
res.Series["rpsby"][0].Name = "qwen"
|
||
_ = json.NewEncoder(w).Encode(res)
|
||
}))
|
||
defer admin.Close()
|
||
m := newConsoleModel(admin.URL, "/nonexistent/server.yaml")
|
||
m.loaded, m.menu, m.inPanel, m.width, m.height = true, cMonitor, true, 160, 60
|
||
var tm tea.Model = m
|
||
tm, cmd := tm.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("2")})
|
||
tm, _ = tm.Update(cmd())
|
||
if asked.Window != time.Hour || len(asked.Queries) < 10 {
|
||
t.Fatalf("asked %+v", asked)
|
||
}
|
||
v := tm.View()
|
||
for _, want := range []string{"[1h]", "CPU", "80%", "Requests", "qwen", "from Prometheus", "Network", "1.9 MB/s"} {
|
||
if !strings.Contains(v, want) {
|
||
t.Errorf("no %q:\n%s", want, v)
|
||
}
|
||
}
|
||
if !strings.ContainsAny(v, "⣀⣤⣶⣿") {
|
||
t.Errorf("no braille chart:\n%s", v)
|
||
}
|
||
|
||
// No Prometheus: what the console saw, and it says so.
|
||
m2, _ := consoleWith(t, cMonitor)
|
||
m2.width, m2.height = 160, 60
|
||
for _, c := range []float64{20, 60} {
|
||
m2.data.nodes = []adminapi.NodeView{{ID: "n1", State: "ready", Load: &adminapi.LoadView{CPUPercent: c, MemPercent: 30,
|
||
MemUsed: 3, MemTotal: 10}}}
|
||
m2.mon.sample(m2.data)
|
||
}
|
||
if v := m2.View(); !strings.Contains(v, "since it opened") || !strings.Contains(v, "60%") {
|
||
t.Errorf("local:\n%s", v)
|
||
}
|
||
}
|
||
|
||
func TestBrailleChartsFillToTheirHeight(t *testing.T) {
|
||
rows := brailleChart([]chartSeries{{values: []float64{0, 50, 100, 100}}}, 2, 2, 100)
|
||
if len(rows) != 2 {
|
||
t.Fatal(rows)
|
||
}
|
||
// The right cell is full top to bottom; the left holds a low point and a middle one.
|
||
if !strings.Contains(rows[0], "⣿") || !strings.Contains(rows[1], "⣿") {
|
||
t.Errorf("%q", rows)
|
||
}
|
||
if s := sparkline([]float64{0, 1, 2, 3}, 4, 3); !strings.Contains(s, "▁") || !strings.Contains(s, "█") {
|
||
t.Errorf("%q", s)
|
||
}
|
||
}
|
||
|
||
func TestANodesPageHasItsCharts(t *testing.T) {
|
||
var asked adminapi.MetricsRequest
|
||
admin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
if r.URL.Path != "/api/v1/metrics/range" {
|
||
_, _ = w.Write([]byte("[]"))
|
||
return
|
||
}
|
||
_ = json.NewDecoder(r.Body).Decode(&asked)
|
||
_ = json.NewEncoder(w).Encode(adminapi.MetricsResult{Configured: true, Series: map[string][]promq.Series{
|
||
"cpu": {{Name: "n1", Points: []promq.Point{{Value: 12}, {Value: 34}}}}}})
|
||
}))
|
||
defer admin.Close()
|
||
m := newConsoleModel(admin.URL, "/nonexistent/server.yaml")
|
||
m.loaded, m.menu, m.inPanel, m.width, m.height = true, cNodes, true, 160, 60
|
||
m.data.nodes = []adminapi.NodeView{{ID: "n1", State: "ready"}}
|
||
var tm tea.Model = m
|
||
tm = press(tm, "enter") // the menu; Details first
|
||
tm, cmd := tm.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||
if cmd == nil {
|
||
t.Fatal("opening the page read nothing")
|
||
}
|
||
tm, _ = tm.Update(cmd())
|
||
if len(asked.Queries) == 0 || !strings.Contains(asked.Queries[0].Expr, `node="n1"`) {
|
||
t.Fatalf("asked %+v", asked.Queries)
|
||
}
|
||
if v := tm.View(); !strings.Contains(v, "Over the last") || !strings.Contains(v, "34%") {
|
||
t.Errorf("%s", v)
|
||
}
|
||
}
|
||
|
||
func TestAWindowsSamplesFillTheChartsWidth(t *testing.T) {
|
||
// Five minutes at five-second steps is sixty samples; the chart is a
|
||
// hundred and twenty dots across. Every column is drawn.
|
||
to := time.Now()
|
||
s := chartSeries{from: to.Add(-5 * time.Minute), to: to}
|
||
for i := 0; i < 60; i++ {
|
||
s.at = append(s.at, to.Add(-5*time.Minute+time.Duration(i)*5*time.Second))
|
||
s.values = append(s.values, 50)
|
||
}
|
||
for c, v := range s.fit(120) {
|
||
if math.IsNaN(v) {
|
||
t.Fatalf("column %d of 120 empty", c)
|
||
}
|
||
}
|
||
// A series that began two minutes ago fills the last two fifths only.
|
||
s.at, s.values = s.at[36:], s.values[36:]
|
||
got := s.fit(100)
|
||
if !math.IsNaN(got[50]) || math.IsNaN(got[70]) || math.IsNaN(got[99]) {
|
||
t.Errorf("a late start: %v", got)
|
||
}
|
||
}
|