Files
llmgw/internal/cli/tui_actions_test.go
T
Ilker ManapandClaude Opus 5.5 a1b1663e9d A btop-like monitor in the console, from Prometheus; node exporters discovered and installed
Monitor draws CPU, memory, GPUs, requests, tokens, latency, network and disk
as Braille charts over 5m/1h/24h/7d, then per node, card and model
sparklines; node and model pages have their own. History is read from
monitoring.prometheus through POST /api/v1/metrics/range, or without it
from what the console saw. The control plane serves the cluster's machines
for Prometheus http_sd at /prometheus/targets on its metrics port, and node
add installs prometheus-node-exporter.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 01:51:05 +02:00

594 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package cli
import (
"encoding/json"
"io"
"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)
}
}