From 9862ef0b2b4a6cb4897c407a3652cc4947f0b945 Mon Sep 17 00:00:00 2001 From: Igor Churmeev Date: Tue, 20 Oct 2026 05:18:03 +0000 Subject: [PATCH 1/4] feat(ui): show the meta of a job as keys and values with m --- docs/jobs.md | 2 + docs/keys.md | 1 + internal/nomad/describe.go | 10 ++++ internal/nomad/describe_test.go | 23 +++++++++ internal/ui/app.go | 7 +++ internal/ui/app_test.go | 7 +++ internal/ui/hints_test.go | 17 +++++-- internal/ui/jobmeta.go | 81 +++++++++++++++++++++++++++++ internal/ui/jobmeta_test.go | 90 +++++++++++++++++++++++++++++++++ internal/ui/jobs.go | 1 + internal/ui/jobs_test.go | 1 + internal/ui/roles.go | 1 + internal/ui/writes_test.go | 2 +- 13 files changed, 238 insertions(+), 5 deletions(-) create mode 100644 internal/ui/jobmeta.go create mode 100644 internal/ui/jobmeta_test.go diff --git a/docs/jobs.md b/docs/jobs.md index 5d2950a..12c740d 100644 --- a/docs/jobs.md +++ b/docs/jobs.md @@ -19,6 +19,8 @@ From the list: - `t` opens its task groups; - `d` shows the job as the cluster describes it; - `h` shows the job file it was submitted with; +- `m` shows its meta, a key and its value on each row; `c` copies the value + under the cursor. A job without meta says so; - `v` opens its versions; - `o` shows the events of the job. diff --git a/docs/keys.md b/docs/keys.md index 06ac21c..e3ad86d 100644 --- a/docs/keys.md +++ b/docs/keys.md @@ -219,6 +219,7 @@ start, stop, restart or drain then applies to every marked row. | `t` | Task groups | | | `d` | Describe | | | `h` | Job file the job was submitted with | | +| `m` | Meta of the job; `c` copies a value | | | `v` | Versions | | | `l` | Logs of a task in every allocation of the job. Not shown for a periodic or parameterized job. | | | `w` | Watch the deploy of the job at the version it runs. Not shown for a periodic or parameterized job. | | diff --git a/internal/nomad/describe.go b/internal/nomad/describe.go index 6c317dd..5bf3a08 100644 --- a/internal/nomad/describe.go +++ b/internal/nomad/describe.go @@ -24,6 +24,16 @@ func (c *Client) DescribeJob(ctx context.Context, namespace, jobID string) (stri return asJSON(job) } +// JobMeta is the meta of the job block, which is nil when the job has none. +func (c *Client) JobMeta(ctx context.Context, namespace, jobID string) (map[string]string, error) { + job, _, err := c.api.Jobs().Info(jobID, c.query(ctx, namespace)) + if err != nil { + return nil, err + } + + return job.Meta, nil +} + // DescribeAllocation is what the cluster knows about an allocation. func (c *Client) DescribeAllocation(ctx context.Context, namespace, allocID string) (string, error) { alloc, _, err := c.api.Allocations().Info(allocID, c.query(ctx, namespace)) diff --git a/internal/nomad/describe_test.go b/internal/nomad/describe_test.go index c4dcea3..7f4465b 100644 --- a/internal/nomad/describe_test.go +++ b/internal/nomad/describe_test.go @@ -27,6 +27,29 @@ func TestDescribeJob(t *testing.T) { r.Contains(out, "\n") } +func TestJobMeta(t *testing.T) { + r := require.New(t) + + client, asked := recorder(t, `{"ID": "web", "Meta": {"owner": "team-web", "version": "3"}}`) + + meta, err := client.JobMeta(context.Background(), "production", "web") + r.NoError(err) + + r.Equal("/v1/job/web", asked.URL.Path) + r.Equal("production", asked.URL.Query().Get("namespace")) + r.Equal(map[string]string{"owner": "team-web", "version": "3"}, meta) +} + +func TestJobMeta_AJobWithoutMetaGivesNone(t *testing.T) { + r := require.New(t) + + client, _ := recorder(t, `{"ID": "web"}`) + + meta, err := client.JobMeta(context.Background(), "production", "web") + r.NoError(err) + r.Empty(meta) +} + func TestDescribeAllocation(t *testing.T) { r := require.New(t) diff --git a/internal/ui/app.go b/internal/ui/app.go index a4063fc..da344fe 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -125,6 +125,13 @@ type ( meta []nomad.MetaEntry } + // jobMetaMsg carries the job it was requested for, like every answer + // about one job. + jobMetaMsg struct { + namespace, jobID string + meta map[string]string + } + // raftMsg holds the raft peers of the cluster. An ACL may deny the // request, and then the error is shown where the peers would have // been. diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go index e35a372..6889802 100644 --- a/internal/ui/app_test.go +++ b/internal/ui/app_test.go @@ -24,6 +24,7 @@ type fakeClient struct { nodeAllocs []nomad.Alloc nodeDetail nomad.NodeDetail nodeMeta []nomad.MetaEntry + jobMeta map[string]string versions []nomad.JobVersion diff []nomad.DiffLine groups []nomad.TaskGroup @@ -536,6 +537,12 @@ func (f *fakeClient) DescribeJob(_ context.Context, namespace, jobID string) (st return f.describe, f.err } +func (f *fakeClient) JobMeta(_ context.Context, namespace, jobID string) (map[string]string, error) { + f.askedNamespace, f.askedID = namespace, jobID + + return f.jobMeta, f.err +} + func (f *fakeClient) DescribeAllocation(_ context.Context, namespace, allocID string) (string, error) { f.askedNamespace, f.askedID = namespace, allocID diff --git a/internal/ui/hints_test.go b/internal/ui/hints_test.go index 4d3db48..584f6f2 100644 --- a/internal/ui/hints_test.go +++ b/internal/ui/hints_test.go @@ -49,6 +49,14 @@ func runningAtVersionThree() []nomad.DeploymentDetail { }} } +// apiInstances are the instances of api, in the namespace of web. +func apiInstances() []nomad.ServiceInstance { + return []nomad.ServiceInstance{ + {ID: "reg-1", Service: "api", Namespace: "production", JobID: "web", AllocID: "alloc-lost", Address: "10.0.0.7", Port: 80, AllocStatus: "lost"}, + {ID: "reg-2", Service: "api", Namespace: "production", JobID: "web", AllocID: "alloc-1", Address: "10.0.0.8", Port: 80, AllocStatus: "running"}, + } +} + // everyScreenClient is a cluster with something on every screen. func everyScreenClient() *fakeClient { return &fakeClient{ @@ -63,6 +71,7 @@ func everyScreenClient() *fakeClient { nodeAllocs: clientAllocs(), nodeDetail: clientDetail(), nodeMeta: clientMeta(), + jobMeta: map[string]string{"owner": "team-web"}, identity: &nomad.NodeIdentity{Expires: time.Now().Add(time.Hour), Claims: "{}"}, versions: threeVersions(), variables: []nomad.Variable{{Path: "nomad/jobs/web", Namespace: "production"}}, @@ -91,10 +100,7 @@ func everyScreenClient() *fakeClient { file: &nomad.LogStream{Lines: make(chan string)}, // A registration left behind first: there is one to delete. - instances: []nomad.ServiceInstance{ - {ID: "reg-1", Service: "api", Namespace: "production", JobID: "web", AllocID: "alloc-lost", Address: "10.0.0.7", Port: 80, AllocStatus: "lost"}, - {ID: "reg-2", Service: "api", Namespace: "production", JobID: "web", AllocID: "alloc-1", Address: "10.0.0.8", Port: 80, AllocStatus: "running"}, - }, + instances: apiInstances(), variable: nomad.VariableDetail{ Variable: nomad.Variable{Path: "nomad/jobs/web", Namespace: "production"}, @@ -189,6 +195,9 @@ func openOfAJob(open map[string]Model) { groups, _ = groups.update(taskGroupsMsg(twoGroups())) open["taskgroups"] = groups + meta, cmd := jobs.update(key('m')) + open["jobmeta"] = drain(meta, cmd) + described, cmd := jobs.update(key('d')) open["describe"] = drain(described, cmd) diff --git a/internal/ui/jobmeta.go b/internal/ui/jobmeta.go new file mode 100644 index 0000000..89021c9 --- /dev/null +++ b/internal/ui/jobmeta.go @@ -0,0 +1,81 @@ +package ui + +import ( + "context" + + tea "charm.land/bubbletea/v2" + + "github.com/ingvarch/urga/internal/nomad" +) + +// jobMetaPage is the meta of a job: a key and its value on each row. +type jobMetaPage struct { + namespace, jobID string + + meta map[string]string + read bool +} + +func (p jobMetaPage) title(_ env, count int) string { + return sprintf("Meta (Job: %s) [%d]", p.jobID, count) +} + +func (jobMetaPage) titles() []string { return fieldTitles } +func (jobMetaPage) topics() []string { return []string{nomad.TopicJob} } + +// where is the namespace of the job, which is where its stream watches. +func (p jobMetaPage) where(env) string { return p.namespace } + +func (p jobMetaPage) fetch(e env) tea.Cmd { + client, namespace, jobID := e.client, p.namespace, p.jobID + + return request(func(ctx context.Context) (map[string]string, error) { + return client.JobMeta(ctx, namespace, jobID) + }, func(meta map[string]string) tea.Msg { + return jobMetaMsg{namespace: namespace, jobID: jobID, meta: meta} + }) +} + +// take keeps the meta of the job the page is open on. +func (p jobMetaPage) take(msg tea.Msg, _ env) (page, outcome, bool) { + meta, ok := msg.(jobMetaMsg) + if !ok || meta.jobID != p.jobID || meta.namespace != p.namespace { + return p, outcome{}, false + } + + p.meta, p.read = meta.meta, true + + return p, outcome{}, true +} + +func (p jobMetaPage) rows(env) []tableRow { return fieldRows(p.meta) } + +// panel says that the job has no meta, once the cluster has answered. +func (p jobMetaPage) panel(_ env, width, room int) []string { + if !p.read || len(p.meta) > 0 { + return nil + } + + head := []string{panelRow(styleMuted.Render(truncate(sprintf("The job %s has no meta.", p.jobID), width-tableIndent)))} + + return fitPanel(head, panelBlock{}, room) +} + +var jobMetaKeys = []pageKey[jobMetaPage]{copyKey[jobMetaPage]()} + +func (p jobMetaPage) keys(e env) []keyHint { return hintsOf(p, e, jobMetaKeys) } + +func (p jobMetaPage) press(k string, e env) (page, outcome, bool) { + return pressOf(p, e, jobMetaKeys, k) +} + +// openJobMeta opens the meta of the job under the cursor, in the namespace +// of the job. +func openJobMeta(p jobsPage, e env) (jobsPage, outcome) { + job, ok := p.picked(e) + if !ok { + return p, outcome{} + } + + return p, then(openMsg{jobMetaPage{namespace: job.Namespace, jobID: job.ID}}) +} diff --git a/internal/ui/jobmeta_test.go b/internal/ui/jobmeta_test.go new file mode 100644 index 0000000..662bf66 --- /dev/null +++ b/internal/ui/jobmeta_test.go @@ -0,0 +1,90 @@ +package ui + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/ingvarch/urga/internal/nomad" +) + +func onJobMeta(t *testing.T, client *fakeClient) Model { + t.Helper() + + jobs := twoJobs() + jobs[0].Namespace = "staging" + client.jobs = jobs + + m := newTestModel(client) + m, _ = m.update(jobsMsg(jobs)) + + m, cmd := m.update(key('m')) + + return drain(m, cmd) +} + +func TestJobMeta_ShowsTheKeysAndValuesOfTheJob(t *testing.T) { + r := require.New(t) + + client := &fakeClient{jobMeta: map[string]string{"version": "3", "owner": "team-web"}} + m := onJobMeta(t, client) + + r.IsType(jobMetaPage{}, m.screen.page) + + out := plain(m.render()) + r.Contains(out, "Meta (Job: web) [2]") + r.Less(strings.Index(out, "owner"), strings.Index(out, "version")) + r.Contains(out, "team-web") + r.NotContains(out, "has no meta") + + // The job's own namespace, not the one the session has open. + r.Equal("staging", client.askedNamespace) + r.Equal("web", client.askedID) + r.Equal("staging", client.watchedNamespace) + r.Equal([]string{nomad.TopicJob}, client.watchedTopics) +} + +func TestJobMeta_CopiesTheValueUnderTheCursor(t *testing.T) { + r := require.New(t) + + m := onJobMeta(t, &fakeClient{jobMeta: map[string]string{"owner": "team-web"}}) + + _, cmd := m.update(key('c')) + r.Contains(clipboardOf(cmd), "team-web") +} + +func TestJobMeta_SaysWhenTheJobHasNone(t *testing.T) { + r := require.New(t) + + client := &fakeClient{jobs: twoJobs()} + m := newTestModel(client) + m, _ = m.update(jobsMsg(twoJobs())) + + m, cmd := m.update(key('m')) + r.NotContains(plain(m.render()), "has no meta") + + m = drain(m, cmd) + r.Contains(plain(m.render()), "The job web has no meta.") +} + +func TestJobMeta_DropsTheAnswerOfAnotherJob(t *testing.T) { + r := require.New(t) + + m := onJobMeta(t, &fakeClient{jobMeta: map[string]string{"owner": "team-web"}}) + + m, _ = m.update(jobMetaMsg{namespace: "staging", jobID: "cron", meta: map[string]string{"owner": "somebody else"}}) + r.NotContains(plain(m.render()), "somebody else") + + m, _ = m.update(jobMetaMsg{namespace: "production", jobID: "web", meta: map[string]string{"owner": "another namespace"}}) + r.NotContains(plain(m.render()), "another namespace") +} + +func TestJobMeta_EscGoesBackToTheJobs(t *testing.T) { + r := require.New(t) + + m := onJobMeta(t, &fakeClient{}) + + m, _ = m.update(escape()) + r.IsType(jobsPage{}, m.screen.page) +} diff --git a/internal/ui/jobs.go b/internal/ui/jobs.go index ff13aac..7bcc3a4 100644 --- a/internal/ui/jobs.go +++ b/internal/ui/jobs.go @@ -170,6 +170,7 @@ var jobsKeys = []pageKey[jobsPage]{ {press: "t", label: "Task Groups", do: openJobGroups}, {press: "d", label: "Describe", do: describeJob}, {press: "h", label: "Job Spec", do: showJobSpec}, + {press: "m", label: "Meta", do: openJobMeta}, {press: "ctrl+s", label: "Start/Stop", do: startStopJob, writes: true}, {press: "ctrl+p", label: "Purge", do: purgeJobs, writes: true, offered: stopped}, {press: "r", label: "Run Now", do: runNow, writes: true, offered: runsOnSchedule}, diff --git a/internal/ui/jobs_test.go b/internal/ui/jobs_test.go index 6b9978f..aad60d4 100644 --- a/internal/ui/jobs_test.go +++ b/internal/ui/jobs_test.go @@ -83,6 +83,7 @@ func TestJobs_TheKeysOfAJob(t *testing.T) { {Key: "", Description: "Task Groups"}, {Key: "", Description: "Describe"}, {Key: "", Description: "Job Spec"}, + {Key: "", Description: "Meta"}, {Key: "", Description: "Start/Stop"}, {Key: "", Description: "Evaluate"}, {Key: "", Description: "Reschedule Failed"}, diff --git a/internal/ui/roles.go b/internal/ui/roles.go index fae8b87..69a0a07 100644 --- a/internal/ui/roles.go +++ b/internal/ui/roles.go @@ -56,6 +56,7 @@ type ( Jobs(ctx context.Context, namespace string) ([]nomad.Job, error) DescribeJob(ctx context.Context, namespace, jobID string) (string, error) JobSpec(ctx context.Context, namespace, jobID string) (nomad.JobSource, error) + JobMeta(ctx context.Context, namespace, jobID string) (map[string]string, error) TaskGroups(ctx context.Context, namespace, jobID string) ([]nomad.TaskGroup, error) PlanJob(ctx context.Context, namespace, source string, vars nomad.JobVariables, opened map[string]int) (nomad.Plan, error) JobCounts(ctx context.Context, namespace, source string, vars nomad.JobVariables) (map[string]int, error) diff --git a/internal/ui/writes_test.go b/internal/ui/writes_test.go index b86b319..4ac9122 100644 --- a/internal/ui/writes_test.go +++ b/internal/ui/writes_test.go @@ -32,7 +32,7 @@ var ( clientReads = []string{ "Address", "Region", "Agent", "Regions", "Datacenters", "AllocationChecks", "Files", "File", "Deployment", "JobDeployments", "DeploymentAllocations", "Token", "ServiceInstances", - "Jobs", "NextLaunch", "JobSpec", "JobVersions", "JobVersionDiff", "TaskGroups", "PlanJob", "PlanRevert", "JobTarget", "JobCounts", + "Jobs", "NextLaunch", "JobSpec", "JobMeta", "JobVersions", "JobVersionDiff", "TaskGroups", "PlanJob", "PlanRevert", "JobTarget", "JobCounts", "DescribeJob", "DescribeAllocation", "DescribeDeployment", "DescribeService", "Allocations", "NodeAllocations", "Allocation", "Logs", "LogBefore", "Usage", "AllocationUsage", "TaskUsage", "NodeUsage", From 19702a5fd428428eef7149ce5246877aac4d1f62 Mon Sep 17 00:00:00 2001 From: Igor Churmeev Date: Tue, 20 Oct 2026 05:44:58 +0000 Subject: [PATCH 2/4] feat(ui): list every task of the namespace with :tasks, and show when a task finished and how long it ran --- README.md | 11 +- docs/allocations.md | 27 ++- docs/keys.md | 3 + internal/ui/command_test.go | 4 +- internal/ui/eventsof_test.go | 6 +- internal/ui/files.go | 6 +- internal/ui/hints_test.go | 2 + internal/ui/logs.go | 10 +- internal/ui/mark.go | 2 + internal/ui/namespacetasks.go | 111 +++++++++++++ internal/ui/namespacetasks_test.go | 255 +++++++++++++++++++++++++++++ internal/ui/shell.go | 10 +- internal/ui/sort.go | 11 +- internal/ui/sort_test.go | 68 ++++++-- internal/ui/table.go | 56 ++++++- internal/ui/table_test.go | 18 +- internal/ui/taskactions.go | 67 ++++++-- internal/ui/tasks.go | 83 +++++++--- internal/ui/tasks_test.go | 43 +++++ internal/ui/usage_test.go | 2 +- internal/ui/views.go | 7 +- 21 files changed, 713 insertions(+), 89 deletions(-) create mode 100644 internal/ui/namespacetasks.go create mode 100644 internal/ui/namespacetasks_test.go diff --git a/README.md b/README.md index b14098a..ba9c8f9 100644 --- a/README.md +++ b/README.md @@ -23,9 +23,10 @@ One binary, no config to write, no browser. ## What it does -- Lists jobs, allocations, tasks, task groups, deployments, evaluations, - services, namespaces, variables, clients, node pools, servers, volumes, - CSI plugins and the ACL objects. +- Lists jobs, allocations, the tasks of an allocation or of the whole + namespace, task groups, deployments, evaluations, services, namespaces, + variables, clients, node pools, servers, volumes, CSI plugins and the ACL + objects. - Watches the deploy of a job with `w`: the newest evaluation, the deployment and its allocations, until it ends. - Updates the screen when the cluster changes, using the Nomad event stream, @@ -129,8 +130,8 @@ quit. keys of every screen. - [Jobs](docs/jobs.md): jobs, task groups and their scaling, editing and plans, versions, evaluations and deployments. -- [Allocations and tasks](docs/allocations.md): allocations, checks, task - actions, logs and files. +- [Allocations and tasks](docs/allocations.md): allocations, every task of + the namespace with `:tasks`, checks, task actions, logs and files. - [Clients, servers and other resources](docs/clients.md): clients, servers, the scheduler, namespaces, services, variables and node pools. - [Storage](docs/storage.md): CSI volumes, dynamic host volumes and CSI diff --git a/docs/allocations.md b/docs/allocations.md index 4abf8d5..4509e57 100644 --- a/docs/allocations.md +++ b/docs/allocations.md @@ -1,7 +1,8 @@ # Allocations and tasks -This page covers allocations, the tasks of an allocation, checks, logs and -files. For the keys of each screen, see [Keys and commands](keys.md). +This page covers allocations, the tasks of an allocation, every task of the +namespace with `:tasks`, checks, logs and files. For the keys of each screen, +see [Keys and commands](keys.md). ## Allocation list @@ -59,6 +60,10 @@ For each running task, the list shows CPU and memory use as a share of what the task asked for, updated every 5 seconds. Memory is read the same way as in the allocation list. +The list also shows Started (how long ago the task last started), Finished +(how long ago it ended, for a task that has ended) and Uptime (how long it has +run since it started, or how long it ran once it ended). + From here: - `c` opens the client the allocation runs on; @@ -66,6 +71,24 @@ From here: - `f` opens the follow-up evaluation; - `o` opens the events of the allocation. +## Tasks of the namespace + +`:tasks` lists every task of every allocation of the namespace on one screen, +to find the one that is dead, pending or restarting without opening each job +and each allocation. The columns are Job, Namespace, Group, Task, Alloc, Node, +State, Restarts, Started, Finished and Uptime. The rows have the colors of the +tasks screen: failed tasks in red, pending ones in orange, dead ones grey. A +capital letter sorts by a column: `R` by restarts, `U` by uptime. + +The keys of a task work on the row: `enter` and `ctrl-e` for the logs, `e` for +the events, `o` for the events of its allocation, `b` for the files, `s` for a +shell, `r` to restart and `x` to send a signal. They act on the task in its +own allocation and namespace, also when the session looks at every namespace. + +The list is read from the allocation list in one request, so there is no CPU +and memory column: they would cost a request for each task. It holds the +tasks of allocations that ended until the cluster forgets them. + ## Checks The panel lists the checks of the allocation while it is running: diff --git a/docs/keys.md b/docs/keys.md index e3ad86d..fc199e2 100644 --- a/docs/keys.md +++ b/docs/keys.md @@ -16,6 +16,7 @@ Press `:` to open the command line, type a command and press `enter`. | --- | --- | --- | | `jobs` | `job`, `jb` | Jobs | | `allocations` | `allocs`, `alloc` | Allocations of the namespace | +| `tasks` | `task` | Every task of the namespace. See [Tasks of the namespace](allocations.md#tasks-of-the-namespace). | | `deployments` | `dp` | Deployments | | `evaluations` | `evals`, `eval`, `ev` | Evaluations | | `events` | `event` | What happens in the cluster, event by event. See [Events](#events). | @@ -312,6 +313,8 @@ On what was done to the count of a group, `d` describes its policy. `p`, `n` and `f` are shown only when there is such an allocation or evaluation. +The list of `:tasks` has these keys too, except `c`, `p`, `n` and `f`. + ### Logs | Key | What it does | diff --git a/internal/ui/command_test.go b/internal/ui/command_test.go index 769310e..a408ec4 100644 --- a/internal/ui/command_test.go +++ b/internal/ui/command_test.go @@ -26,6 +26,8 @@ func TestParseCommand(t *testing.T) { {input: "vars", view: variablesView, ok: true}, {input: "np", view: nodePoolsView, ok: true}, {input: "alloc", view: allocationsView, ok: true}, + {input: "tasks", view: tasksView, ok: true}, + {input: "ta", view: tasksView, ok: true}, // A namespace as the second word opens the resource there. {input: "jobs production", view: jobsView, namespace: "production", ok: true}, @@ -77,7 +79,7 @@ func TestMatchingCommands(t *testing.T) { r.Equal([]string{ "about", "allocations", "authmethods", "bindingrules", "clients", "deployments", "evaluations", "events", "jobs", "namespaces", "nodepools", "overview", "plugins", "policies", "roles", - "scaling", "scheduler", "servers", "services", "tokens", "variables", "volumes", "ctx", "dc", "region", "find", "gc", "run", + "scaling", "scheduler", "servers", "services", "tasks", "tokens", "variables", "volumes", "ctx", "dc", "region", "find", "gc", "run", }, matchingCommands("")) // A word that is an alias of its own comes first, whatever other names diff --git a/internal/ui/eventsof_test.go b/internal/ui/eventsof_test.go index 76beb3c..7794d24 100644 --- a/internal/ui/eventsof_test.go +++ b/internal/ui/eventsof_test.go @@ -8,14 +8,14 @@ import ( "github.com/ingvarch/urga/internal/nomad" ) -const mlAlloc = "af1f37df-7b19-6b1c-da67-5e8f482b5a15" +const frontendAlloc = "af1f37df-7b19-6b1c-da67-5e8f482b5a15" // inML is a job and an allocation of the namespace ml, seen from a session // that looks at every namespace. func inML() ([]nomad.Job, []nomad.Alloc) { jobs := []nomad.Job{{ID: "web", Name: "web", Namespace: "ml", Type: "service", Status: "running", Running: 1, Desired: 1}} allocs := []nomad.Alloc{{ - ID: mlAlloc, Namespace: "ml", JobID: "web", TaskGroup: "frontend", Status: "running", + ID: frontendAlloc, Namespace: "ml", JobID: "web", TaskGroup: "frontend", Status: "running", Tasks: []nomad.Task{{Name: "server", State: "running"}}, }} @@ -90,7 +90,7 @@ func TestEventsOf_AnAllocationOfEveryList(t *testing.T) { r.Equal("Events (Allocation: af1f37df) [2]", m.title(), name) r.Equal("ml", client.feedNamespace, name) - r.Equal(nomad.Subject{AllocID: mlAlloc}, client.feedSubject, name) + r.Equal(nomad.Subject{AllocID: frontendAlloc}, client.feedSubject, name) } } diff --git a/internal/ui/files.go b/internal/ui/files.go index fc0a115..77dead6 100644 --- a/internal/ui/files.go +++ b/internal/ui/files.go @@ -33,13 +33,13 @@ type filesPage struct { // browse opens the directory of the task under the cursor: what its // templates rendered is in local/ there. -func browse(p tasksPage, e env) (tasksPage, outcome) { - task, ok := p.picked(e) +func browse[P taskLister](p P, e env) (P, outcome) { + task, ok := p.pickedTask(e) if !ok { return p, outcome{} } - return p, then(openMsg{filesPage{namespace: p.namespace, allocID: p.allocID, path: path.Join("/", task.Name)}}) + return p, then(openMsg{filesPage{namespace: task.namespace, allocID: task.allocID, path: path.Join("/", task.name)}}) } func (p filesPage) title(_ env, count int) string { diff --git a/internal/ui/hints_test.go b/internal/ui/hints_test.go index 584f6f2..e7239df 100644 --- a/internal/ui/hints_test.go +++ b/internal/ui/hints_test.go @@ -166,6 +166,8 @@ func listsByName(client *fakeClient) map[string]Model { open[name] = m } + open["namespacetasks"], _ = typeCommand(newTestModel(client), "tasks").update(allocsMsg(client.allocs)) + open["overview"] = overviewWithCounts(client) // This urga, which has no rows. diff --git a/internal/ui/logs.go b/internal/ui/logs.go index 58e4c24..86a35f4 100644 --- a/internal/ui/logs.go +++ b/internal/ui/logs.go @@ -73,13 +73,13 @@ type logsPage struct { } // followLogs follows what the task under the cursor writes to a source. -func followLogs(p tasksPage, e env, source string) (tasksPage, outcome) { - task, ok := p.picked(e) +func followLogs[P taskLister](p P, e env, source string) (P, outcome) { + task, ok := p.pickedTask(e) if !ok { return p, outcome{} } - return p, then(openMsg{logsPage{namespace: p.namespace, allocID: p.allocID, task: task.Name, source: source}}) + return p, then(openMsg{logsPage{namespace: task.namespace, allocID: task.allocID, task: task.name, source: source}}) } // title says whose output this is, in which allocation, which of the two @@ -484,11 +484,11 @@ func wrapLines(m Model) (Model, tea.Cmd) { } // followStdout follows what the task under the cursor writes to stdout. -func followStdout(p tasksPage, e env) (tasksPage, outcome) { +func followStdout[P taskLister](p P, e env) (P, outcome) { return followLogs(p, e, nomad.LogStdout) } // followStderr follows what the task under the cursor writes to stderr. -func followStderr(p tasksPage, e env) (tasksPage, outcome) { +func followStderr[P taskLister](p P, e env) (P, outcome) { return followLogs(p, e, nomad.LogStderr) } diff --git a/internal/ui/mark.go b/internal/ui/mark.go index dea3db3..858a129 100644 --- a/internal/ui/mark.go +++ b/internal/ui/mark.go @@ -81,6 +81,8 @@ func groupMark(group nomad.TaskGroup) string { return group.Name } func taskMark(task nomad.Task) string { return task.Name } +func allocTaskMark(task allocTask) string { return task.allocID + "/" + task.name } + func names[T any](items []T, mark func(T) string) []string { out := make([]string, 0, len(items)) for _, item := range items { diff --git a/internal/ui/namespacetasks.go b/internal/ui/namespacetasks.go new file mode 100644 index 0000000..e913c6e --- /dev/null +++ b/internal/ui/namespacetasks.go @@ -0,0 +1,111 @@ +package ui + +import ( + "context" + + tea "charm.land/bubbletea/v2" + + "github.com/ingvarch/urga/internal/nomad" +) + +// namespaceTasksPage is every task of every allocation of the namespace the +// session looks at, read from the allocation list. +type namespaceTasksPage struct { + ofTheSession + + allocs []nomad.Alloc +} + +// namespaceTaskTitles are the columns of the tasks of the namespace. There +// is no CPU and memory: they cost a request for each task. +var namespaceTaskTitles = []string{ + "Job", "Namespace", "Group", "Task", "Alloc", "Node", "State", "Restarts", "Started", "Finished", "Uptime", +} + +func (namespaceTasksPage) title(e env, count int) string { + return sprintf("Tasks (%s) [%d]", namespaceLabel(e.namespace), count) +} + +func (namespaceTasksPage) titles() []string { return namespaceTaskTitles } +func (namespaceTasksPage) topics() []string { return []string{nomad.TopicAllocation} } + +func (namespaceTasksPage) fetch(e env) tea.Cmd { + client, namespace := e.client, e.namespace + + return fetchList(func(ctx context.Context) ([]nomad.Alloc, error) { + return client.Allocations(ctx, namespace, "") + }, func(items []nomad.Alloc) tea.Msg { return allocsMsg(items) }) +} + +func (p namespaceTasksPage) take(msg tea.Msg, _ env) (page, outcome, bool) { + allocs, ok := msg.(allocsMsg) + if !ok { + return p, outcome{}, false + } + + p.allocs = allocs + + return p, outcome{}, true +} + +// tasks are the tasks of every allocation, the allocations in the order the +// cluster lists them and the tasks of each by name. Each task keeps the +// namespace of its allocation: the session may look at every namespace. +func (p namespaceTasksPage) tasks() []allocTask { + var tasks []allocTask + + for _, alloc := range p.allocs { + for _, task := range alloc.Tasks { + tasks = append(tasks, allocTask{ + taskRef: taskRef{namespace: alloc.Namespace, allocID: alloc.ID, name: task.Name}, + task: task, + alloc: alloc, + }) + } + } + + return tasks +} + +func (p namespaceTasksPage) rows(env) []tableRow { return namespaceTaskRows(p.tasks()) } + +func (p namespaceTasksPage) ids(env) []string { return names(p.tasks(), allocTaskMark) } + +func namespaceTaskRows(tasks []allocTask) []tableRow { + rows := make([]tableRow, 0, len(tasks)) + + for _, t := range tasks { + row := tableRow{color: taskColor(t.task)} + row.add( + t.alloc.JobID, + t.alloc.Namespace, + t.alloc.TaskGroup, + t.name, + shortID(t.allocID), + t.alloc.NodeName, + t.task.State, + sprintf("%d", t.task.Restarts), + ) + addTaskTimes(&row, t.task) + + rows = append(rows, row) + } + + return rows +} + +func (p namespaceTasksPage) pickedTask(e env) (allocTask, bool) { return pickedFrom(e, p.tasks()) } + +func (p namespaceTasksPage) pickedAlloc(e env) (namespace, allocID string, ok bool) { + task, ok := p.pickedTask(e) + + return task.namespace, task.allocID, ok +} + +var namespaceTasksKeys = taskKeys[namespaceTasksPage]() + +func (p namespaceTasksPage) keys(e env) []keyHint { return hintsOf(p, e, namespaceTasksKeys) } + +func (p namespaceTasksPage) press(k string, e env) (page, outcome, bool) { + return pressOf(p, e, namespaceTasksKeys, k) +} diff --git a/internal/ui/namespacetasks_test.go b/internal/ui/namespacetasks_test.go new file mode 100644 index 0000000..e445336 --- /dev/null +++ b/internal/ui/namespacetasks_test.go @@ -0,0 +1,255 @@ +package ui + +import ( + "slices" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/ingvarch/urga/internal/nomad" +) + +// onNamespaceTasks is the list the command `:tasks` opens, read from the client. +func onNamespaceTasks(t *testing.T, client *fakeClient, namespace string) Model { + t.Helper() + + m := New(client, Options{ + Namespace: namespace, Version: "v-test", PollEvery: time.Millisecond, + Shell: &fakeShell{}, + }) + m, _ = m.update(sizeMsg()) + m = typeCommand(m, "tasks") + + require.IsType(t, namespaceTasksPage{}, m.screen.page) + + return m +} + +func TestNamespaceTasks_OneRowPerTaskOfEveryAllocation(t *testing.T) { + r := require.New(t) + + client := &fakeClient{allocs: twoAllocs()} + m := onNamespaceTasks(t, client, "production") + + r.Contains(plain(m.render()), "Tasks (production) [2]") + + line := taskLine(t, m, "server") + for _, cell := range []string{"web", "frontend", "server", "af1f37df", "node-01", "running"} { + r.Contains(line, cell) + } + + r.Contains(taskLine(t, m, "sidecar"), "dead") + + // The tasks of every allocation of the namespace, with no job. + r.Equal("production/", client.lists["allocations"][0]) +} + +func TestNamespaceTasks_AskAgainInTheNamespaceTheSessionMovesTo(t *testing.T) { + r := require.New(t) + + client := &fakeClient{allocs: twoAllocs()} + m := onNamespaceTasks(t, client, "production") + m = elsewhere(m) + m = playOut(m, m.fetch()) + + r.Equal("staging/", client.lists["allocations"][len(client.lists["allocations"])-1]) + r.Contains(plain(m.render()), "Tasks (staging)") +} + +func TestNamespaceTasks_KeepTheOrderOfTheClusterAndTheNamespaceOfTheirAllocation(t *testing.T) { + r := require.New(t) + + page := namespaceTasksPage{allocs: []nomad.Alloc{ + {ID: "a1", Namespace: "production", Tasks: []nomad.Task{{Name: "a"}, {Name: "b"}}}, + {ID: "a0", Namespace: "staging", Tasks: []nomad.Task{{Name: "c"}}}, + }} + + var got []string + for _, task := range page.tasks() { + got = append(got, task.namespace+"/"+task.allocID+"/"+task.name) + } + + // The allocations stay in the order the cluster answered, each task in + // the namespace of its own allocation. + r.Equal([]string{"production/a1/a", "production/a1/b", "staging/a0/c"}, got) + r.Equal([]string{"a1/a", "a1/b", "a0/c"}, page.ids(env{})) +} + +// Two jobs of the same name in two namespaces read apart by the namespace +// of their allocation, next to the job. +func TestNamespaceTasks_ARowShowsTheNamespaceOfItsAllocation(t *testing.T) { + r := require.New(t) + + column := slices.Index(namespaceTaskTitles, "Namespace") + r.Equal(slices.Index(namespaceTaskTitles, "Job")+1, column) + + rows := namespaceTaskRows([]allocTask{{alloc: nomad.Alloc{JobID: "web", Namespace: "staging"}}}) + + r.Equal("staging", rows[0].cells[column]) +} + +func TestNamespaceTasks_RowsHaveTheColorsOfTheTasks(t *testing.T) { + r := require.New(t) + + rows := namespaceTaskRows([]allocTask{ + {task: nomad.Task{State: "running"}}, + {task: nomad.Task{State: "dead", Failed: true}}, + {task: nomad.Task{State: "pending"}}, + {task: nomad.Task{State: "dead"}}, + }) + + r.Nil(rows[0].color) + r.Equal(colorDead, rows[1].color) + r.Equal(colorPending, rows[2].color) + r.Equal(colorSpent, rows[3].color) +} + +func TestNamespaceTasks_ShowWhenTheyStartedAndEnded(t *testing.T) { + r := require.New(t) + + now := time.Now() + rows := namespaceTaskRows([]allocTask{ + {task: nomad.Task{State: "dead", Started: now.Add(-3 * time.Hour), Finished: now.Add(-1 * time.Hour)}}, + }) + + started, finished, uptime := slices.Index(namespaceTaskTitles, "Started"), slices.Index(namespaceTaskTitles, "Finished"), slices.Index(namespaceTaskTitles, "Uptime") + + r.Equal([]string{"3h", "1h", "2h"}, []string{rows[0].cells[started], rows[0].cells[finished], rows[0].cells[uptime]}) +} + +func TestNamespaceTasks_TheHeaderShowsTheKeysOfATask(t *testing.T) { + r := require.New(t) + + m := onNamespaceTasks(t, &fakeClient{allocs: twoAllocs()}, "production") + + r.Equal([]hint{ + {Key: "", Description: "Logs"}, + {Key: "", Description: "Events"}, + {Key: "", Description: "Alloc Events"}, + {Key: "", Description: "Stderr"}, + {Key: "", Description: "Shell"}, + {Key: "", Description: "Restart"}, + {Key: "", Description: "Signal"}, + {Key: "", Description: "Browse"}, + }, m.hints()) +} + +// staged is twoAllocs with the first allocation in staging, which the +// session lists with every namespace. +func staged() []nomad.Alloc { + allocs := twoAllocs() + allocs[0].Namespace = "staging" + + return allocs +} + +func TestNamespaceTasks_LogsOfTheTaskInItsOwnAllocation(t *testing.T) { + r := require.New(t) + + for press, source := range map[string]string{"": nomad.LogStdout, "": nomad.LogStderr} { + m := onNamespaceTasks(t, &fakeClient{allocs: staged()}, nomad.AllNamespaces) + + next, _ := m.update(keyOf(press)) + + logs, ok := next.screen.page.(logsPage) + r.True(ok, press) + r.Equal(logsPage{namespace: "staging", allocID: frontendAlloc, task: "server", source: source}, logs, press) + } +} + +func TestNamespaceTasks_EventsAndFilesOfTheTaskInItsOwnAllocation(t *testing.T) { + r := require.New(t) + + m := onNamespaceTasks(t, &fakeClient{allocs: staged()}, nomad.AllNamespaces) + + events, _ := m.update(key('e')) + page, ok := events.screen.page.(taskEventsPage) + r.True(ok) + r.Equal("staging", page.namespace) + r.Equal(frontendAlloc, page.allocID) + r.Equal("server", page.task) + r.Equal(staged()[0].ID, page.alloc.ID) + + files, _ := m.update(key('b')) + browsed, ok := files.screen.page.(filesPage) + r.True(ok) + r.Equal(filesPage{namespace: "staging", allocID: frontendAlloc, path: "/server"}, browsed) +} + +func TestNamespaceTasks_EventsOfTheAllocationOfTheRowInItsOwnNamespace(t *testing.T) { + r := require.New(t) + + m := onNamespaceTasks(t, &fakeClient{allocs: staged()}, nomad.AllNamespaces) + + next, _ := m.update(key('o')) + events, ok := next.screen.page.(feedPage) + r.True(ok) + r.Equal("staging", events.namespace) + r.Equal(nomad.Subject{AllocID: frontendAlloc}, events.subject) +} + +func TestNamespaceTasks_ShellInTheTaskInItsOwnAllocation(t *testing.T) { + r := require.New(t) + + shell := &fakeShell{} + m := onNamespaceTasks(t, &fakeClient{allocs: staged()}, nomad.AllNamespaces) + m.opts.Shell = shell + + m, cmd := m.update(key('s')) + m = follow(m, cmd, 3) + + r.Equal("server", shell.opened.Task) + r.Equal(frontendAlloc, shell.opened.AllocID) + r.Equal("staging", shell.opened.Namespace) +} + +func TestNamespaceTasks_RestartAndSignalReachTheTaskInItsOwnAllocation(t *testing.T) { + r := require.New(t) + + client := &fakeClient{allocs: staged()} + m := onNamespaceTasks(t, client, nomad.AllNamespaces) + + m, _ = m.update(key('r')) + r.Contains(plain(m.render()), "Really restart task server of the allocation af1f37df?") + + m = confirmed(t, m) + + r.Equal("server", client.restartedTask) + r.Equal(frontendAlloc, client.askedID) + r.Equal("staging", client.askedNamespace) + + client.askedID, client.askedNamespace = "", "" + + m, _ = m.update(key('x')) + m, _ = m.update(enter()) + _ = confirmed(t, m) + + r.Equal("server", client.signalledTask) + r.Equal(frontendAlloc, client.askedID) + r.Equal("staging", client.askedNamespace) +} + +func TestNamespaceTasks_ARestartAndASignalAreOfferedOnARunningTaskOnly(t *testing.T) { + r := require.New(t) + + m := onNamespaceTasks(t, &fakeClient{allocs: twoAllocs()}, "production") + r.True(offers(m, "r")) + r.True(offers(m, "x")) + + // The sidecar is dead. + m, _ = m.update(key('j')) + + r.False(offers(m, "r")) + r.False(offers(m, "x")) +} + +func TestNamespaceTasks_NoTasksNoKeysToAct(t *testing.T) { + r := require.New(t) + + m := onNamespaceTasks(t, &fakeClient{}, "production") + + next, cmd := m.update(enter()) + r.Nil(cmd) + r.IsType(namespaceTasksPage{}, next.screen.page) +} diff --git a/internal/ui/shell.go b/internal/ui/shell.go index 070abb0..bdb60aa 100644 --- a/internal/ui/shell.go +++ b/internal/ui/shell.go @@ -37,17 +37,17 @@ type shellDoneMsg struct { } // shell opens a shell in the task under the cursor. -func shell(p tasksPage, e env) (tasksPage, outcome) { - task, ok := p.picked(e) +func shell[P taskLister](p P, e env) (P, outcome) { + task, ok := p.pickedTask(e) if !ok { return p, outcome{} } return p, then(shellMsg{ Region: e.client.Region(), - Namespace: p.namespace, - AllocID: p.allocID, - Task: task.Name, + Namespace: task.namespace, + AllocID: task.allocID, + Task: task.name, }) } diff --git a/internal/ui/sort.go b/internal/ui/sort.go index ca60ae2..4405866 100644 --- a/internal/ui/sort.go +++ b/internal/ui/sort.go @@ -155,15 +155,10 @@ func holdsAges(rows []tableRow, column int) bool { return false } -// ageIn is how long ago the moment behind a cell was. A cell with no moment -// reads "-" or nothing, and counts as new. +// ageIn is how long the span behind a cell is. A cell with no moment reads +// "-" or nothing, and counts as new. func ageIn(row tableRow, column int, now time.Time) time.Duration { - moment := row.ages[column] - if moment.IsZero() { - return 0 - } - - return now.Sub(moment) + return row.ages[column].length(now) } // naturalLess compares the way a person reads: the digits in a value count as diff --git a/internal/ui/sort_test.go b/internal/ui/sort_test.go index 56984a2..5ef9248 100644 --- a/internal/ui/sort_test.go +++ b/internal/ui/sort_test.go @@ -90,14 +90,7 @@ func TestSort_AgeGoesByTime(t *testing.T) { // Every list that shows how long ago something was orders that column by // time, whatever the column is called. func TestSort_EveryAgeColumnGoesByTime(t *testing.T) { - newer, older := time.Now().Add(-30*time.Minute), time.Now().Add(-time.Hour) - - lists := []struct { - name string - titles []string - column string - rows func(at time.Time) []tableRow - }{ + sortsByTime(t, []ageList{ {"jobs", jobTitles, "Age", func(at time.Time) []tableRow { return jobRows([]nomad.Job{{SubmitTime: at}}, nil) }}, @@ -107,9 +100,6 @@ func TestSort_EveryAgeColumnGoesByTime(t *testing.T) { {"allocations of a deployment", deploymentAllocTitles, "Age", func(at time.Time) []tableRow { return deploymentAllocRows([]nomad.Alloc{{Created: at}}, nil) }}, - {"tasks", taskTitles, "Started", func(at time.Time) []tableRow { - return taskRows([]nomad.Task{{Started: at}}, nil) - }}, {"task events", taskEventTitles, "Age", func(at time.Time) []tableRow { return taskEventRows([]nomad.TaskEvent{{Time: at}}) }}, @@ -134,7 +124,42 @@ func TestSort_EveryAgeColumnGoesByTime(t *testing.T) { {"versions", versionTitles, "Age", func(at time.Time) []tableRow { return versionRows([]nomad.JobVersion{{Submitted: at}}) }}, - } + }) +} + +// The moments of a task go by time too: when it started and ended, on both +// screens that list tasks. +func TestSort_EveryTaskTimeColumnGoesByTime(t *testing.T) { + sortsByTime(t, []ageList{ + {"tasks", taskTitles, "Started", func(at time.Time) []tableRow { + return taskRows([]nomad.Task{{Started: at}}, nil) + }}, + {"tasks by end", taskTitles, "Finished", func(at time.Time) []tableRow { + return taskRows([]nomad.Task{{State: "dead", Started: at.Add(-time.Minute), Finished: at}}, nil) + }}, + {"tasks of the namespace by start", namespaceTaskTitles, "Started", func(at time.Time) []tableRow { + return namespaceTaskRows([]allocTask{{task: nomad.Task{Started: at}}}) + }}, + {"tasks of the namespace by end", namespaceTaskTitles, "Finished", func(at time.Time) []tableRow { + return namespaceTaskRows([]allocTask{{task: nomad.Task{State: "dead", Started: at.Add(-time.Minute), Finished: at}}}) + }}, + }) +} + +// ageList is a list with a column of ages, and how to build a row whose age +// is a moment. +type ageList struct { + name string + titles []string + column string + rows func(at time.Time) []tableRow +} + +// sortsByTime checks that each list orders its age column by time. +func sortsByTime(t *testing.T, lists []ageList) { + t.Helper() + + newer, older := time.Now().Add(-30*time.Minute), time.Now().Add(-time.Hour) for _, list := range lists { t.Run(list.name, func(t *testing.T) { @@ -247,3 +272,22 @@ func TestSort_HelpNamesTheLetterOfEachColumn(t *testing.T) { r.Contains(out, " Namespace") r.NotContains(out, "") } + +// An uptime is a length, not a moment: 5m comes before 2h, which the text +// of the two gets wrong. +func TestSort_UptimeGoesByLength(t *testing.T) { + r := require.New(t) + + now := time.Now() + ran := func(name string, d time.Duration) nomad.Task { + return nomad.Task{Name: name, State: "dead", Started: now.Add(-24 * time.Hour), Finished: now.Add(-24*time.Hour + d)} + } + + tbl := newTableModel(taskTitles) + tbl.setSize(100, 6) + tbl.show(taskRows([]nomad.Task{ran("long", 2*time.Hour), ran("short", 5*time.Minute)}, nil), newSortState()) + + sortTable(&tbl, slices.Index(taskTitles, "Uptime")) + + r.Equal([]string{"5m", "2h"}, cellsOf(tbl, slices.Index(taskTitles, "Uptime"))) +} diff --git a/internal/ui/table.go b/internal/ui/table.go index ea33ee0..e5f72c4 100644 --- a/internal/ui/table.go +++ b/internal/ui/table.go @@ -31,10 +31,27 @@ type tableRow struct { marked bool } -// moments are what the age cells of a row count from, by column. The list is -// sorted by them: the text of an age sorts wrong, "2d" comes before "5h" as -// text and is older. -type moments map[int]time.Time +// moments are what the age and uptime cells of a row count, by column. The +// list is sorted by them: the text of an age sorts wrong, "2d" comes before +// "5h" as text and is older. +type moments map[int]span + +// span is a length of time between two moments. With no end, the span +// counts to now: an age is a span from a moment to now, an uptime one from a +// start to an end, or to now. +type span struct{ from, to time.Time } + +// length is how long the span is, and 0 when it has no start. +func (s span) length(now time.Time) time.Duration { + switch { + case s.from.IsZero(): + return 0 + case s.to.IsZero(): + return now.Sub(s.from) + } + + return s.to.Sub(s.from) +} // add puts cells at the end of a row, as they read. func (r *tableRow) add(cells ...string) { @@ -43,16 +60,37 @@ func (r *tableRow) add(cells ...string) { } } -// addAge puts at the end of a row how long ago a moment was, and keeps the -// moment under the same column. Set apart from the cell, the moment could -// get the wrong column number and make another column sort by time. +// addAge puts at the end of a row how long ago a moment was, and keeps its +// span under the same column. Set apart from the cell, the span could get +// the wrong column number and make another column sort by time. func (r *tableRow) addAge(moment time.Time) { + r.keep(span{from: moment}) + r.add(ageOf(moment)) +} + +// addSpan puts at the end of a row how long passed between two moments, or +// since the first when there is no second. It reads "-" without a start. +func (r *tableRow) addSpan(from, to time.Time) { + s := span{from: from, to: to} + + r.keep(s) + + if from.IsZero() { + r.add("-") + + return + } + + r.add(age(s.length(time.Now()))) +} + +// keep stores the span under the column of the cell that comes next. +func (r *tableRow) keep(s span) { if r.ages == nil { r.ages = moments{} } - r.ages[len(r.cells)] = moment - r.add(ageOf(moment)) + r.ages[len(r.cells)] = s } // tableModel is the list every screen shows: a header, rows, a cursor and a diff --git a/internal/ui/table_test.go b/internal/ui/table_test.go index 690c9b8..b34822a 100644 --- a/internal/ui/table_test.go +++ b/internal/ui/table_test.go @@ -219,5 +219,21 @@ func TestTableRow_AnAgeStaysWithItsCell(t *testing.T) { row.addAge(changed) r.Equal([]string{"1h", "web", "running", "2d"}, row.cells) - r.Equal(moments{0: started, 3: changed}, row.ages) + r.Equal(moments{0: span{from: started}, 3: span{from: changed}}, row.ages) +} + +func TestTableRow_ASpanIsAgeToNowOrBetweenTwoMoments(t *testing.T) { + r := require.New(t) + + from := time.Now().Add(-3 * time.Hour) + to := from.Add(5 * time.Minute) + + var row tableRow + row.addSpan(time.Time{}, time.Time{}) + row.addSpan(from, time.Time{}) + row.addSpan(from, to) + + // No start reads "-"; no end counts to now; an end stops the count there. + r.Equal([]string{"-", "3h", "5m"}, row.cells) + r.Equal(moments{0: span{}, 1: span{from: from}, 2: span{from: from, to: to}}, row.ages) } diff --git a/internal/ui/taskactions.go b/internal/ui/taskactions.go index 132921e..2e9c78d 100644 --- a/internal/ui/taskactions.go +++ b/internal/ui/taskactions.go @@ -7,6 +7,8 @@ import ( "strings" tea "charm.land/bubbletea/v2" + + "github.com/ingvarch/urga/internal/nomad" ) // taskRef is a task of an allocation, and the namespace the allocation @@ -15,40 +17,81 @@ type taskRef struct { namespace, allocID, name string } +// allocTask is a task on a row: named by where it runs, with its state and +// the allocation as the list holds it, which the events page shows until it +// is read again. +type allocTask struct { + taskRef + + task nomad.Task + alloc nomad.Alloc +} + +// taskLister is a page that lists tasks: of one allocation, or of the whole +// namespace. The keys of a task work on every one of them. +type taskLister interface { + page + + // pickedTask is the task under the cursor. + pickedTask(e env) (allocTask, bool) + + // pickedAlloc is the allocation under the cursor and its namespace. A + // page of one allocation has it with no task to pick. + pickedAlloc(e env) (namespace, allocID string, ok bool) +} + +// taskKeys are the keys of a task, for a page that lists them. A page with +// keys of its own adds them to these. +func taskKeys[P taskLister]() []pageKey[P] { + return []pageKey[P]{ + {press: "enter", label: "Logs", do: followStdout[P]}, + // The same key opens the events of a task here and edits elsewhere, + // because a screen never offers both. + {press: "e", label: "Events", do: openTaskEvents[P]}, + {press: "o", label: "Alloc Events", do: openTaskAllocEvents[P]}, + {press: "ctrl+e", label: "Stderr", do: followStderr[P]}, + // The same key opens a shell here and scales a task group elsewhere. + {press: "s", label: "Shell", do: shell[P], writes: true}, + {press: "r", label: "Restart", do: restartTask[P], writes: true, offered: taskRuns[P]}, + {press: "x", label: "Signal", do: askSignal[P], writes: true, offered: taskRuns[P]}, + {press: "b", label: "Browse", do: browse[P]}, + } +} + // taskRuns says the task under the cursor runs: its client restarts or // signals only a running task. -func taskRuns(p tasksPage, e env) bool { - task, ok := p.picked(e) +func taskRuns[P taskLister](p P, e env) bool { + task, ok := p.pickedTask(e) - return ok && task.State == statusRunning + return ok && task.task.State == statusRunning } // restartTask restarts the task under the cursor in place, once the // question is answered yes. The rest of the allocation keeps running. -func restartTask(p tasksPage, e env) (tasksPage, outcome) { - task, ok := p.picked(e) +func restartTask[P taskLister](p P, e env) (P, outcome) { + task, ok := p.pickedTask(e) if !ok { return p, outcome{} } - client, namespace, allocID := e.client, p.namespace, p.allocID + client := e.client return p, then(askMsg{ - question: fmt.Sprintf("Really restart task %s of the allocation %s?", task.Name, shortID(allocID)), - apply: act(fmt.Sprintf("Task %s restarted.", task.Name), func(ctx context.Context) error { - return client.RestartTask(ctx, namespace, allocID, task.Name) + question: fmt.Sprintf("Really restart task %s of the allocation %s?", task.name, shortID(task.allocID)), + apply: act(fmt.Sprintf("Task %s restarted.", task.name), func(ctx context.Context) error { + return client.RestartTask(ctx, task.namespace, task.allocID, task.name) }), }) } // askSignal asks which signal to send to the task under the cursor. -func askSignal(p tasksPage, e env) (tasksPage, outcome) { - task, ok := p.picked(e) +func askSignal[P taskLister](p P, e env) (P, outcome) { + task, ok := p.pickedTask(e) if !ok { return p, outcome{} } - return p, then(signalLine(taskRef{namespace: p.namespace, allocID: p.allocID, name: task.Name})) + return p, then(signalLine(task.taskRef)) } // signalLine asks which signal to send to a task. It is filled with the one a diff --git a/internal/ui/tasks.go b/internal/ui/tasks.go index 5df92fe..69c3aac 100644 --- a/internal/ui/tasks.go +++ b/internal/ui/tasks.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "image/color" + "time" tea "charm.land/bubbletea/v2" @@ -109,18 +110,23 @@ func (p tasksPage) reading(ctx context.Context, client Client, ref rowRef) (noma // picked is the task under the cursor. func (p tasksPage) picked(e env) (nomad.Task, bool) { return pickedFrom(e, p.alloc.Tasks) } -var tasksKeys = []pageKey[tasksPage]{ - {press: "enter", label: "Logs", do: followStdout}, - // The same key opens the events of a task here and edits elsewhere, - // because a screen never offers both. - {press: "e", label: "Events", do: openTaskEvents}, - {press: "o", label: "Alloc Events", do: openAllocationEvents}, - {press: "ctrl+e", label: "Stderr", do: followStderr}, - // The same key opens a shell here and scales a task group elsewhere. - {press: "s", label: "Shell", do: shell, writes: true}, - {press: "r", label: "Restart", do: restartTask, writes: true, offered: taskRuns}, - {press: "x", label: "Signal", do: askSignal, writes: true, offered: taskRuns}, - {press: "b", label: "Browse", do: browse}, +func (p tasksPage) pickedTask(e env) (allocTask, bool) { + task, ok := p.picked(e) + if !ok { + return allocTask{}, false + } + + return allocTask{taskRef: taskRef{namespace: p.namespace, allocID: p.allocID, name: task.Name}, task: task, alloc: p.alloc}, true +} + +func (p tasksPage) pickedAlloc(env) (namespace, allocID string, ok bool) { + return p.namespace, p.allocID, true +} + +// tasksKeys are the keys of a task, and the keys of the allocation after them. +var tasksKeys = append(taskKeys[tasksPage](), tasksAllocKeys...) + +var tasksAllocKeys = []pageKey[tasksPage]{ {press: "c", label: "Client", do: openAllocNode, offered: allocHas(func(a nomad.Alloc) string { return a.NodeID })}, {press: "p", label: "Previous", do: openReplaced, offered: allocHas(func(a nomad.Alloc) string { return a.Previous })}, {press: "n", label: "Next", do: openReplacement, offered: allocHas(func(a nomad.Alloc) string { return a.Next })}, @@ -134,7 +140,7 @@ func (p tasksPage) press(k string, e env) (page, outcome, bool) { } // taskTitles are the columns of the task list. -var taskTitles = []string{"Name", "State", "Failed", "Restarts", "CPU", "MEM", "Started"} +var taskTitles = []string{"Name", "State", "Failed", "Restarts", "CPU", "MEM", "Started", "Finished", "Uptime"} func taskRows(tasks []nomad.Task, usage map[string]nomad.ResourceUse) []tableRow { rows := make([]tableRow, 0, len(tasks)) @@ -151,7 +157,7 @@ func taskRows(tasks []nomad.Task, usage map[string]nomad.ResourceUse) []tableRow cpuCell(use, known), memoryCell(use, known), ) - row.addAge(task.Started) + addTaskTimes(&row, task) rows = append(rows, row) } @@ -159,6 +165,36 @@ func taskRows(tasks []nomad.Task, usage map[string]nomad.ResourceUse) []tableRow return rows } +// addTaskTimes puts when a task started and ended, and how long it ran, at +// the end of a row. +func addTaskTimes(row *tableRow, task nomad.Task) { + ended := finishedAt(task) + + row.addAge(task.Started) + row.addAge(ended) + + // Only a running task counts to now; one that ended counts to its end. + switch { + case task.State == statusRunning: + row.addSpan(task.Started, time.Time{}) + case !ended.IsZero(): + row.addSpan(task.Started, ended) + default: + row.addSpan(time.Time{}, time.Time{}) + } +} + +// finishedAt is when a task ended in its last run, or zero while it runs or +// waits. Nomad keeps the end of an earlier run after a restart, so an end +// before the start is not this run's. +func finishedAt(task nomad.Task) time.Time { + if task.State != statusDead || task.Finished.Before(task.Started) { + return time.Time{} + } + + return task.Finished +} + func taskColor(task nomad.Task) color.Color { switch { case task.Failed: @@ -173,19 +209,24 @@ func taskColor(task nomad.Task) color.Color { } // openTaskEvents opens what happened to the task under the cursor. -func openTaskEvents(p tasksPage, e env) (tasksPage, outcome) { - task, ok := p.picked(e) +func openTaskEvents[P taskLister](p P, e env) (P, outcome) { + task, ok := p.pickedTask(e) if !ok { return p, outcome{} } - return p, then(openMsg{taskEventsPage{namespace: p.namespace, allocID: p.allocID, task: task.Name, alloc: p.alloc}}) + return p, then(openMsg{taskEventsPage{namespace: task.namespace, allocID: task.allocID, task: task.name, alloc: task.alloc}}) } -// openAllocationEvents opens the events of the allocation the page is open -// on. -func openAllocationEvents(p tasksPage, _ env) (tasksPage, outcome) { - return p, then(openMsg{subjectEvents(p.namespace, nomad.Subject{AllocID: p.allocID})}) +// openTaskAllocEvents opens the events of the allocation under the cursor, +// in the namespace of that allocation. +func openTaskAllocEvents[P taskLister](p P, e env) (P, outcome) { + namespace, allocID, ok := p.pickedAlloc(e) + if !ok { + return p, outcome{} + } + + return p, then(openMsg{subjectEvents(namespace, nomad.Subject{AllocID: allocID})}) } // taskEventsPage is what happened to one task of an allocation. diff --git a/internal/ui/tasks_test.go b/internal/ui/tasks_test.go index 5ec5740..7941741 100644 --- a/internal/ui/tasks_test.go +++ b/internal/ui/tasks_test.go @@ -1,6 +1,7 @@ package ui import ( + "slices" "strings" "testing" "time" @@ -169,6 +170,22 @@ func TestTasks_TheKeysOfATask(t *testing.T) { ), m.hints()) } +func TestTasks_AllocEventsWithNoTaskToPick(t *testing.T) { + r := require.New(t) + + // Its client has not taken it yet: there is no task under the cursor. + m := newTestModel(&fakeClient{}) + m.namespace = nomad.AllNamespaces + m, _ = m.update(openMsg{listedTasks(nomad.Alloc{ID: frontendAlloc, Namespace: "ml", Status: "pending"})}) + + next, _ := m.update(key('o')) + + events, ok := next.screen.page.(feedPage) + r.True(ok) + r.Equal("ml", events.namespace) + r.Equal(nomad.Subject{AllocID: frontendAlloc}, events.subject) +} + func TestTasks_ThePanelWaitsForTheAllocationToBeRead(t *testing.T) { r := require.New(t) @@ -180,3 +197,29 @@ func TestTasks_ThePanelWaitsForTheAllocationToBeRead(t *testing.T) { r.Contains(out, "sidecar") r.NotContains(out, "Status running") } + +func TestTasks_ShowWhenATaskEndedAndHowLongItRan(t *testing.T) { + r := require.New(t) + + now := time.Now() + tasks := []nomad.Task{ + {Name: "running", State: "running", Started: now.Add(-3 * time.Hour)}, + {Name: "ended", State: "dead", Started: now.Add(-3 * time.Hour), Finished: now.Add(-1 * time.Hour)}, + {Name: "waiting", State: "pending"}, + // Restarted since it ended: the old end is before the new start. + {Name: "again", State: "dead", Started: now.Add(-1 * time.Hour), Finished: now.Add(-3 * time.Hour)}, + // Waiting to restart: the times are those of the earlier run. + {Name: "restarting", State: "pending", Started: now.Add(-3 * time.Hour), Finished: now.Add(-2 * time.Hour)}, + } + + rows := taskRows(tasks, nil) + finished, uptime := slices.Index(taskTitles, "Finished"), slices.Index(taskTitles, "Uptime") + + cells := func(i int) []string { return []string{rows[i].cells[finished], rows[i].cells[uptime]} } + + r.Equal([]string{"-", "3h"}, cells(0)) + r.Equal([]string{"1h", "2h"}, cells(1)) + r.Equal([]string{"-", "-"}, cells(2)) + r.Equal([]string{"-", "-"}, cells(3)) + r.Equal([]string{"-", "-"}, cells(4)) +} diff --git a/internal/ui/usage_test.go b/internal/ui/usage_test.go index 0a44e1b..f6b41b6 100644 --- a/internal/ui/usage_test.go +++ b/internal/ui/usage_test.go @@ -306,7 +306,7 @@ func TestUsage_ShownOnTheTasks(t *testing.T) { m := openTasks(t, client) // Each task has its own reading, next to how it is doing. - r.Equal([]string{"Name", "State", "Failed", "Restarts", "CPU", "MEM", "Started"}, m.screen.page.titles()) + r.Equal([]string{"Name", "State", "Failed", "Restarts", "CPU", "MEM", "Started", "Finished", "Uptime"}, m.screen.page.titles()) m = drain(m, m.fetchUsage()) diff --git a/internal/ui/views.go b/internal/ui/views.go index 26ce93e..187dc73 100644 --- a/internal/ui/views.go +++ b/internal/ui/views.go @@ -30,6 +30,11 @@ var ( open: func() page { return allocationsPage{} }, } + tasksView = &view{ + aliases: []string{"tasks", "task"}, + open: func() page { return namespaceTasksPage{} }, + } + deploymentsView = &view{ stored: "deployments", aliases: []string{"deployments", "deployment", "dp"}, @@ -140,7 +145,7 @@ var ( // views is the one place that knows which lists urga opens by name, so that // adding one is adding a line here. var views = []*view{ - jobsView, allocationsView, deploymentsView, servicesView, evaluationsView, + jobsView, allocationsView, tasksView, deploymentsView, servicesView, evaluationsView, nodesView, variablesView, regionsView, datacentersView, clustersView, namespacesView, serversView, nodePoolsView, volumesView, pluginsView, tokensView, policiesView, rolesView, authMethodsView, bindingRulesView, eventsView, scalingView, schedulerView, overviewView, aboutView, From 8a1ac291fa1cb7f264f42c5c0f8f604f062a1be6 Mon Sep 17 00:00:00 2001 From: Igor Churmeev Date: Tue, 20 Oct 2026 06:43:46 +0000 Subject: [PATCH 3/4] feat(ui): run a command in a task with ctrl+x and read what it printed --- README.md | 3 +- docs/allocations.md | 12 +- docs/configuration.md | 10 +- docs/keys.md | 3 +- go.mod | 2 +- internal/nomad/shell.go | 108 +++++++++++- internal/nomad/shell_tail_test.go | 78 +++++++++ internal/nomad/shell_test.go | 175 +++++++++++++++++++ internal/ui/app_test.go | 14 ++ internal/ui/describe.go | 10 +- internal/ui/logs.go | 10 +- internal/ui/logtools_test.go | 3 + internal/ui/namespacetasks_test.go | 1 + internal/ui/page.go | 6 + internal/ui/roles.go | 1 + internal/ui/save.go | 7 +- internal/ui/screen.go | 4 + internal/ui/taskactions.go | 5 +- internal/ui/taskrun.go | 70 ++++++++ internal/ui/taskrun_test.go | 258 +++++++++++++++++++++++++++++ internal/ui/tasks_test.go | 1 + internal/ui/writes_test.go | 2 +- 22 files changed, 759 insertions(+), 24 deletions(-) create mode 100644 internal/nomad/shell_tail_test.go create mode 100644 internal/nomad/shell_test.go create mode 100644 internal/ui/taskrun.go create mode 100644 internal/ui/taskrun_test.go diff --git a/README.md b/README.md index ba9c8f9..6dbecfc 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,8 @@ One binary, no config to write, no browser. with its meta and payload. - Starts, stops, scales and restarts jobs and allocations, restarts every task of an allocation with its prestart and sidecar tasks, restarts and - signals single tasks, and opens a shell in a task. + signals single tasks, opens a shell in a task, and runs a command in a task + and keeps what it printed. - Drains clients, shows their CPU and memory over time, marks the ones that are full, shows when the identity of a client expires and renews it. Purges clients that are down, stopped jobs, and collects the garbage of a diff --git a/docs/allocations.md b/docs/allocations.md index 4509e57..469b479 100644 --- a/docs/allocations.md +++ b/docs/allocations.md @@ -82,8 +82,9 @@ capital letter sorts by a column: `R` by restarts, `U` by uptime. The keys of a task work on the row: `enter` and `ctrl-e` for the logs, `e` for the events, `o` for the events of its allocation, `b` for the files, `s` for a -shell, `r` to restart and `x` to send a signal. They act on the task in its -own allocation and namespace, also when the session looks at every namespace. +shell, `r` to restart, `x` to send a signal and `ctrl-x` to run a command. +They act on the task in its own allocation and namespace, also when the +session looks at every namespace. The list is read from the allocation list in one request, so there is no CPU and memory column: they would cost a request for each task. It holds the @@ -113,6 +114,13 @@ These keys work on the task under the cursor while it is running: - `s` opens a shell in the task. urga starts `bash` if the task has it, and `sh` if not. The shell opens on a clean screen. What the terminal showed before, an earlier shell included, is still in its scrollback. +- `ctrl-x` asks for a command and runs it with `/bin/sh -c`, without a + terminal and with nothing on its stdin. It opens what the command printed to + stdout and stderr, in the order it printed it, with the exit code in the + title. `/`, `f`, `c`, `ctrl-s` and `w` work on it as on a log. Of an output + larger than 1 MiB it keeps the last 1 MiB, and the title says so. urga waits + 10 seconds for the command and then shows an error. A task without `/bin/sh` + cannot run it. urga only sends a signal name that Nomad knows. When a Nomad client gets a name it does not know, it sends `SIGINT` instead, which stops most tasks. So diff --git a/docs/configuration.md b/docs/configuration.md index d674645..164ecc9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -45,12 +45,12 @@ urga --address https://nomad.example.com:4646 --region eu --namespace production With `--readonly`, urga does not change anything in the cluster. It hides every key that would: start and stop a job, revert, edit, scale, restart or stop an allocation, restart or signal a task, drain a client, change its -eligibility, promote or fail a deployment, and open a shell. If you press one -of these keys, the status line says that it is off. The header shows -`read-only` next to the address. +eligibility, promote or fail a deployment, open a shell and run a command in +a task. If you press one of these keys, the status line says that it is off. +The header shows `read-only` next to the address. -The shell is off in read-only mode because a shell in a task can change -anything the task can. +The shell and the command are off in read-only mode because a command in a +task can change anything the task can. ## Token diff --git a/docs/keys.md b/docs/keys.md index fc199e2..db916e3 100644 --- a/docs/keys.md +++ b/docs/keys.md @@ -305,6 +305,7 @@ On what was done to the count of a group, `d` describes its policy. | `s` | Shell in the task | yes | | `r` | Restart the task. Shown when it is running. | yes | | `x` | Send a signal to the task. Shown when it is running. | yes | +| `ctrl-x` | Run a command in the task and show what it printed. Shown when it is running. | yes | | `c` | Client the allocation runs on | | | `p` | Allocation this one replaced | | | `n` | Allocation that replaced this one | | @@ -548,7 +549,7 @@ On every list of ACL objects: ### Text screens -Descriptions, job files and diffs: +Descriptions, job files, diffs and the output of a command run with `ctrl-x`: | Key | What it does | | --- | --- | diff --git a/go.mod b/go.mod index b5eea7f..6738d09 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/BurntSushi/toml v1.6.0 github.com/charmbracelet/x/ansi v0.11.8 github.com/google/licenseclassifier/v2 v2.0.0 + github.com/gorilla/websocket v1.5.3 github.com/hashicorp/hcl/v2 v2.25.0 github.com/hashicorp/nomad/api v0.0.0-20260922135024-0afeddadd9b4 github.com/ingvarch/pulse v0.4.0 @@ -35,7 +36,6 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/gorilla/websocket v1.5.3 // indirect github.com/hashicorp/cronexpr v1.1.3 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-rootcerts v1.0.2 // indirect diff --git a/internal/nomad/shell.go b/internal/nomad/shell.go index eac80df..f1e72bd 100644 --- a/internal/nomad/shell.go +++ b/internal/nomad/shell.go @@ -1,8 +1,11 @@ package nomad import ( + "bytes" "context" "io" + "strings" + "unicode/utf8" "github.com/hashicorp/nomad/api" ) @@ -24,11 +27,6 @@ func (c *Client) Exec( stdout, stderr io.Writer, sizes <-chan TerminalSize, ) (int, error) { - alloc, _, err := c.api.Allocations().Info(allocID, c.query(ctx, namespace)) - if err != nil { - return 0, err - } - // The api takes its own size type, so each size is converted and passed // on as it arrives. apiSizes := make(chan api.TerminalSize) @@ -45,5 +43,103 @@ func (c *Client) Exec( } }() - return c.api.Allocations().Exec(ctx, alloc, task, true, command, stdin, stdout, stderr, apiSizes, c.query(ctx, namespace)) + return c.exec(ctx, namespace, allocID, task, true, command, stdin, stdout, stderr, apiSizes) +} + +// CommandOutput is what a command printed to stdout and stderr, in the order +// the task printed it, and the exit code. +type CommandOutput struct { + Text string + ExitCode int + + // Size is how many bytes the command printed, and From how many of them + // were dropped from the start to keep only its end. + Size int64 + From int64 +} + +// outputTail is how much of what a command printed is kept: its last MiB. +// A command such as yes prints hundreds of MiB in the time urga waits for it. +const outputTail = 1 << 20 + +// RunCommand runs a command inside a task with no terminal and nothing on +// its stdin, and returns what it printed: all of it, or the whole lines of +// its last MiB. +func (c *Client) RunCommand(ctx context.Context, namespace, allocID, task string, command []string) (CommandOutput, error) { + printed := tail{limit: outputTail} + + code, err := c.exec(ctx, namespace, allocID, task, false, command, strings.NewReader(""), &printed, &printed, nil) + if err != nil { + // The api may still be writing to the buffer. + return CommandOutput{}, err + } + + text := printed.text() + + return CommandOutput{Text: string(text), ExitCode: code, Size: printed.size, From: printed.size - int64(len(text))}, nil +} + +// tail keeps the last limit bytes written to it, and the byte before them, +// and counts all of them. +type tail struct { + limit int + kept []byte + size int64 +} + +func (t *tail) Write(p []byte) (int, error) { + // The byte before the last limit bytes says whether a line starts + // right where they start. + keep := t.limit + 1 + + t.size += int64(len(p)) + t.kept = append(t.kept, p[max(len(p)-keep, 0):]...) + + // The start is dropped only once twice the limit is held, so a byte is + // copied a few times at most, not once per write. + if len(t.kept) > 2*t.limit { + t.kept = append(t.kept[:0], t.kept[len(t.kept)-keep:]...) + } + + return len(p), nil +} + +// text is all that was written, or when that was more than the limit, the +// lines that start within its last limit bytes. When no line starts there, +// it is those bytes from the first character that starts within them. +func (t *tail) text() []byte { + if t.size <= int64(t.limit) { + return t.kept + } + + kept := t.kept[len(t.kept)-t.limit-1:] + if at := bytes.IndexByte(kept[:len(kept)-1], '\n'); at >= 0 { + return kept[at+1:] + } + + // A character cut by the limit loses its first bytes, so the rest of it + // is dropped too. + start := 1 + for start < min(len(kept), utf8.UTFMax) && !utf8.RuneStart(kept[start]) { + start++ + } + + return kept[start:] +} + +func (c *Client) exec( + ctx context.Context, + namespace, allocID, task string, + tty bool, + command []string, + stdin io.Reader, + stdout, stderr io.Writer, + sizes <-chan api.TerminalSize, +) (int, error) { + alloc, _, err := c.api.Allocations().Info(allocID, c.query(ctx, namespace)) + if err != nil { + return 0, err + } + + return c.api.Allocations().Exec(ctx, alloc, task, tty, command, stdin, stdout, stderr, sizes, c.query(ctx, namespace)) } diff --git a/internal/nomad/shell_tail_test.go b/internal/nomad/shell_tail_test.go new file mode 100644 index 0000000..6df2126 --- /dev/null +++ b/internal/nomad/shell_tail_test.go @@ -0,0 +1,78 @@ +package nomad + +import ( + "fmt" + "slices" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestTail_HoldsAtMostTwiceTheLimitHoweverMuchIsWritten(t *testing.T) { + r := require.New(t) + + var printed strings.Builder + for i := range 600_000 { + fmt.Fprintf(&printed, "%09d\n", i) + } + + all := printed.String() + printedTo := tail{limit: outputTail} + + for chunk := range slices.Chunk([]byte(all), 1000) { + n, err := printedTo.Write(chunk) + r.NoError(err) + r.Equal(len(chunk), n) + r.LessOrEqual(len(printedTo.kept), 2*printedTo.limit) + } + + // 1 MiB holds 104857 lines of 10 bytes and the end of one more. + r.Equal(all[len(all)-104857*10:], string(printedTo.text())) + r.Equal(int64(len(all)), printedTo.size) +} + +func TestTail_OneWriteLargerThanTheLimitKeepsOnlyItsEnd(t *testing.T) { + r := require.New(t) + + var printed strings.Builder + for i := range 100 { + fmt.Fprintf(&printed, "%04d\n", i) + } + + all := printed.String() + printedTo := tail{limit: 22} + + n, err := printedTo.Write([]byte(all)) + r.NoError(err) + r.Equal(len(all), n) + + // What is held is bounded by the limit, not by the size of the write. + r.LessOrEqual(cap(printedTo.kept), 2*printedTo.limit) + + // The last 22 bytes start within 0095, so the lines from 0096 on are kept. + r.Equal("0096\n0097\n0098\n0099\n", string(printedTo.text())) + r.Equal(int64(len(all)), printedTo.size) +} + +func TestTail_WithNoLineBreakItStartsAtACharacter(t *testing.T) { + // The euro sign takes 3 bytes, the 3rd to the 5th. + for limit, want := range map[int]string{4: "cdef", 5: "cdef", 6: "cdef", 7: "€cdef"} { + printedTo := tail{limit: limit} + + _, err := printedTo.Write([]byte("ab€cdef")) + require.NoError(t, err) + + require.Equal(t, want, string(printedTo.text()), "limit %d", limit) + } + + // The face takes 4 bytes, the 3rd to the 6th. + for limit, want := range map[int]string{5: "cdef", 6: "cdef", 7: "cdef", 8: "\U0001F600cdef"} { + printedTo := tail{limit: limit} + + _, err := printedTo.Write([]byte("ab\U0001F600cdef")) + require.NoError(t, err) + + require.Equal(t, want, string(printedTo.text()), "limit %d", limit) + } +} diff --git a/internal/nomad/shell_test.go b/internal/nomad/shell_test.go new file mode 100644 index 0000000..dc3632b --- /dev/null +++ b/internal/nomad/shell_test.go @@ -0,0 +1,175 @@ +package nomad_test + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "slices" + "strings" + "testing" + + "github.com/gorilla/websocket" + "github.com/hashicorp/nomad/api" + "github.com/stretchr/testify/require" + + "github.com/ingvarch/urga/internal/nomad" +) + +func TestRunCommand_RunsWithoutATerminalAndKeepsWhatItPrintedInOrder(t *testing.T) { + r := require.New(t) + + var ( + query url.Values + first api.ExecStreamingInput + ) + + upgrader := websocket.Upgrader{} + + mux := http.NewServeMux() + mux.HandleFunc("/v1/allocation/alloc-1", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ID":"alloc-1","NodeID":"node-1","Namespace":"production"}`)) + }) + mux.HandleFunc("/v1/client/allocation/alloc-1/exec", func(w http.ResponseWriter, req *http.Request) { + query = req.URL.Query() + + conn, err := upgrader.Upgrade(w, req, nil) + if err != nil { + return + } + defer func() { _ = conn.Close() }() + + _ = conn.ReadJSON(&first) + + out := func(frame api.ExecStreamingOutput) { _ = conn.WriteJSON(frame) } + out(api.ExecStreamingOutput{Stdout: &api.ExecStreamingIOOperation{Data: []byte("one\n")}}) + out(api.ExecStreamingOutput{Stderr: &api.ExecStreamingIOOperation{Data: []byte("two\n")}}) + out(api.ExecStreamingOutput{Stdout: &api.ExecStreamingIOOperation{Data: []byte("three\n")}}) + out(api.ExecStreamingOutput{Exited: true, Result: &api.ExecStreamingExitResult{ExitCode: 3}}) + }) + + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + + client, err := nomad.New(nomad.Config{Address: server.URL}) + r.NoError(err) + + got, err := client.RunCommand(context.Background(), "production", "alloc-1", "server", []string{"/bin/sh", "-c", "env"}) + r.NoError(err) + + r.Equal(nomad.CommandOutput{Text: "one\ntwo\nthree\n", ExitCode: 3, Size: 14}, got) + + r.Equal("false", query.Get("tty")) + r.Equal("server", query.Get("task")) + r.Equal(`["/bin/sh","-c","env"]`, query.Get("command")) + r.Equal("production", query.Get("namespace")) + + // Nothing is typed into the command: its stdin is closed at once. + r.NotNil(first.Stdin) + r.True(first.Stdin.Close) + r.Empty(first.Stdin.Data) +} + +func TestRunCommand_ReturnsNoOutputOnAnError(t *testing.T) { + r := require.New(t) + + client, _ := recorder(t, `{}`) + + // The recorder answers every path with an object and speaks no websocket. + got, err := client.RunCommand(context.Background(), "production", "alloc-1", "server", []string{"env"}) + + r.Error(err) + r.Equal(nomad.CommandOutput{}, got) +} + +func TestRunCommand_KeepsTheWholeLinesOfTheLastMiBOfALongOutput(t *testing.T) { + r := require.New(t) + + var printed strings.Builder + for i := range 200_000 { + fmt.Fprintf(&printed, "line %06d\n", i) + } + + client := printing(t, printed.String()) + + got, err := client.RunCommand(context.Background(), "production", "alloc-1", "server", []string{"yes"}) + r.NoError(err) + + // 1 MiB holds 87381 lines of 12 bytes and the end of one more, which is + // cut and dropped. + all := printed.String() + r.Equal(all[len(all)-87381*12:], got.Text) + r.True(strings.HasSuffix(got.Text, "line 199999\n")) + r.Equal(int64(len(all)), got.Size) + r.Equal(int64(len(all)-len(got.Text)), got.From) +} + +func TestRunCommand_KeepsTheLineThatStartsWhereTheLastMiBStarts(t *testing.T) { + r := require.New(t) + + var printed strings.Builder + for i := range 100_000 { + fmt.Fprintf(&printed, "line %010d\n", i) + } + + got, err := printing(t, printed.String()).RunCommand(context.Background(), "production", "alloc-1", "server", []string{"yes"}) + r.NoError(err) + + // 1 MiB holds 65536 lines of 16 bytes, the first of them whole. + all := printed.String() + r.Equal(all[len(all)-65536*16:], got.Text) + r.Equal(int64(len(all)-1<<20), got.From) +} + +func TestRunCommand_KeepsTheLastMiBOfALineLongerThanThat(t *testing.T) { + r := require.New(t) + + all := strings.Repeat("x", 3<<19) + "\n" + + got, err := printing(t, all).RunCommand(context.Background(), "production", "alloc-1", "server", []string{"yes"}) + r.NoError(err) + + r.Equal(all[len(all)-1<<20:], got.Text) + r.Equal(int64(len(all)), got.Size) + r.Equal(int64(len(all)-1<<20), got.From) +} + +// printing is a client whose task prints the text to stdout in small frames +// and exits with 0. +func printing(t *testing.T, text string) *nomad.Client { + t.Helper() + + upgrader := websocket.Upgrader{} + + mux := http.NewServeMux() + mux.HandleFunc("/v1/allocation/alloc-1", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ID":"alloc-1","NodeID":"node-1","Namespace":"production"}`)) + }) + mux.HandleFunc("/v1/client/allocation/alloc-1/exec", func(w http.ResponseWriter, req *http.Request) { + conn, err := upgrader.Upgrade(w, req, nil) + if err != nil { + return + } + defer func() { _ = conn.Close() }() + + var first api.ExecStreamingInput + _ = conn.ReadJSON(&first) + + for chunk := range slices.Chunk([]byte(text), 4096) { + _ = conn.WriteJSON(api.ExecStreamingOutput{Stdout: &api.ExecStreamingIOOperation{Data: chunk}}) + } + + _ = conn.WriteJSON(api.ExecStreamingOutput{Exited: true, Result: &api.ExecStreamingExitResult{ExitCode: 0}}) + }) + + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + + client, err := nomad.New(nomad.Config{Address: server.URL}) + require.NoError(t, err) + + return client +} diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go index 6889802..cae1e25 100644 --- a/internal/ui/app_test.go +++ b/internal/ui/app_test.go @@ -176,6 +176,12 @@ type fakeClient struct { signalledTask string signal string + // output is what any command printed; ranCommand and ranTask are the + // last one run and the task it ran in. + output nomad.CommandOutput + ranCommand []string + ranTask string + // files are the directories of any allocation by path, and which one // was listed last. files map[string][]nomad.File @@ -926,6 +932,14 @@ func (f *fakeClient) SignalTask(_ context.Context, namespace, allocID, task, sig return f.actionErr } +func (f *fakeClient) RunCommand(_ context.Context, namespace, allocID, task string, command []string) (nomad.CommandOutput, error) { + f.wrote("RunCommand") + f.askedNamespace, f.askedID = namespace, allocID + f.ranTask, f.ranCommand = task, command + + return f.output, f.actionErr +} + func (f *fakeClient) StopAllocation(_ context.Context, namespace, allocID string) error { f.wrote("StopAllocation") f.askedNamespace, f.askedID = namespace, allocID diff --git a/internal/ui/describe.go b/internal/ui/describe.go index a62e35a..8a38942 100644 --- a/internal/ui/describe.go +++ b/internal/ui/describe.go @@ -15,6 +15,10 @@ type describeMsg struct { label string content string + // after goes at the end of the title, after what the root adds: a title + // too long for the border is cut from its end. + after string + // lines are a description urga drew, some of it in a colour of its // own; it takes the place of content. lines []paintedLine @@ -94,7 +98,7 @@ func (m Model) showDescribe(msg describeMsg) (Model, tea.Cmd) { text = paintedText(msg.lines) } - return m.push(screen{page: describePage{label: msg.label, content: text.textContent, evaluation: msg.evaluation}}) + return m.push(screen{page: describePage{label: msg.label, after: msg.after, content: text.textContent, evaluation: msg.evaluation}}) } // describePage is a description: what the cluster says of a resource, or @@ -103,6 +107,7 @@ type describePage struct { noAnswers label string + after string content textContent evaluation evalRef } @@ -117,7 +122,8 @@ func (describePage) topics() []string { return nil } func (describePage) fetch(env) tea.Cmd { return nil } func (describePage) rows(env) []tableRow { return nil } func (p describePage) text(env) textContent { return p.content } -func (p describePage) saveAs() (string, string) { return p.label, "txt" } +func (p describePage) saveAs() (string, string) { return p.label + p.after, "txt" } +func (p describePage) titleEnd() string { return p.after } func (p describePage) keys(e env) []keyHint { return hintsOf(p, e, describeKeys) } func (p describePage) press(k string, e env) (page, outcome, bool) { diff --git a/internal/ui/logs.go b/internal/ui/logs.go index 86a35f4..7ead265 100644 --- a/internal/ui/logs.go +++ b/internal/ui/logs.go @@ -341,11 +341,17 @@ func (l logState) earlier(msg logPartMsg) (logState, outcome, bool) { // span says how much of a log or a file was read when only its end was: // ", last 1.1 MiB of 5.3 MiB". func (l logState) span() string { - if l.from <= 0 { + return spanOf(l.size, l.from) +} + +// spanOf says how much of size bytes is shown when the first from bytes are +// not: ", last 1.1 MiB of 5.3 MiB", or nothing when all of it is. +func spanOf(size, from int64) string { + if from <= 0 { return "" } - return fmt.Sprintf(", last %s of %s", sizeOf(l.size-l.from), sizeOf(l.size)) + return fmt.Sprintf(", last %s of %s", sizeOf(size-from), sizeOf(size)) } // linesOf are the lines of a chunk a stream sent: the newline it ends with diff --git a/internal/ui/logtools_test.go b/internal/ui/logtools_test.go index dadb731..f6d6dd2 100644 --- a/internal/ui/logtools_test.go +++ b/internal/ui/logtools_test.go @@ -330,4 +330,7 @@ func TestLogs_ASaveNameIsAName(t *testing.T) { r.Equal("urga", plainName("")) r.Equal("web", plainName("/web/")) r.Equal("Job-web", plainName("Job: web")) + + // A long name cut on a dash does not end with one before the time. + r.Equal(strings.Repeat("a", nameLength-1), plainName(strings.Repeat("a", nameLength-1)+" b")) } diff --git a/internal/ui/namespacetasks_test.go b/internal/ui/namespacetasks_test.go index e445336..e116d47 100644 --- a/internal/ui/namespacetasks_test.go +++ b/internal/ui/namespacetasks_test.go @@ -131,6 +131,7 @@ func TestNamespaceTasks_TheHeaderShowsTheKeysOfATask(t *testing.T) { {Key: "", Description: "Shell"}, {Key: "", Description: "Restart"}, {Key: "", Description: "Signal"}, + {Key: "", Description: "Run Command"}, {Key: "", Description: "Browse"}, }, m.hints()) } diff --git a/internal/ui/page.go b/internal/ui/page.go index cfe7f5b..f201780 100644 --- a/internal/ui/page.go +++ b/internal/ui/page.go @@ -415,6 +415,12 @@ func windowKey[P page](press, label string, msg tea.Msg) pageKey[P] { return pageKey[P]{press: press, label: label, do: func(p P, _ env) (P, outcome) { return p, then(msg) }} } +// endTitled is a page whose title ends in something long, like a command, +// which goes after what the root adds to the title. +type endTitled interface { + titleEnd() string +} + // saving is a page of text that names the file it is saved to: what it is // of, and the extension. type saving interface { diff --git a/internal/ui/roles.go b/internal/ui/roles.go index 69a0a07..14f8c80 100644 --- a/internal/ui/roles.go +++ b/internal/ui/roles.go @@ -97,6 +97,7 @@ type ( RestartAllTasks(ctx context.Context, namespace, allocID string) error RestartTask(ctx context.Context, namespace, allocID, task string) error SignalTask(ctx context.Context, namespace, allocID, task, signal string) error + RunCommand(ctx context.Context, namespace, allocID, task string, command []string) (nomad.CommandOutput, error) StopAllocation(ctx context.Context, namespace, allocID string) error } diff --git a/internal/ui/save.go b/internal/ui/save.go index 63d416f..651f12e 100644 --- a/internal/ui/save.go +++ b/internal/ui/save.go @@ -120,11 +120,16 @@ func saveName(p page) string { var notInAName = regexp.MustCompile(`[^a-zA-Z0-9._-]+`) +// nameLength keeps a name, with the time and extension after it, well under +// the 255 bytes a file name can hold. +const nameLength = 100 + func plainName(what string) string { name := strings.Trim(notInAName.ReplaceAllString(what, "-"), "-") if name == "" { return "urga" } - return name + // The name is ASCII, so any byte is a rune boundary. + return strings.TrimRight(name[:min(len(name), nameLength)], "-") } diff --git a/internal/ui/screen.go b/internal/ui/screen.go index 885cb5f..cf1e38c 100644 --- a/internal/ui/screen.go +++ b/internal/ui/screen.go @@ -209,6 +209,10 @@ func (m Model) title() string { title += sprintf(", from column %d", m.text.shift+1) } + if p, ok := m.screen.page.(endTitled); ok { + title += p.titleEnd() + } + return title } diff --git a/internal/ui/taskactions.go b/internal/ui/taskactions.go index 2e9c78d..37731bc 100644 --- a/internal/ui/taskactions.go +++ b/internal/ui/taskactions.go @@ -54,12 +54,13 @@ func taskKeys[P taskLister]() []pageKey[P] { {press: "s", label: "Shell", do: shell[P], writes: true}, {press: "r", label: "Restart", do: restartTask[P], writes: true, offered: taskRuns[P]}, {press: "x", label: "Signal", do: askSignal[P], writes: true, offered: taskRuns[P]}, + {press: "ctrl+x", label: "Run Command", do: askCommand[P], writes: true, offered: taskRuns[P]}, {press: "b", label: "Browse", do: browse[P]}, } } -// taskRuns says the task under the cursor runs: its client restarts or -// signals only a running task. +// taskRuns says the task under the cursor runs. Its client acts only on a +// running task: restart, signal, run a command. func taskRuns[P taskLister](p P, e env) bool { task, ok := p.pickedTask(e) diff --git a/internal/ui/taskrun.go b/internal/ui/taskrun.go new file mode 100644 index 0000000..0d80804 --- /dev/null +++ b/internal/ui/taskrun.go @@ -0,0 +1,70 @@ +package ui + +import ( + "context" + "errors" + "fmt" + "strings" + + tea "charm.land/bubbletea/v2" +) + +// askCommand asks which command to run in the task under the cursor. +func askCommand[P taskLister](p P, e env) (P, outcome) { + task, ok := p.pickedTask(e) + if !ok { + return p, outcome{} + } + + return p, then(lineMsg{ + prefix: fmt.Sprintf("run in %s: ", task.name), + answer: func(m Model, typed string) (Model, tea.Cmd) { return m.runIn(task.taskRef, typed) }, + }) +} + +// runIn runs the command typed in the task. It asks no question first: the +// command was typed and entered, as a shell is opened without one. +func (m Model) runIn(task taskRef, typed string) (Model, tea.Cmd) { + typed = strings.TrimSpace(typed) + if typed == "" { + return m.warn("No command to run."), nil + } + + return m, runCommand(m.client, task, typed) +} + +// runCommand runs the line in a shell of the task, so a pipe, quotes and +// variables work as typed, and opens what it printed. +func runCommand(client allocsClient, task taskRef, line string) tea.Cmd { + return request(func(ctx context.Context) (describeMsg, error) { + out, err := client.RunCommand(ctx, task.namespace, task.allocID, task.name, []string{"/bin/sh", "-c", line}) + if errors.Is(err, context.DeadlineExceeded) { + return describeMsg{}, fmt.Errorf("%s did not end within %s", line, defaultTimeout) + } + + if err != nil { + return describeMsg{}, err + } + + // The command goes last: a title too long for the border is cut from + // its end. + return describeMsg{ + label: fmt.Sprintf("Output (Task: %s, Allocation: %s) [exit %d%s]", + task.name, shortID(task.allocID), out.ExitCode, spanOf(out.Size, out.From)), + after: ": " + line, + content: printedText(out.Text), + }, nil + }, func(msg describeMsg) tea.Msg { return msg }) +} + +// printedText is what a command printed, as a log shows it: no empty last +// line, and no control characters. +func printedText(text string) string { + lines := linesOf(text) + + for i, line := range lines { + lines[i] = cleanLine(line) + } + + return strings.Join(lines, "\n") +} diff --git a/internal/ui/taskrun_test.go b/internal/ui/taskrun_test.go new file mode 100644 index 0000000..43aaa3a --- /dev/null +++ b/internal/ui/taskrun_test.go @@ -0,0 +1,258 @@ +package ui + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/stretchr/testify/require" + + "github.com/ingvarch/urga/internal/nomad" +) + +// ranCommand asks for a command on the task under the cursor and enters it. +func ranCommand(m Model, line string) (Model, tea.Cmd) { + m, _ = m.update(ctrl('x')) + m = typeIn(m, line) + + return m.update(enter()) +} + +func TestTaskRun_RunTheCommandAndOpenWhatItPrinted(t *testing.T) { + r := require.New(t) + + client := &fakeClient{jobs: twoJobs(), allocs: twoAllocs(), output: nomad.CommandOutput{Text: "nameserver 10.0.0.2\n", Size: 20}} + m := openTasks(t, client) + + m, _ = m.update(ctrl('x')) + + r.Equal(overlayAnswer, m.overlay) + r.Contains(plain(m.render()), "run in server: ") + r.Empty(m.prompt.text) + + m = typeIn(m, "cat /etc/resolv.conf") + m, cmd := m.update(enter()) + + // No question first: the command was typed and entered. + r.NotEqual(overlayConfirm, m.overlay) + + m = follow(m, cmd, 2) + + r.Equal([]string{"/bin/sh", "-c", "cat /etc/resolv.conf"}, client.ranCommand) + r.Equal("server", client.ranTask) + r.Equal(frontendAlloc, client.askedID) + r.Equal("production", client.askedNamespace) + + r.IsType(describePage{}, m.screen.page) + r.Equal("Output (Task: server, Allocation: af1f37df) [exit 0]: cat /etc/resolv.conf", m.title()) + r.Contains(plain(m.render()), "nameserver 10.0.0.2") +} + +func TestTaskRun_TheSpacesAroundTheCommandAreDropped(t *testing.T) { + r := require.New(t) + + client := &fakeClient{jobs: twoJobs(), allocs: twoAllocs(), output: nomad.CommandOutput{Text: "PATH=/bin\n", Size: 10}} + m := openTasks(t, client) + + m, _ = m.update(ctrl('x')) + m = typeIn(m, " env ") + m, cmd := m.update(enter()) + m = follow(m, cmd, 2) + + r.Equal([]string{"/bin/sh", "-c", "env"}, client.ranCommand) + r.Equal("Output (Task: server, Allocation: af1f37df) [exit 0]: env", m.title()) +} + +func TestTaskRun_TheExitCodeIsInTheTitleAndTheTextStillOpens(t *testing.T) { + r := require.New(t) + + client := &fakeClient{jobs: twoJobs(), allocs: twoAllocs(), output: nomad.CommandOutput{Text: "no such file\n", ExitCode: 2}} + m := openTasks(t, client) + + m, cmd := ranCommand(m, "cat /nope") + m = follow(m, cmd, 2) + + r.Contains(plain(m.render()), "Output (Task: server, Allocation: af1f37df) [exit 2]: cat /nope") + r.Contains(plain(m.render()), "no such file") +} + +func TestTaskRun_TheTitleSaysWhenOnlyTheEndOfALongOutputIsKept(t *testing.T) { + r := require.New(t) + + output := nomad.CommandOutput{Text: "y\ny\n", Size: 37 << 20, From: 36 << 20} + client := &fakeClient{jobs: twoJobs(), allocs: twoAllocs(), output: output} + m := openTasks(t, client) + + m, cmd := ranCommand(m, "yes") + m = follow(m, cmd, 2) + + r.Equal("Output (Task: server, Allocation: af1f37df) [exit 0, last 1.0 MiB of 37.0 MiB]: yes", m.title()) +} + +func TestTaskRun_ALongCommandDoesNotHideTheExitCode(t *testing.T) { + r := require.New(t) + + client := &fakeClient{jobs: twoJobs(), allocs: twoAllocs(), output: nomad.CommandOutput{Text: "done\n", ExitCode: 3}} + m := openTasks(t, client) + m, _ = m.update(tea.WindowSizeMsg{Width: 80, Height: 30}) + + command := "echo " + strings.Repeat("x", 55) + r.Len(command, 60) + + m, cmd := ranCommand(m, command) + m = follow(m, cmd, 2) + + // A title too long for the border is cut from its end, so the command + // goes last. + r.Contains(outputTitle(m), "[exit 3]") +} + +func TestTaskRun_MovedSidewaysTheTitleShowsTheColumnBeforeTheCommand(t *testing.T) { + r := require.New(t) + + client := &fakeClient{jobs: twoJobs(), allocs: twoAllocs(), output: nomad.CommandOutput{Text: longLine()}} + m := openTasks(t, client) + m, _ = m.update(tea.WindowSizeMsg{Width: 80, Height: 30}) + + m, cmd := ranCommand(m, "cat /etc/resolv.conf") + m = follow(m, cmd, 2) + m = right(m) + + column := sprintf("from column %d", m.text.shift+1) + + r.Equal("Output (Task: server, Allocation: af1f37df) [exit 0], "+column+": cat /etc/resolv.conf", m.title()) + r.Contains(outputTitle(m), column) +} + +// outputTitle is the line of the screen that holds the title of an output. +func outputTitle(m Model) string { + title := "" + + for _, line := range strings.Split(plain(m.render()), "\n") { + if strings.Contains(line, "Output") { + title = line + } + } + + return title +} + +func TestTaskRun_CopyTheOutput(t *testing.T) { + r := require.New(t) + + client := &fakeClient{jobs: twoJobs(), allocs: twoAllocs(), output: nomad.CommandOutput{Text: "A=1\nB=2\n"}} + m := openTasks(t, client) + + m, cmd := ranCommand(m, "env") + m = follow(m, cmd, 2) + + _, text, ok := clipped(m) + + r.True(ok) + r.Equal("A=1\nB=2", text) +} + +func TestTaskRun_SaveTheOutputOfALongCommand(t *testing.T) { + r := require.New(t) + + dir := t.TempDir() + t.Chdir(dir) + + client := &fakeClient{jobs: twoJobs(), allocs: twoAllocs(), output: nomad.CommandOutput{Text: "done\n"}} + m := openTasks(t, client) + + m, cmd := ranCommand(m, "echo "+strings.Repeat("x", 300)) + m = follow(m, cmd, 2) + + m, _ = m.update(ctrlKey('s')) + m, cmd = m.update(enter()) + m = drain(m, cmd) + + files, err := os.ReadDir(dir) + r.NoError(err) + r.Len(files, 1, m.flash.text) + + r.Contains(files[0].Name(), "-echo-xxx") + + saved, err := os.ReadFile(filepath.Join(dir, files[0].Name())) + r.NoError(err) + r.Equal("done", strings.TrimSpace(string(saved))) +} + +func TestTaskRun_ABlankLineRunsNothing(t *testing.T) { + r := require.New(t) + + client := &fakeClient{jobs: twoJobs(), allocs: twoAllocs()} + m := openTasks(t, client) + + m, cmd := ranCommand(m, " ") + + r.Nil(cmd) + r.Empty(client.writes) + r.Equal(flashWarn, m.flash.level) + r.Contains(plain(m.render()), "No command to run.") +} + +func TestTaskRun_ACommandThatDoesNotEndShowsTheLimit(t *testing.T) { + r := require.New(t) + + client := &fakeClient{jobs: twoJobs(), allocs: twoAllocs(), actionErr: context.DeadlineExceeded} + m := openTasks(t, client) + + m, cmd := ranCommand(m, "sleep 60") + m = follow(m, cmd, 2) + + r.Contains(plain(m.render()), "sleep 60 did not end within 10s") + r.IsType(tasksPage{}, m.screen.page) +} + +func TestTaskRun_AnotherErrorShowsAsItIs(t *testing.T) { + r := require.New(t) + + client := &fakeClient{jobs: twoJobs(), allocs: twoAllocs(), actionErr: context.Canceled} + m := openTasks(t, client) + + m, cmd := ranCommand(m, "env") + m = follow(m, cmd, 2) + + r.Contains(plain(m.render()), "context canceled") +} + +func TestTaskRun_OnlyARunningTaskRunsACommand(t *testing.T) { + r := require.New(t) + + m := openTasks(t, &fakeClient{jobs: twoJobs(), allocs: twoAllocs()}) + r.True(offers(m, "ctrl-x")) + + // The sidecar is dead. + m, _ = m.update(key('j')) + + r.False(offers(m, "ctrl-x")) +} + +func TestTaskRun_OnTheTasksOfTheNamespaceItRunsInTheAllocationOfTheRow(t *testing.T) { + r := require.New(t) + + client := &fakeClient{allocs: staged(), output: nomad.CommandOutput{Text: "ok\n"}} + m := onNamespaceTasks(t, client, nomad.AllNamespaces) + + m, cmd := ranCommand(m, "env") + follow(m, cmd, 2) + + r.Equal("server", client.ranTask) + r.Equal(frontendAlloc, client.askedID) + r.Equal("staging", client.askedNamespace) +} + +func TestPrintedText(t *testing.T) { + r := require.New(t) + + // Colours stay, other escape sequences and control characters go, and + // the last newline does not make an empty line. + r.Equal("\x1b[31mred\x1b[0m\nplain\n", printedText("\x1b[31mred\x1b[0m\x00\nplain\x07\n\n")) + r.Equal("a b", printedText("a\tb\n")) + r.Equal("", printedText("")) +} diff --git a/internal/ui/tasks_test.go b/internal/ui/tasks_test.go index 7941741..8d6a0fc 100644 --- a/internal/ui/tasks_test.go +++ b/internal/ui/tasks_test.go @@ -153,6 +153,7 @@ func TestTasks_TheKeysOfATask(t *testing.T) { {Key: "", Description: "Shell"}, {Key: "", Description: "Restart"}, {Key: "", Description: "Signal"}, + {Key: "", Description: "Run Command"}, {Key: "", Description: "Browse"}, } r.Equal(keys, m.hints()) diff --git a/internal/ui/writes_test.go b/internal/ui/writes_test.go index 4ac9122..db74a14 100644 --- a/internal/ui/writes_test.go +++ b/internal/ui/writes_test.go @@ -20,7 +20,7 @@ var ( clientWrites = []string{ "SubmitJob", "SubmitNamespace", "SubmitNodeMeta", "SubmitVariable", "StartJob", "StopJob", "LaunchJob", "RevertJobTo", "ScaleJob", - "RestartAllocation", "RestartAllTasks", "StopAllocation", "RestartTask", "SignalTask", "PromoteGroups", "PauseDeployment", "DeleteServiceRegistration", + "RestartAllocation", "RestartAllTasks", "StopAllocation", "RestartTask", "SignalTask", "RunCommand", "PromoteGroups", "PauseDeployment", "DeleteServiceRegistration", "DrainNode", "SetNodeEligible", "RenewNodeIdentity", "PromoteDeployment", "FailDeployment", "DetachVolume", "ReleaseClaim", "DispatchJob", "EvaluateJob", "RescheduleFailed", From a72fa7e41e473abdc4b26538628889ccae9e6094 Mon Sep 17 00:00:00 2001 From: Igor Churmeev Date: Tue, 20 Oct 2026 08:00:20 +0000 Subject: [PATCH 4/4] fix(ui): give no column the sort letter G, which goes to the bottom --- docs/keys.md | 9 +++++---- internal/ui/sort.go | 5 +++-- internal/ui/sort_test.go | 9 +++++++++ 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/docs/keys.md b/docs/keys.md index db916e3..26c4a90 100644 --- a/docs/keys.md +++ b/docs/keys.md @@ -168,10 +168,11 @@ Shift and a letter order the list by a column; the same key again turns the order round, and the title of the column shows which way with an arrow. Every column has a letter of its own. The common ones keep the letters you know from other tools: `N` for a name, `A` for an age, `S` for a status or a -state, `P` for a namespace. Any other column takes the first letter of its -title that is free, so on `:events` `T` is the topic and `Y` the type. The -header lists the letters of the screen that is open, next to the namespaces, -and so does `?`. +state, `P` for a namespace, `R` for restarts. Any other column takes the +first letter of its title that is free, so on `:events` `T` is the topic and +`Y` the type. `G` is never a column's letter, because it goes to the bottom. +The header lists the letters of the screen that is open, next to the +namespaces, and so does `?`. ## Filter diff --git a/internal/ui/sort.go b/internal/ui/sort.go index 4405866..ecaadc7 100644 --- a/internal/ui/sort.go +++ b/internal/ui/sort.go @@ -45,15 +45,16 @@ func (s sortState) marker(column int) string { // knownLetters are the letters of the columns most lists have, the ones // people know from other tools: they stay whatever else a list shows. -var knownLetters = map[string]rune{"Name": 'n', "Age": 'a', "Status": 's', "State": 's', "Namespace": 'p'} +var knownLetters = map[string]rune{"Name": 'n', "Age": 'a', "Status": 's', "State": 's', "Namespace": 'p', "Restarts": 'r'} // sortLetters is the letter of each column that orders the list by it: the // known one of a common column, otherwise the first letter of the title that // no other column has. Two columns that start alike each get one, the way // Topic and Type get T and Y. A column whose letters are all taken gets none. +// G is never one: it goes to the bottom. func sortLetters(titles []string) []rune { letters := make([]rune, len(titles)) - taken := map[rune]bool{} + taken := map[rune]bool{'g': true} for i, title := range titles { if letter, ok := knownLetters[title]; ok && !taken[letter] { diff --git a/internal/ui/sort_test.go b/internal/ui/sort_test.go index 5ef9248..97f3d2e 100644 --- a/internal/ui/sort_test.go +++ b/internal/ui/sort_test.go @@ -258,6 +258,15 @@ func TestSort_EveryColumnHasALetterOfItsOwn(t *testing.T) { r.Equal([]rune{'i', 'n', 't', 'p', 's', 'l', 'a'}, sortLetters(jobTitles)) } +func TestSort_NoColumnTakesTheLetterOfTheBottom(t *testing.T) { + r := require.New(t) + + // G goes to the bottom of every list, so Group takes its next free + // letter, and on the tasks of the namespace R stays with the restarts. + r.Equal([]rune{'r', 'd', 'p', 'h', 'u', 'c', 'o', 'a', 'e', 's'}, sortLetters(groupTitles)) + r.Equal([]rune{'j', 'p', 'o', 't', 'a', 'n', 's', 'r', 'e', 'f', 'u'}, sortLetters(namespaceTaskTitles)) +} + func TestSort_HelpNamesTheLetterOfEachColumn(t *testing.T) { r := require.New(t)