From 097b411b660fd078916039c7bddc9be1ac2d201f Mon Sep 17 00:00:00 2001 From: Igor Churmeev Date: Mon, 9 Nov 2026 22:37:20 +0000 Subject: [PATCH 1/7] refactor(ui): name the letters of the log toggles once --- internal/ui/logs.go | 6 +++--- internal/ui/logs_test.go | 10 ++++++++++ internal/ui/page.go | 15 ++++++++++++--- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/internal/ui/logs.go b/internal/ui/logs.go index 7f5490c..6715397 100644 --- a/internal/ui/logs.go +++ b/internal/ui/logs.go @@ -448,9 +448,9 @@ func (m Model) logToggles(width int) string { press, name string on bool }{ - {"s", "Autoscroll", m.text.following}, - {"t", "Timestamps", m.text.times}, - {"w", "Wrap", m.text.wrap}, + {followPress, "Autoscroll", m.text.following}, + {timesPress, "Timestamps", m.text.times}, + {wrapPress, "Wrap", m.text.wrap}, } cells := make([]string, 0, len(toggles)) diff --git a/internal/ui/logs_test.go b/internal/ui/logs_test.go index 2b673e4..d7c91e8 100644 --- a/internal/ui/logs_test.go +++ b/internal/ui/logs_test.go @@ -739,3 +739,13 @@ func TestLogs_ASwitchOfTheSessionLeavesTheLogAsItIs(t *testing.T) { r.True(m.text.wrap) r.False(client.logsClosed, "the log was closed to be read again") } + +// The toggle line asks for the keys by their letters: a key that took another +// letter would leave its toggle off the line. +func TestLogToggles_AskForTheKeysOfTheToggles(t *testing.T) { + r := require.New(t) + + r.Equal(followPress, followKey[logsPage]().press) + r.Equal(timesPress, timesKey[logsPage]().press) + r.Equal(wrapPress, wrapKey[logsPage]().press) +} diff --git a/internal/ui/page.go b/internal/ui/page.go index 3f12e0e..43acd81 100644 --- a/internal/ui/page.go +++ b/internal/ui/page.go @@ -424,7 +424,14 @@ func findWordKeys[P page]() []pageKey[P] { } } -func wrapKey[P page]() pageKey[P] { return windowKey[P]("w", "Toggle Wrap", wrapMsg{}) } +// The keys of the toggles of the window, which its toggle line names too. +const ( + followPress = "s" + timesPress = "t" + wrapPress = "w" +) + +func wrapKey[P page]() pageKey[P] { return windowKey[P](wrapPress, "Toggle Wrap", wrapMsg{}) } func saveKey[P page]() pageKey[P] { return windowKey[P]("ctrl+s", "Save", saveMsg{}) } @@ -433,9 +440,11 @@ func copyTextKey[P page]() pageKey[P] { return windowKey[P]("c", "Copy", copyTex // followKey follows the end of what a stream writes, or stops following it // where the view is; timesKey shows or hides the time each line of it // arrived in front of the line. -func followKey[P page]() pageKey[P] { return windowKey[P]("s", "Toggle Autoscroll", followMsg{}) } +func followKey[P page]() pageKey[P] { + return windowKey[P](followPress, "Toggle Autoscroll", followMsg{}) +} -func timesKey[P page]() pageKey[P] { return windowKey[P]("t", "Toggle Timestamps", timesMsg{}) } +func timesKey[P page]() pageKey[P] { return windowKey[P](timesPress, "Toggle Timestamps", timesMsg{}) } // windowKey is a key of the window over a text, which the root holds. func windowKey[P page](press, label string, msg tea.Msg) pageKey[P] { From a1229f7d3d097275856cddcec7600f75f2474c98 Mon Sep 17 00:00:00 2001 From: Igor Churmeev Date: Mon, 9 Nov 2026 22:37:58 +0000 Subject: [PATCH 2/7] refactor(ui): explain the colors of the rows without naming another program --- internal/ui/allocrows_test.go | 2 +- internal/ui/jobs.go | 8 ++++---- internal/ui/statecolor_test.go | 4 ++-- internal/ui/theme.go | 6 +++--- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/internal/ui/allocrows_test.go b/internal/ui/allocrows_test.go index 41e731c..410e514 100644 --- a/internal/ui/allocrows_test.go +++ b/internal/ui/allocrows_test.go @@ -70,7 +70,7 @@ func TestAllocations_ARecentRestartOrOOMNeedsAttention(t *testing.T) { rows := allocRows(troubledAllocs(), nil, time.Now()) // A restart or an OOM kill of the last hour is a task in trouble now: - // red, as k9s paints a pod that crashes. + // red. r.Equal(colorDead, rows[0].color) r.Equal(colorDead, rows[1].color) diff --git a/internal/ui/jobs.go b/internal/ui/jobs.go index 871df4f..fe8bbaf 100644 --- a/internal/ui/jobs.go +++ b/internal/ui/jobs.go @@ -360,10 +360,10 @@ func markWorkload(m map[string]nomad.QueueWorkload, w nomad.QueueWorkload) { m[w.JobID] = w } -// jobColor shows the state of a job without reading the row, as k9s shows a -// pod: what waits for room is orange, a service not all up yet is red, what -// was stopped and still runs is purple, a dead one is red, a batch job that -// ended gets the spent color, not the dead one, unless a group of it failed. +// jobColor shows the state of a job without reading the row: what waits for +// room is orange, a service not all up yet is red, what was stopped and still +// runs is purple, a dead one is red, a batch job that ended gets the spent +// color, not the dead one, unless a group of it failed. func jobColor(job nomad.Job) color.Color { switch job.Status { case statusRunning: diff --git a/internal/ui/statecolor_test.go b/internal/ui/statecolor_test.go index 4f53e2a..b92b93d 100644 --- a/internal/ui/statecolor_test.go +++ b/internal/ui/statecolor_test.go @@ -41,11 +41,11 @@ func hue(c color.Color) float64 { return h } -func TestStateColours_ReadLikeK9s(t *testing.T) { +func TestStateColours_HaveTheHueOfTheirState(t *testing.T) { r := require.New(t) // What waits to start is orange, what needs a look is yellow, what goes - // away is purple: the colours k9s gives a pod in the same state. + // away is purple. r.InDelta(25, hue(colorPending), 15, "pending is orange") r.InDelta(45, hue(colorAttention), 10, "attention is yellow") r.InDelta(260, hue(colorStopping), 20, "stopping is purple") diff --git a/internal/ui/theme.go b/internal/ui/theme.go index 64000e0..42fb2e7 100644 --- a/internal/ui/theme.go +++ b/internal/ui/theme.go @@ -29,9 +29,9 @@ var ( colorMark = lipgloss.Color("#d7a13b") ) -// Row colors say what state a resource is in, at a glance down the list. -// They follow k9s: what waits to start is orange, what started and is not -// ready yet or broke is red, what is on its way out is purple. +// Row colors say what state a resource is in, at a glance down the list: +// what waits to start is orange, what started and is not ready yet or broke +// is red, what is on its way out is purple. var ( colorAttention = lipgloss.Color("#e5c07b") colorPending = lipgloss.Color("#f0883e") From 79961b7a26db596dc965f2de9b5d1df07638b9e5 Mon Sep 17 00:00:00 2001 From: Igor Churmeev Date: Mon, 9 Nov 2026 22:39:51 +0000 Subject: [PATCH 3/7] refactor(ui): build the mark of a queued job with jobMark --- internal/ui/jobs.go | 2 +- internal/ui/mark.go | 4 ++++ internal/ui/queue.go | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/internal/ui/jobs.go b/internal/ui/jobs.go index fe8bbaf..d639bcd 100644 --- a/internal/ui/jobs.go +++ b/internal/ui/jobs.go @@ -355,7 +355,7 @@ func batchQueue(ctx context.Context, client Client, pool string) (map[string]nom func markWorkload(m map[string]nomad.QueueWorkload, w nomad.QueueWorkload) { if w.Namespace != "" { - m[w.Namespace+"/"+w.JobID] = w + m[workloadMark(w)] = w } m[w.JobID] = w } diff --git a/internal/ui/mark.go b/internal/ui/mark.go index 858a129..682ff1b 100644 --- a/internal/ui/mark.go +++ b/internal/ui/mark.go @@ -61,6 +61,10 @@ func allocLabel(allocs []nomad.Alloc) string { // the thing it was put on is gone. func jobMark(job nomad.Job) string { return job.Namespace + "/" + job.ID } +func workloadMark(w nomad.QueueWorkload) string { + return jobMark(nomad.Job{Namespace: w.Namespace, ID: w.JobID}) +} + func allocMark(alloc nomad.Alloc) string { return alloc.ID } func nodeMark(node nomad.Node) string { return node.ID } diff --git a/internal/ui/queue.go b/internal/ui/queue.go index b289463..f6b59d5 100644 --- a/internal/ui/queue.go +++ b/internal/ui/queue.go @@ -387,7 +387,7 @@ func (p queuePage) press(k string, e env) (page, outcome, bool) { func (p queuePage) ids(env) []string { ids := make([]string, 0, len(p.items)) for _, item := range p.items { - ids = append(ids, item.Namespace+"/"+item.JobID) + ids = append(ids, workloadMark(item)) } return ids From b5675f60aacfc7f63313ac34e50378f21139e24b Mon Sep 17 00:00:00 2001 From: Igor Churmeev Date: Mon, 9 Nov 2026 22:40:28 +0000 Subject: [PATCH 4/7] refactor(ui): ask for lists with request and drop fetchList --- internal/ui/acl.go | 2 +- internal/ui/alloc.go | 2 +- internal/ui/client.go | 2 +- internal/ui/clientdetail.go | 2 +- internal/ui/clusters.go | 2 +- internal/ui/deployment.go | 4 ++-- internal/ui/evaluation.go | 2 +- internal/ui/files.go | 2 +- internal/ui/instances.go | 4 ++-- internal/ui/jobs.go | 2 +- internal/ui/namespaces.go | 2 +- internal/ui/namespacetasks.go | 2 +- internal/ui/node.go | 2 +- internal/ui/nodepools.go | 2 +- internal/ui/plugins.go | 2 +- internal/ui/region.go | 4 ++-- internal/ui/scaling.go | 2 +- internal/ui/screen.go | 5 ----- internal/ui/server.go | 2 +- internal/ui/taskgroup.go | 2 +- internal/ui/variable.go | 2 +- internal/ui/version.go | 2 +- internal/ui/volumes.go | 2 +- internal/ui/watch_test.go | 2 +- 24 files changed, 26 insertions(+), 31 deletions(-) diff --git a/internal/ui/acl.go b/internal/ui/acl.go index 9e97ef9..3d8d19a 100644 --- a/internal/ui/acl.go +++ b/internal/ui/acl.go @@ -84,7 +84,7 @@ func (aclPage) topics() []string { return nil } func (p aclPage) fetch(e env) tea.Cmd { client, kind := e.client, p.kind - return fetchList(func(ctx context.Context) ([]nomad.ACLObject, error) { + return request(func(ctx context.Context) ([]nomad.ACLObject, error) { return client.ACLObjects(ctx, kind) }, func(items []nomad.ACLObject) tea.Msg { return aclMsg{kind: kind, objects: items} }) } diff --git a/internal/ui/alloc.go b/internal/ui/alloc.go index 816d8b2..a35e892 100644 --- a/internal/ui/alloc.go +++ b/internal/ui/alloc.go @@ -70,7 +70,7 @@ func (p allocationsPage) where(e env) string { func (p allocationsPage) fetch(e env) tea.Cmd { client, namespace, jobID, evalID := e.client, p.where(e), p.jobID, p.evalID - return fetchList(func(ctx context.Context) ([]nomad.Alloc, error) { + return request(func(ctx context.Context) ([]nomad.Alloc, error) { if evalID != "" { return client.EvaluationAllocations(ctx, namespace, evalID) } diff --git a/internal/ui/client.go b/internal/ui/client.go index b234e47..e76434f 100644 --- a/internal/ui/client.go +++ b/internal/ui/client.go @@ -155,7 +155,7 @@ func (p clientPage) fetch(e env) tea.Cmd { client, nodeID := e.client, p.nodeID cmds := []tea.Cmd{ - fetchList(func(ctx context.Context) ([]nomad.Alloc, error) { + request(func(ctx context.Context) ([]nomad.Alloc, error) { return client.NodeAllocations(ctx, nodeID) }, func(items []nomad.Alloc) tea.Msg { return allocsMsg(items) }), fetchHost(client, nodeID), diff --git a/internal/ui/clientdetail.go b/internal/ui/clientdetail.go index 81dee6b..5fd5f65 100644 --- a/internal/ui/clientdetail.go +++ b/internal/ui/clientdetail.go @@ -234,7 +234,7 @@ func (nodeMetaPage) topics() []string { return nil } func (p nodeMetaPage) fetch(e env) tea.Cmd { client, nodeID := e.client, p.nodeID - return fetchList(func(ctx context.Context) ([]nomad.MetaEntry, error) { + return request(func(ctx context.Context) ([]nomad.MetaEntry, error) { return client.NodeMeta(ctx, nodeID) }, func(meta []nomad.MetaEntry) tea.Msg { return nodeMetaMsg{nodeID: nodeID, meta: meta} }) } diff --git a/internal/ui/clusters.go b/internal/ui/clusters.go index afd0244..ce19e73 100644 --- a/internal/ui/clusters.go +++ b/internal/ui/clusters.go @@ -157,7 +157,7 @@ func (m Model) askAboutTheCluster() tea.Cmd { m.onConnection(tea.Batch( fetchAgent(client), fetchToken(client), - fetchList(client.Namespaces, func(items []nomad.Namespace) tea.Msg { return namespacesMsg(items) }), + request(client.Namespaces, func(items []nomad.Namespace) tea.Msg { return namespacesMsg(items) }), fetchRegions(client), fetchDatacenters(client), )), diff --git a/internal/ui/deployment.go b/internal/ui/deployment.go index 3dad619..033e21a 100644 --- a/internal/ui/deployment.go +++ b/internal/ui/deployment.go @@ -51,7 +51,7 @@ func (deploymentsPage) topics() []string { return []string{nomad.TopicDeployment func (deploymentsPage) fetch(e env) tea.Cmd { client, namespace := e.client, e.namespace - return fetchList(func(ctx context.Context) ([]nomad.Deployment, error) { + return request(func(ctx context.Context) ([]nomad.Deployment, error) { return client.Deployments(ctx, namespace) }, func(items []nomad.Deployment) tea.Msg { return deploymentsMsg(items) }) } @@ -287,7 +287,7 @@ func (p deploymentPage) fetch(e env) tea.Cmd { client, namespace, id := e.client, p.namespace, p.deploymentID return tea.Batch( - fetchList(func(ctx context.Context) ([]nomad.Alloc, error) { + request(func(ctx context.Context) ([]nomad.Alloc, error) { return client.DeploymentAllocations(ctx, namespace, id) }, func(items []nomad.Alloc) tea.Msg { return allocsMsg(items) }), fetchDeployment(client, namespace, id), diff --git a/internal/ui/evaluation.go b/internal/ui/evaluation.go index 323c5cf..a39db8a 100644 --- a/internal/ui/evaluation.go +++ b/internal/ui/evaluation.go @@ -38,7 +38,7 @@ func (evaluationsPage) topics() []string { return []string{nomad.TopicEvaluation func (evaluationsPage) fetch(e env) tea.Cmd { client, namespace := e.client, e.namespace - return fetchList(func(ctx context.Context) ([]nomad.Evaluation, error) { + return request(func(ctx context.Context) ([]nomad.Evaluation, error) { return client.Evaluations(ctx, namespace) }, func(items []nomad.Evaluation) tea.Msg { return evaluationsMsg(items) }) } diff --git a/internal/ui/files.go b/internal/ui/files.go index 97cbc26..1a63abf 100644 --- a/internal/ui/files.go +++ b/internal/ui/files.go @@ -54,7 +54,7 @@ func (filesPage) topics() []string { return nil } func (p filesPage) fetch(e env) tea.Cmd { client, namespace, allocID, dir := e.client, p.namespace, p.allocID, p.path - return fetchList(func(ctx context.Context) ([]nomad.File, error) { + return request(func(ctx context.Context) ([]nomad.File, error) { return client.Files(ctx, namespace, allocID, dir) }, func(files []nomad.File) tea.Msg { return filesMsg(files) }) } diff --git a/internal/ui/instances.go b/internal/ui/instances.go index 16047c8..ab95041 100644 --- a/internal/ui/instances.go +++ b/internal/ui/instances.go @@ -52,7 +52,7 @@ func (servicesPage) topics() []string { return []string{nomad.TopicService} } func (servicesPage) fetch(e env) tea.Cmd { client, namespace := e.client, e.namespace - return fetchList(func(ctx context.Context) ([]nomad.Service, error) { + return request(func(ctx context.Context) ([]nomad.Service, error) { return client.Services(ctx, namespace) }, func(items []nomad.Service) tea.Msg { return servicesMsg(items) }) } @@ -142,7 +142,7 @@ func (p serviceInstancesPage) where(env) string { return p.namespace } func (p serviceInstancesPage) fetch(e env) tea.Cmd { client, namespace, service := e.client, p.namespace, p.service - return fetchList(func(ctx context.Context) ([]nomad.ServiceInstance, error) { + return request(func(ctx context.Context) ([]nomad.ServiceInstance, error) { return client.ServiceInstances(ctx, namespace, service) }, func(items []nomad.ServiceInstance) tea.Msg { return instancesMsg(items) }) } diff --git a/internal/ui/jobs.go b/internal/ui/jobs.go index d639bcd..01073c1 100644 --- a/internal/ui/jobs.go +++ b/internal/ui/jobs.go @@ -89,7 +89,7 @@ func (jobsPage) topics() []string { return []string{nomad.TopicJob} } func (p jobsPage) fetch(e env) tea.Cmd { client, namespace, pool := e.client, p.where(e), p.pool - jobs := fetchList(func(ctx context.Context) ([]nomad.Job, error) { + jobs := request(func(ctx context.Context) ([]nomad.Job, error) { if pool != "" { return client.PoolJobs(ctx, pool) } diff --git a/internal/ui/namespaces.go b/internal/ui/namespaces.go index 37daa69..9ca8eec 100644 --- a/internal/ui/namespaces.go +++ b/internal/ui/namespaces.go @@ -21,7 +21,7 @@ func (namespacesPage) topics() []string { return nil } func (p namespacesPage) take(tea.Msg, env) (page, outcome, bool) { return p, outcome{}, false } func (namespacesPage) fetch(e env) tea.Cmd { - return fetchList(e.client.Namespaces, func(items []nomad.Namespace) tea.Msg { return namespacesMsg(items) }) + return request(e.client.Namespaces, func(items []nomad.Namespace) tea.Msg { return namespacesMsg(items) }) } func (namespacesPage) ids(e env) []string { return names(e.namespaces, namespaceMark) } diff --git a/internal/ui/namespacetasks.go b/internal/ui/namespacetasks.go index 5c8bd5d..d2e7d8b 100644 --- a/internal/ui/namespacetasks.go +++ b/internal/ui/namespacetasks.go @@ -34,7 +34,7 @@ func (namespaceTasksPage) topics() []string { return []string{nomad.TopicAllocat func (namespaceTasksPage) fetch(e env) tea.Cmd { client, namespace := e.client, e.namespace - return fetchList(func(ctx context.Context) ([]nomad.Alloc, error) { + return request(func(ctx context.Context) ([]nomad.Alloc, error) { return client.Allocations(ctx, namespace, "") }, func(items []nomad.Alloc) tea.Msg { return allocsMsg(items) }) } diff --git a/internal/ui/node.go b/internal/ui/node.go index 9c719e8..5306296 100644 --- a/internal/ui/node.go +++ b/internal/ui/node.go @@ -56,7 +56,7 @@ func (nodesPage) fewerTopics() []string { return []string{nomad.TopicNode} } func (p nodesPage) fetch(e env) tea.Cmd { client, pool := e.client, p.pool - return fetchList(func(ctx context.Context) ([]nomad.Node, error) { + return request(func(ctx context.Context) ([]nomad.Node, error) { var ( nodes []nomad.Node err error diff --git a/internal/ui/nodepools.go b/internal/ui/nodepools.go index bd2cbe0..d65753f 100644 --- a/internal/ui/nodepools.go +++ b/internal/ui/nodepools.go @@ -25,7 +25,7 @@ func (nodePoolsPage) titles() []string { return nodePoolTitles } func (nodePoolsPage) topics() []string { return []string{nomad.TopicNodePool} } func (nodePoolsPage) fetch(e env) tea.Cmd { - return fetchList(e.client.NodePools, func(items []nomad.NodePool) tea.Msg { return nodePoolsMsg(items) }) + return request(e.client.NodePools, func(items []nomad.NodePool) tea.Msg { return nodePoolsMsg(items) }) } func (p nodePoolsPage) take(msg tea.Msg, _ env) (page, outcome, bool) { diff --git a/internal/ui/plugins.go b/internal/ui/plugins.go index 3c247cc..36c6885 100644 --- a/internal/ui/plugins.go +++ b/internal/ui/plugins.go @@ -36,7 +36,7 @@ func (pluginsPage) titles() []string { return pluginsTitles } func (pluginsPage) topics() []string { return []string{nomad.TopicCSIPlugin} } func (pluginsPage) fetch(e env) tea.Cmd { - return fetchList(e.client.Plugins, func(items []nomad.Plugin) tea.Msg { return pluginsMsg(items) }) + return request(e.client.Plugins, func(items []nomad.Plugin) tea.Msg { return pluginsMsg(items) }) } func (p pluginsPage) take(msg tea.Msg, _ env) (page, outcome, bool) { diff --git a/internal/ui/region.go b/internal/ui/region.go index 18a6cb1..36f7415 100644 --- a/internal/ui/region.go +++ b/internal/ui/region.go @@ -238,13 +238,13 @@ func keep[T any](items []T, pass func(T) bool) []T { } func fetchRegions(client clusterClient) tea.Cmd { - return fetchList(client.Regions, func(names []string) tea.Msg { return regionsMsg(names) }) + return request(client.Regions, func(names []string) tea.Msg { return regionsMsg(names) }) } func fetchDatacenters(client clusterClient) tea.Cmd { region := client.Region() - return fetchList(client.Datacenters, func(names []string) tea.Msg { + return request(client.Datacenters, func(names []string) tea.Msg { return datacentersMsg{region: region, names: names} }) } diff --git a/internal/ui/scaling.go b/internal/ui/scaling.go index 764f2b5..d7ccb2e 100644 --- a/internal/ui/scaling.go +++ b/internal/ui/scaling.go @@ -36,7 +36,7 @@ func (scalingPoliciesPage) topics() []string { return []string{nomad.TopicJob} } func (scalingPoliciesPage) fetch(e env) tea.Cmd { client, namespace := e.client, e.namespace - return fetchList(func(ctx context.Context) ([]nomad.ScalingPolicy, error) { + return request(func(ctx context.Context) ([]nomad.ScalingPolicy, error) { return client.ScalingPolicies(ctx, namespace) }, func(items []nomad.ScalingPolicy) tea.Msg { return scalingPoliciesMsg(items) }) } diff --git a/internal/ui/screen.go b/internal/ui/screen.go index d30aebd..9909f59 100644 --- a/internal/ui/screen.go +++ b/internal/ui/screen.go @@ -374,11 +374,6 @@ func request[T any](load func(ctx context.Context) (T, error), wrap func(T) tea. } } -// fetchList is a request for a list of resources. -func fetchList[T any](load func(ctx context.Context) ([]T, error), wrap func([]T) tea.Msg) tea.Cmd { - return request(load, wrap) -} - // selectedIndex is the resource the cursor is on. The filter shifts the rows, // so the row number is not the number of the resource. func (m Model) selectedIndex() (int, bool) { diff --git a/internal/ui/server.go b/internal/ui/server.go index 6369c17..de84571 100644 --- a/internal/ui/server.go +++ b/internal/ui/server.go @@ -226,7 +226,7 @@ func (serversPage) topics() []string { return nil } func (serversPage) fetch(e env) tea.Cmd { return tea.Batch( - fetchList(e.client.Servers, func(items []nomad.Server) tea.Msg { return serversMsg(items) }), + request(e.client.Servers, func(items []nomad.Server) tea.Msg { return serversMsg(items) }), fetchHealth(e.client), ) } diff --git a/internal/ui/taskgroup.go b/internal/ui/taskgroup.go index 8d80b48..c8bd354 100644 --- a/internal/ui/taskgroup.go +++ b/internal/ui/taskgroup.go @@ -32,7 +32,7 @@ func (taskGroupsPage) topics() []string { return nil } func (p taskGroupsPage) fetch(e env) tea.Cmd { client, namespace, jobID := e.client, p.namespace, p.jobID - return fetchList(func(ctx context.Context) ([]nomad.TaskGroup, error) { + return request(func(ctx context.Context) ([]nomad.TaskGroup, error) { return client.TaskGroups(ctx, namespace, jobID) }, func(items []nomad.TaskGroup) tea.Msg { return taskGroupsMsg(items) }) } diff --git a/internal/ui/variable.go b/internal/ui/variable.go index cb27915..91173d1 100644 --- a/internal/ui/variable.go +++ b/internal/ui/variable.go @@ -44,7 +44,7 @@ func (variablesPage) topics() []string { return []string{nomad.TopicVariable} } func (variablesPage) fetch(e env) tea.Cmd { client, namespace := e.client, e.namespace - return fetchList(func(ctx context.Context) ([]nomad.Variable, error) { + return request(func(ctx context.Context) ([]nomad.Variable, error) { return client.Variables(ctx, namespace) }, func(items []nomad.Variable) tea.Msg { return variablesMsg(items) }) } diff --git a/internal/ui/version.go b/internal/ui/version.go index ca725b7..e564bfc 100644 --- a/internal/ui/version.go +++ b/internal/ui/version.go @@ -35,7 +35,7 @@ func (versionsPage) topics() []string { return nil } func (p versionsPage) fetch(e env) tea.Cmd { client, namespace, jobID := e.client, p.namespace, p.jobID - return fetchList(func(ctx context.Context) ([]nomad.JobVersion, error) { + return request(func(ctx context.Context) ([]nomad.JobVersion, error) { return client.JobVersions(ctx, namespace, jobID) }, func(items []nomad.JobVersion) tea.Msg { return versionsMsg{jobID: jobID, versions: items} diff --git a/internal/ui/volumes.go b/internal/ui/volumes.go index 5291639..2be7c06 100644 --- a/internal/ui/volumes.go +++ b/internal/ui/volumes.go @@ -43,7 +43,7 @@ func (volumesPage) topics() []string { func (volumesPage) fetch(e env) tea.Cmd { client, namespace := e.client, e.namespace - return fetchList(func(ctx context.Context) ([]nomad.Volume, error) { + return request(func(ctx context.Context) ([]nomad.Volume, error) { return client.Volumes(ctx, namespace) }, func(items []nomad.Volume) tea.Msg { return volumesMsg(items) }) } diff --git a/internal/ui/watch_test.go b/internal/ui/watch_test.go index 88cdf92..25d6ce1 100644 --- a/internal/ui/watch_test.go +++ b/internal/ui/watch_test.go @@ -879,7 +879,7 @@ func TestWatch_OnlyTheReadOfTheScreenKeepsItsReadIndex(t *testing.T) { m := newTestModel(client) jobs := func() tea.Cmd { - return fetchList(func(ctx context.Context) ([]nomad.Job, error) { + return request(func(ctx context.Context) ([]nomad.Job, error) { return client.Jobs(ctx, "production") }, func(items []nomad.Job) tea.Msg { return jobsMsg(items) }) } From 6a9f81ef2d0319a9f744786fbd21f414c654c012 Mon Sep 17 00:00:00 2001 From: Igor Churmeev Date: Mon, 9 Nov 2026 22:41:36 +0000 Subject: [PATCH 5/7] refactor(ui): judge the faults of allocations at the time of the model --- internal/ui/alloc.go | 4 ++-- internal/ui/allocfaults_test.go | 12 ++++++++++++ internal/ui/clock_test.go | 13 ++++++------- 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/internal/ui/alloc.go b/internal/ui/alloc.go index a35e892..d13ae44 100644 --- a/internal/ui/alloc.go +++ b/internal/ui/alloc.go @@ -79,13 +79,13 @@ func (p allocationsPage) fetch(e env) tea.Cmd { }, func(items []nomad.Alloc) tea.Msg { return allocsMsg(items) }) } -func (p allocationsPage) take(msg tea.Msg, _ env) (page, outcome, bool) { +func (p allocationsPage) take(msg tea.Msg, e env) (page, outcome, bool) { allocs, ok := msg.(allocsMsg) if !ok { return p, outcome{}, false } - p.allocs, p.read = allocs, time.Now() + p.allocs, p.read = allocs, e.now return p, outcome{}, true } diff --git a/internal/ui/allocfaults_test.go b/internal/ui/allocfaults_test.go index e7756a8..10329fc 100644 --- a/internal/ui/allocfaults_test.go +++ b/internal/ui/allocfaults_test.go @@ -56,6 +56,18 @@ func TestAllocFaults_RestartingWithinTheHour(t *testing.T) { r.Equal([]string{"recent"}, page.ids(env{})) } +func TestAllocFaults_JudgedAtTheTimeOfTheModelWhenRead(t *testing.T) { + r := require.New(t) + + then := fixedTime() + restarted := nomad.Task{Name: "server", Restarts: 1, LastRestart: then.Add(-10 * time.Minute)} + allocs := allocsMsg{allocFaultsAlloc("recent", statusRunning, restarted)} + + page, _, ok := allocationsPage{fault: faultRestarting}.take(allocs, env{now: then}) + r.True(ok) + r.Equal([]string{"recent"}, page.(allocationsPage).ids(env{})) +} + func TestAllocFaults_KilledForMemoryWithinTheHour(t *testing.T) { r := require.New(t) diff --git a/internal/ui/clock_test.go b/internal/ui/clock_test.go index e99600e..eab6978 100644 --- a/internal/ui/clock_test.go +++ b/internal/ui/clock_test.go @@ -76,13 +76,12 @@ func TestRender_APageCountsFromTheTimeOfTheModel(t *testing.T) { // time is part of what changes. Everything else counts from the model. var clockReaders = map[string]bool{ // update and what it calls when a message comes in - "Model.update": true, - "Model.flashed": true, - "allocationsPage.take": true, - "logState.take": true, - "jobLogsPage.lines": true, - "jobLogsPage.chunk": true, - "saveName": true, + "Model.update": true, + "Model.flashed": true, + "logState.take": true, + "jobLogsPage.lines": true, + "jobLogsPage.chunk": true, + "saveName": true, // commands "readPart": true, From 0416d2e2e6aa9368c73f96ee6ad7a6f344f89343 Mon Sep 17 00:00:00 2001 From: Igor Churmeev Date: Mon, 9 Nov 2026 22:42:06 +0000 Subject: [PATCH 6/7] refactor(nomad): put the comment of unixTime above unixTime --- internal/nomad/client.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/nomad/client.go b/internal/nomad/client.go index 131c46e..89aaea8 100644 --- a/internal/nomad/client.go +++ b/internal/nomad/client.go @@ -326,8 +326,6 @@ func newJob(stub *api.JobListStub) Job { return job } -// unixTime reads a Nomad timestamp. Nomad says "never" with a zero, which is -// not 1970: a job with no submit time would read as twenty thousand days old. // valueOf is what a field the cluster may leave out holds: the zero value // when it is left out. func valueOf[T any](value *T) T { @@ -340,6 +338,8 @@ func valueOf[T any](value *T) T { return *value } +// unixTime reads a Nomad timestamp. Nomad says "never" with a zero, which is +// not 1970: a job with no submit time would read as twenty thousand days old. func unixTime(nanos int64) time.Time { if nanos == 0 { return time.Time{} From 0f94e4088c264c0205de102eae246fddab3a0966 Mon Sep 17 00:00:00 2001 From: Igor Churmeev Date: Mon, 9 Nov 2026 22:44:32 +0000 Subject: [PATCH 7/7] refactor(ui): read the queues of the node pools in one place --- internal/ui/app_test.go | 2 + internal/ui/jobs.go | 42 +++++++++------------ internal/ui/queue.go | 79 +++++++++++++++++++++++++-------------- internal/ui/queue_test.go | 20 ++++++++++ 4 files changed, 90 insertions(+), 53 deletions(-) diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go index 1388af8..9809277 100644 --- a/internal/ui/app_test.go +++ b/internal/ui/app_test.go @@ -52,6 +52,7 @@ type fakeClient struct { queueStatus map[string][]nomad.QueueTenant queueErr error queueReads int + queuePools []string queueStatusErr error poolsErr error servers []nomad.Server @@ -1342,6 +1343,7 @@ func (f *fakeClient) PoolNodes(_ context.Context, pool string) ([]nomad.Node, er func (f *fakeClient) BatchQueueJobs(ctx context.Context, pool string) ([]nomad.QueueWorkload, error) { f.queueReads++ + f.queuePools = append(f.queuePools, pool) err := f.err if f.queueErr != nil { diff --git a/internal/ui/jobs.go b/internal/ui/jobs.go index 01073c1..1eea734 100644 --- a/internal/ui/jobs.go +++ b/internal/ui/jobs.go @@ -319,38 +319,32 @@ func fetchBatchQueue(client Client, pool string) tea.Cmd { } // batchQueue reads the queue of the pool, or of every pool that has one when -// no pool is given. It stops at the first pool that fails. +// no pool is given. A pool that fails fails the whole read. func batchQueue(ctx context.Context, client Client, pool string) (map[string]nomad.QueueWorkload, error) { - names := []string{pool} - - if pool == "" { - pools, err := client.NodePools(ctx) - if err != nil { - return nil, err - } - - names = nil - for _, p := range pools { - if p.Queue != "" { - names = append(names, p.Name) - } - } + items, err := queuedIn(ctx, client, pool) + if err != nil { + return nil, err } workloads := map[string]nomad.QueueWorkload{} + for _, w := range items { + markWorkload(workloads, w) + } - for _, name := range names { - list, err := client.BatchQueueJobs(ctx, name) - if err != nil { - return nil, err - } + return workloads, nil +} - for _, w := range list { - markWorkload(workloads, w) - } +func queuedIn(ctx context.Context, client Client, pool string) ([]nomad.QueueWorkload, error) { + if pool != "" { + return client.BatchQueueJobs(ctx, pool) } - return workloads, nil + q, err := readPoolQueues(ctx, client) + if err != nil { + return nil, err + } + + return q.items, q.failed } func markWorkload(m map[string]nomad.QueueWorkload, w nomad.QueueWorkload) { diff --git a/internal/ui/queue.go b/internal/ui/queue.go index f6b59d5..f637b98 100644 --- a/internal/ui/queue.go +++ b/internal/ui/queue.go @@ -266,6 +266,50 @@ func fetchPoolStatus(client Client, pool string) tea.Cmd { } } +// poolQueues is what the queues of the node pools hold. +type poolQueues struct { + items []nomad.QueueWorkload + + // none says no node pool has a queue. + none bool + + // failed is the error of the first pool whose queue could not be read. + // Its workloads are left out of items. + failed error +} + +// readPoolQueues reads the queue of every node pool that has one. A cluster +// without queues ends the read with nomad.ErrQueueUnsupported. +func readPoolQueues(ctx context.Context, client Client) (poolQueues, error) { + pools, err := client.NodePools(ctx) + if err != nil { + return poolQueues{}, err + } + + q := poolQueues{none: true} + + for _, p := range pools { + if p.Queue == "" { + continue + } + q.none = false + + items, err := client.BatchQueueJobs(ctx, p.Name) + switch { + case errors.Is(err, nomad.ErrQueueUnsupported): + return poolQueues{}, err + case err != nil: + if q.failed == nil { + q.failed = err + } + default: + q.items = append(q.items, items...) + } + } + + return q, nil +} + func fetchAllQueues(client Client) tea.Cmd { return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout) @@ -273,7 +317,7 @@ func fetchAllQueues(client Client) tea.Cmd { ctx, reads := nomad.WithReads(ctx) - pools, err := client.NodePools(ctx) + q, err := readPoolQueues(ctx, client) if err != nil { if errors.Is(err, nomad.ErrQueueUnsupported) { return queueUnsupportedMsg{} @@ -282,44 +326,21 @@ func fetchAllQueues(client Client) tea.Cmd { return errMsg{err: err} } - var all []nomad.QueueWorkload - hasQueue, failed := false, false - - for _, p := range pools { - if p.Queue == "" { - continue - } - hasQueue = true - - items, err := client.BatchQueueJobs(ctx, p.Name) - if err != nil { - if errors.Is(err, nomad.ErrQueueUnsupported) { - return queueUnsupportedMsg{} - } - - failed = true - - continue - } - - all = append(all, items...) - } - - if !hasQueue { + if q.none { if _, err := client.BatchQueueJobs(ctx, "default"); errors.Is(err, nomad.ErrQueueUnsupported) { return queueUnsupportedMsg{} } } - sortQueueWorkloads(all) + sortQueueWorkloads(q.items) // The page shows nothing of a pool that failed, so it has no read // index of the jobs. - if failed { - return queueItemsMsg{items: all} + if q.failed != nil { + return queueItemsMsg{items: q.items} } - return withReads(queueItemsMsg{items: all}, reads) + return withReads(queueItemsMsg{items: q.items}, reads) } } diff --git a/internal/ui/queue_test.go b/internal/ui/queue_test.go index 839b2ad..855de9a 100644 --- a/internal/ui/queue_test.go +++ b/internal/ui/queue_test.go @@ -196,6 +196,26 @@ func TestQueue_UnsupportedOnOlderClusters(t *testing.T) { r.Contains(out, "The batch job queue needs Nomad 2.1.") } +// The pool "default" is asked only to tell a cluster without queues from one +// where no pool has a queue. +func TestQueue_AsksTheDefaultPoolOnlyWhenNoPoolHasAQueue(t *testing.T) { + for name, tc := range map[string]struct { + pools []nomad.NodePool + asked []string + }{ + "a pool with a queue": {[]nomad.NodePool{{Name: "batch", Queue: nomad.QueueFifo}, {Name: "default"}}, []string{"batch"}}, + "no pool with a queue": {[]nomad.NodePool{{Name: "default"}}, []string{"default"}}, + } { + t.Run(name, func(t *testing.T) { + client := &fakeClient{nodePools: tc.pools} + + fetchAllQueues(client)() + + require.Equal(t, tc.asked, client.queuePools) + }) + } +} + func TestQueue_EnterOpensJobAllocations(t *testing.T) { r := require.New(t)