diff --git a/.gitignore b/.gitignore index dffe59e7..7c3fa96b 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ vendor ignore.gif **/*/my_logs.txt opt +.vscode +__debug_bin* + diff --git a/README.md b/README.md index 9b35a387..e8a6d378 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ An efficient terminal application/TUI for interacting with your [HashiCorp Nomad](https://www.nomadproject.io/) cluster. - Browse jobs, allocations, and tasks +- Restart tasks - Live tail logs - Tail global or targeted events - Exec to interact with running tasks diff --git a/internal/dev/dev.go b/internal/dev/dev.go index ce5d55ea..32c56498 100644 --- a/internal/dev/dev.go +++ b/internal/dev/dev.go @@ -9,15 +9,20 @@ import ( var debugSet = os.Getenv("WANDER_DEBUG") -// dev -func Debug(msg string) { - if debugSet != "" { - f, err := tea.LogToFile("wander.log", "") - if err != nil { - fmt.Println("fatal:", err) - os.Exit(1) +// Returns a function that prints a message to the log file if the WANDER_DEBUG +// environment variable is set. +func createDebug(path string) func(string) { + return func (msg string) { + if debugSet != "" { + f, err := tea.LogToFile(path, "") + if err != nil { + fmt.Println("fatal:", err) + os.Exit(1) + } + log.Printf("%q", msg) + defer f.Close() } - log.Printf("%q", msg) - defer f.Close() } } + +var Debug = createDebug("wander.log") diff --git a/internal/tui/components/app/app.go b/internal/tui/components/app/app.go index 1af6d9e9..1a741628 100644 --- a/internal/tui/components/app/app.go +++ b/internal/tui/components/app/app.go @@ -2,11 +2,18 @@ package app import ( "fmt" + "os" + "os/exec" + "path" + "strings" + "time" + "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" "github.com/hashicorp/nomad/api" "github.com/itchyny/gojq" "github.com/robinovitch61/wander/internal/dev" + "github.com/robinovitch61/wander/internal/tui/components/toast" "github.com/robinovitch61/wander/internal/tui/components/header" "github.com/robinovitch61/wander/internal/tui/components/page" "github.com/robinovitch61/wander/internal/tui/constants" @@ -15,11 +22,6 @@ import ( "github.com/robinovitch61/wander/internal/tui/message" "github.com/robinovitch61/wander/internal/tui/nomad" "github.com/robinovitch61/wander/internal/tui/style" - "os" - "os/exec" - "path" - "strings" - "time" ) type TLSConfig struct { @@ -61,13 +63,13 @@ type Config struct { } type Model struct { - config Config - client api.Client + config Config + client api.Client - header header.Model - compact bool - currentPage nomad.Page - pageModels map[nomad.Page]*page.Model + header header.Model + compact bool + currentPage nomad.Page + pageModels map[nomad.Page]*page.Model inJobsMode bool jobID string @@ -88,6 +90,9 @@ type Model struct { logsStream nomad.LogsStream lastLogFinished bool + // adminAction is a key of TaskAdminActions (or JobAdminActions, when it exists) + adminAction nomad.AdminAction + width, height int initialized bool err error @@ -180,8 +185,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // but returns empty results when one provides an empty token m.getCurrentPageModel().SetHeader([]string{"Error"}) m.getCurrentPageModel().SetAllPageRows([]page.Row{ - {"", "No results. Is the cluster empty or was no nomad token provided?"}, - {"", "Press q or ctrl+c to quit."}, + {Key: "", Row: "No results. Is the cluster empty or was no nomad token provided?"}, + {Key: "", Row: "Press q or ctrl+c to quit."}, }) m.getCurrentPageModel().SetViewportSelectionEnabled(false) } @@ -296,6 +301,28 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return nomad.ExecCompleteMsg{Output: string(stdoutProxy.SavedOutput)} }) } + + case nomad.TaskAdminActionCompleteMsg: + m.getCurrentPageModel().SetToast( + toast.New( + fmt.Sprintf( + "%s completed successfully", + nomad.GetTaskAdminText( + m.adminAction, msg.TaskName, msg.AllocName, msg.AllocID))), + style.SuccessToast, + ) + + case nomad.TaskAdminActionFailedMsg: + m.getCurrentPageModel().SetToast( + toast.New( + fmt.Sprintf( + "%s failed with error: %s", + nomad.GetTaskAdminText( + m.adminAction, msg.TaskName, msg.AllocName, msg.AllocID), + msg.Error())), + style.ErrorToast, + ) + } currentPageModel = m.getCurrentPageModel() @@ -375,6 +402,7 @@ func (m *Model) handleKeyMsg(msg tea.KeyMsg) tea.Cmd { if !m.currentPageFilterFocused() && !m.currentPageViewportSaving() { switch { + case key.Matches(msg, keymap.KeyMap.Compact): m.toggleCompact() return nil @@ -388,6 +416,18 @@ func (m *Model) handleKeyMsg(msg tea.KeyMsg) tea.Cmd { m.event = selectedPageRow.Key case nomad.LogsPage: m.logline = selectedPageRow.Row + case nomad.TaskAdminPage: + m.adminAction = nomad.KeyToAdminAction(selectedPageRow.Key) + case nomad.TaskAdminConfirmPage: + if selectedPageRow.Key == constants.ConfirmationKey { + cmds = append(cmds, nomad.GetCmdForTaskAdminAction( + m.client, m.adminAction, m.taskName, m.alloc.Name, m.alloc.ID)) + } else { + backPage := m.currentPage.Backward(m.inJobsMode) + m.setPage(backPage) + cmds = append(cmds, m.getCurrentPageCmd()) + return tea.Batch(cmds...) + } default: if m.currentPage.ShowsTasks() { taskInfo, err := nomad.TaskInfoFromKey(selectedPageRow.Key) @@ -399,10 +439,11 @@ func (m *Model) handleKeyMsg(msg tea.KeyMsg) tea.Cmd { } } - nextPage := m.currentPage.Forward() + nextPage := m.currentPage.Forward(m.inJobsMode) if nextPage != m.currentPage { m.setPage(nextPage) - return m.getCurrentPageCmd() + cmds = append(cmds, m.getCurrentPageCmd()) + return tea.Batch(cmds...) } } @@ -426,6 +467,7 @@ func (m *Model) handleKeyMsg(msg tea.KeyMsg) tea.Cmd { m.getCurrentPageModel().SetLoading(true) return m.getCurrentPageCmd() } + } if key.Matches(msg, keymap.KeyMap.Exec) { @@ -534,6 +576,22 @@ func (m *Model) handleKeyMsg(msg tea.KeyMsg) tea.Cmd { return m.getCurrentPageCmd() } + if key.Matches(msg, keymap.KeyMap.AdminMenu) && m.currentPage.HasAdminMenu() { + if selectedPageRow, err := m.getCurrentPageModel().GetSelectedPageRow(); err == nil { + // Get task info from the currently selected row + taskInfo, err := nomad.TaskInfoFromKey(selectedPageRow.Key) + if err != nil { + m.err = err + return nil + } + if taskInfo.Running { + m.alloc, m.taskName = taskInfo.Alloc, taskInfo.TaskName + m.setPage(nomad.TaskAdminPage) + return m.getCurrentPageCmd() + } + } + } + if m.currentPage == nomad.LogsPage { switch { case key.Matches(msg, keymap.KeyMap.StdOut): @@ -654,8 +712,40 @@ func (m Model) getCurrentPageCmd() tea.Cmd { return nomad.PrettifyLine(m.logline, nomad.LoglinePage) case nomad.StatsPage: return nomad.FetchStats(m.client, m.alloc.ID, m.alloc.Name) + case nomad.TaskAdminPage: + return func() tea.Msg { + // this does no async work, just constructs the task admin menu + var rows []page.Row + for action := range nomad.TaskAdminActions { + rows = append(rows, page.Row{ + Key: nomad.AdminActionToKey(action), + Row: nomad.GetTaskAdminText(action, m.taskName, m.alloc.Name, m.alloc.ID), + }) + } + return nomad.PageLoadedMsg{ + Page: nomad.TaskAdminPage, + TableHeader: []string{"Available Admin Actions"}, + AllPageRows: rows, + } + } + + case nomad.TaskAdminConfirmPage: + return func() tea.Msg { + // this does no async work, just constructs the confirmation page + confirmationText := nomad.GetTaskAdminText(m.adminAction, m.taskName, m.alloc.Name, m.alloc.ID) + confirmationText = strings.ToLower(confirmationText[:1]) + confirmationText[1:] + return nomad.PageLoadedMsg{ + Page: nomad.TaskAdminConfirmPage, + TableHeader: []string{"Are you sure?"}, + AllPageRows: []page.Row{ + {Key: "Cancel", Row: "Cancel"}, + {Key: constants.ConfirmationKey, Row: fmt.Sprintf("Yes, %s", confirmationText)}, + }, + } + } + default: - panic("page load command not found") + panic(fmt.Sprintf("Load command for page:%s not found", m.currentPage)) } } diff --git a/internal/tui/components/page/page.go b/internal/tui/components/page/page.go index acc49192..3b95f0dd 100644 --- a/internal/tui/components/page/page.go +++ b/internal/tui/components/page/page.go @@ -2,6 +2,8 @@ package page import ( "fmt" + "strings" + "github.com/atotto/clipboard" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/textinput" @@ -14,7 +16,6 @@ import ( "github.com/robinovitch61/wander/internal/tui/constants" "github.com/robinovitch61/wander/internal/tui/keymap" "github.com/robinovitch61/wander/internal/tui/message" - "strings" ) type Config struct { @@ -245,6 +246,10 @@ func (m *Model) SetViewportXOffset(n int) { m.viewport.SetXOffset(n) } +func (m *Model) SetToast(toast toast.Model, style lipgloss.Style) { + m.viewport.SetToast(toast, style) +} + func (m *Model) HideToast() { m.viewport.HideToast() } diff --git a/internal/tui/components/viewport/viewport.go b/internal/tui/components/viewport/viewport.go index 3539e8f0..a629dbf6 100644 --- a/internal/tui/components/viewport/viewport.go +++ b/internal/tui/components/viewport/viewport.go @@ -354,6 +354,11 @@ func (m *Model) ToggleWrapText() { m.updateForWrapText() } +func (m *Model) SetToast(toast toast.Model, style lipgloss.Style) { + m.toast = toast + m.toast.MessageStyle = style.Copy().Width(m.width) +} + func (m *Model) HideToast() { m.toast.Visible = false } diff --git a/internal/tui/constants/constants.go b/internal/tui/constants/constants.go index 4ad836b1..73b2ad06 100644 --- a/internal/tui/constants/constants.go +++ b/internal/tui/constants/constants.go @@ -34,3 +34,5 @@ const DefaultEventJQQuery = `.Events[] | {"1:Index": .Index, "2:Topic": .Topic, // DefaultAllocEventJQQuery is a single line as this shows up verbatim in `wander --help` const DefaultAllocEventJQQuery = `.Index as $index | .Events[] | .Type as $type | .Payload.Allocation | .DeploymentStatus.Healthy as $healthy | .ClientStatus as $clientStatus | .Name as $allocName | (.TaskStates // {"":{"Events": [{}]}}) | to_entries[] | .key as $k | .value.Events[] | {"0:Index": $index, "1:AllocName": $allocName, "2:TaskName": $k, "3:Type": $type, "4:Time": ((.Time // 0) / 1000000000 | todate), "5:Msg": .DisplayMessage, "6:Healthy": $healthy, "7:ClientStatus": $clientStatus}` + +const ConfirmationKey = "Yes" diff --git a/internal/tui/keymap/keymap.go b/internal/tui/keymap/keymap.go index 22159e4d..97e7306b 100644 --- a/internal/tui/keymap/keymap.go +++ b/internal/tui/keymap/keymap.go @@ -25,6 +25,7 @@ type keyMap struct { StdErr key.Binding Spec key.Binding Wrap key.Binding + AdminMenu key.Binding } var KeyMap = keyMap{ @@ -108,4 +109,8 @@ var KeyMap = keyMap{ key.WithKeys("ctrl+w"), key.WithHelp("ctrl+w", "toggle wrap"), ), + AdminMenu: key.NewBinding( + key.WithKeys("X"), + key.WithHelp("X", "admin"), + ), } diff --git a/internal/tui/nomad/jobtasks.go b/internal/tui/nomad/jobtasks.go index 412c3864..877680f3 100644 --- a/internal/tui/nomad/jobtasks.go +++ b/internal/tui/nomad/jobtasks.go @@ -1,3 +1,7 @@ +/* +Task related functions +*/ + package nomad import ( diff --git a/internal/tui/nomad/pages.go b/internal/tui/nomad/pages.go index b8e10bb1..8af64802 100644 --- a/internal/tui/nomad/pages.go +++ b/internal/tui/nomad/pages.go @@ -36,6 +36,8 @@ const ( LogsPage LoglinePage StatsPage + TaskAdminPage + TaskAdminConfirmPage ) func GetAllPageConfigs(width, height int, compactTables bool) map[Page]page.Config { @@ -132,6 +134,16 @@ func GetAllPageConfigs(width, height int, compactTables bool) map[Page]page.Conf LoadingString: StatsPage.LoadingString(), SelectionEnabled: false, WrapText: false, RequestInput: false, }, + TaskAdminPage: { + Width: width, Height: height, + LoadingString: TaskAdminPage.LoadingString(), + SelectionEnabled: true, WrapText: false, RequestInput: false, + }, + TaskAdminConfirmPage: { + Width: width, Height: height, + LoadingString: TaskAdminConfirmPage.LoadingString(), + SelectionEnabled: true, WrapText: false, RequestInput: false, + }, } } @@ -165,6 +177,16 @@ func (p Page) ShowsTasks() bool { return false } +func (p Page) HasAdminMenu() bool { + adminMenuPages := []Page{AllTasksPage, JobTasksPage} + for _, adminMenuPage := range adminMenuPages { + if adminMenuPage == p { + return true + } + } + return false +} + func (p Page) CanBeFirstPage() bool { return p == JobsPage || p == AllTasksPage } @@ -226,6 +248,10 @@ func (p Page) String() string { return "log" case StatsPage: return "stats" + case TaskAdminPage: + return "task admin menu" + case TaskAdminConfirmPage: + return "confirm" } return "unknown" } @@ -234,7 +260,7 @@ func (p Page) LoadingString() string { return fmt.Sprintf("Loading %s...", p.String()) } -func (p Page) Forward() Page { +func (p Page) Forward(inJobsMode bool) Page { switch p { case JobsPage: return JobTasksPage @@ -250,6 +276,10 @@ func (p Page) Forward() Page { return LogsPage case LogsPage: return LoglinePage + case TaskAdminPage: + return TaskAdminConfirmPage + case TaskAdminConfirmPage: + return returnToTasksPage(inJobsMode) } return p } @@ -293,6 +323,10 @@ func (p Page) Backward(inJobsMode bool) Page { return LogsPage case StatsPage: return returnToTasksPage(inJobsMode) + case TaskAdminPage: + return returnToTasksPage(inJobsMode) + case TaskAdminConfirmPage: + return TaskAdminPage } return p } @@ -348,6 +382,10 @@ func (p Page) GetFilterPrefix(namespace, jobID, taskName, allocName, allocID str return fmt.Sprintf("Log Line for Task %s", taskFilterPrefix(taskName, allocName)) case StatsPage: return fmt.Sprintf("Stats for Allocation %s", allocName) + case TaskAdminPage: + return fmt.Sprintf("Admin Actions for Task %s (%s)", taskFilterPrefix(taskName, allocName), formatter.ShortAllocID(allocID)) + case TaskAdminConfirmPage: + return fmt.Sprintf("Confirm Admin Action for Task %s (%s)", taskFilterPrefix(taskName, allocName), formatter.ShortAllocID(allocID)) default: panic("page not found") } @@ -377,6 +415,7 @@ type UpdatePageDataMsg struct { Page Page } +// Update page data with a delay. This is useful for pages that update. func UpdatePageDataWithDelay(id int, p Page, d time.Duration) tea.Cmd { if p.doesUpdate() && d > 0 { return tea.Tick(d, func(t time.Time) tea.Msg { return UpdatePageDataMsg{id, p} }) @@ -427,11 +466,16 @@ func GetPageKeyHelp( viewportKeyMap := viewport.GetKeyMap() secondRow := []key.Binding{viewportKeyMap.Save, keymap.KeyMap.Wrap} + + if currentPage.HasAdminMenu() { + secondRow = append(secondRow, keymap.KeyMap.AdminMenu) + } + thirdRow := []key.Binding{viewportKeyMap.Down, viewportKeyMap.Up, viewportKeyMap.PageDown, viewportKeyMap.PageUp, viewportKeyMap.Bottom, viewportKeyMap.Top} var fourthRow []key.Binding - if nextPage := currentPage.Forward(); nextPage != currentPage { - changeKeyHelp(&keymap.KeyMap.Forward, currentPage.Forward().String()) + if nextPage := currentPage.Forward(inJobsMode); nextPage != currentPage { + changeKeyHelp(&keymap.KeyMap.Forward, currentPage.Forward(inJobsMode).String()) fourthRow = append(fourthRow, keymap.KeyMap.Forward) } diff --git a/internal/tui/nomad/taskadmin.go b/internal/tui/nomad/taskadmin.go new file mode 100644 index 00000000..3e1c203b --- /dev/null +++ b/internal/tui/nomad/taskadmin.go @@ -0,0 +1,77 @@ +/* Admin Actions for tasks +Restart, Stop, etc. +*/ +package nomad + +import ( + "fmt" + tea "github.com/charmbracelet/bubbletea" + "github.com/hashicorp/nomad/api" + "github.com/robinovitch61/wander/internal/tui/formatter" +) + +var ( + // TaskAdminActions maps task-specific AdminActions to their display text + TaskAdminActions = map[AdminAction]string{ + RestartTaskAction: "Restart", + //StopTaskAction: "Stop", + } +) + +type TaskAdminActionCompleteMsg struct { + TaskName, AllocName, AllocID string +} + +type TaskAdminActionFailedMsg struct { + Err error + TaskName, AllocName, AllocID string +} + +func (e TaskAdminActionFailedMsg) Error() string { return e.Err.Error() } + +func GetTaskAdminText( + adminAction AdminAction, taskName, allocName, allocID string) string { + return fmt.Sprintf( + "%s task %s in %s (%s)", + TaskAdminActions[adminAction], + taskName, allocName, formatter.ShortAllocID(allocID)) +} + +func GetCmdForTaskAdminAction( + client api.Client, + adminAction AdminAction, + taskName, + allocName, + allocID string, +) tea.Cmd { + switch adminAction { + case RestartTaskAction: + return RestartTask(client, taskName, allocName, allocID) + //case StopTaskAction: + // return StopTask(client, taskName, allocName, allocID) + default: + return nil + } +} + +func RestartTask(client api.Client, taskName, allocName, allocID string) tea.Cmd { + return func() tea.Msg { + alloc, _, err := client.Allocations().Info(allocID, nil) + + if err != nil { + return TaskAdminActionFailedMsg{ + Err: err, + TaskName: taskName, AllocName: allocName, AllocID: allocID} + } + + err = client.Allocations().Restart(alloc, taskName, nil) + if err != nil { + return TaskAdminActionFailedMsg{ + Err: err, + TaskName: taskName, AllocName: allocName, AllocID: allocID} + } + + return TaskAdminActionCompleteMsg{ + TaskName: taskName, AllocName: allocName, AllocID: allocID} + } +} diff --git a/internal/tui/nomad/util.go b/internal/tui/nomad/util.go index df09ef40..1b5d73e6 100644 --- a/internal/tui/nomad/util.go +++ b/internal/tui/nomad/util.go @@ -13,6 +13,47 @@ import ( const keySeparator = "|【=◈︿◈=】|" +type AdminAction int8 + +// all admin actions, task or job +const ( + RestartTaskAction AdminAction = iota + StopTaskAction + RestartJobAction + StopJobAction +) + +// AdminActionToKey and KeyToAdminAction are used to render the TaskAdminPage +func AdminActionToKey(adminAction AdminAction) string { + switch adminAction { + case RestartTaskAction: + return "restart-task" + case StopTaskAction: + return "stop-task" + case RestartJobAction: + return "restart-job" + case StopJobAction: + return "stop-job" + default: + return "" + } +} + +func KeyToAdminAction(adminAction string) AdminAction { + switch adminAction { + case "restart-task": + return RestartTaskAction + case "stop-task": + return StopTaskAction + case "restart-job": + return RestartJobAction + case "stop-job": + return StopJobAction + default: + return -1 + } +} + type taskRowEntry struct { FullAllocationAsJSON string NodeID, JobID, ID, TaskGroup, Name, TaskName, State string