diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4f85fbbd..242b227e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,6 +19,8 @@ jobs: uses: actions/setup-go@v4 with: go-version: '1.21' + - name: Run gofmt + run: if [ "$(gofmt -s -l . | wc -l)" -gt 0 ]; then exit 1; fi - name: Run GoReleaser uses: goreleaser/goreleaser-action@v4 with: diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml new file mode 100644 index 00000000..312a5280 --- /dev/null +++ b/.github/workflows/golangci-lint.yml @@ -0,0 +1,28 @@ +name: golangci-lint +on: + push: + branches: + - main + - next + pull_request: + +permissions: + contents: read + +jobs: + golangci: + name: lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-go@v4 + with: + go-version: '1.21' + cache: false + - name: golangci-lint + uses: golangci/golangci-lint-action@v4 + with: + # Require: The version of golangci-lint to use. + # When `install-mode` is `binary` (default) the value can be v1.2 or v1.2.3 or `latest` to use the latest version. + # When `install-mode` is `goinstall` the value can be v1.2.3, `latest`, or the hash of a commit. + version: v1.54 \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 059b59a8..86be78d0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,6 +46,8 @@ jobs: uses: actions/setup-go@v4 with: go-version: '1.21' + - name: Run gofmt + run: if [ "$(gofmt -s -l . | wc -l)" -gt 0 ]; then exit 1; fi - name: Run GoReleaser uses: goreleaser/goreleaser-action@v4 with: 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/.golangci.yaml b/.golangci.yaml new file mode 100644 index 00000000..0ce59c53 --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,11 @@ +linters: + # Enable specific linter + # https://golangci-lint.run/usage/linters/#enabled-by-default + enable: + - errcheck + - gosimple + - govet + - ineffassign + - staticcheck + - unused + - whitespace diff --git a/README.md b/README.md index 9b35a387..07a3faf3 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ An efficient terminal application/TUI for interacting with your [HashiCorp Nomad - Live tail logs - Tail global or targeted events - Exec to interact with running tasks +- Administrative actions (e.g. restart tasks) - View resource usage stats (memory, CPU) - See full job or allocation specs - Save any content to a local file @@ -299,4 +300,4 @@ go build # outputs ./wander executable The [scripts](/scripts) directory contains various development helper scripts. -If the `WANDER_DEBUG` environment variable is set to `true`, the `dev.Debug(s string)` function outputs to `wander.log`. +If the `WANDER_DEBUG` environment variable is set to `true`, the `dev.Debug(s string)` function outputs to `WANDER_DEBUG_PATH` (defaults to `wander.log`). diff --git a/cmd/root.go b/cmd/root.go index 905dd573..9f5b1443 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,6 +1,7 @@ package cmd import ( + "errors" "fmt" tea "github.com/charmbracelet/bubbletea" "github.com/robinovitch61/wander/internal/dev" @@ -208,7 +209,7 @@ func init() { rootCmd.PersistentFlags().BoolP(cliLong, rootNameToArg[cliLong].cliShort, rootNameToArg[cliLong].defaultIfBool, rootNameToArg[cliLong].description) // colors, config or env var only - viper.BindPFlag("", rootCmd.PersistentFlags().Lookup(rootNameToArg["logo-color"].cfgFileEnvVar)) + _ = viper.BindPFlag("", rootCmd.PersistentFlags().Lookup(rootNameToArg["logo-color"].cfgFileEnvVar)) for _, cliLong = range []string{ "addr", @@ -247,7 +248,7 @@ func init() { } else { rootCmd.PersistentFlags().StringP(cliLong, c.cliShort, c.defaultString, c.description) } - viper.BindPFlag(cliLong, rootCmd.PersistentFlags().Lookup(c.cfgFileEnvVar)) + _ = viper.BindPFlag(cliLong, rootCmd.PersistentFlags().Lookup(c.cfgFileEnvVar)) } // serve @@ -265,7 +266,7 @@ func init() { } else { serveCmd.PersistentFlags().StringP(cliLong, c.cliShort, c.defaultString, c.description) } - viper.BindPFlag(cliLong, serveCmd.PersistentFlags().Lookup(c.cfgFileEnvVar)) + _ = viper.BindPFlag(cliLong, serveCmd.PersistentFlags().Lookup(c.cfgFileEnvVar)) } // exec @@ -305,9 +306,8 @@ func initConfig(cmd *cobra.Command, nameToArg map[string]arg) error { if err := viper.ReadInConfig(); err == nil { fmt.Println("Using config file:", viper.ConfigFileUsed()) } else { - if _, ok := err.(viper.ConfigFileNotFoundError); ok { - // no config file found, that's ok - } else { + var configFileNotFoundError viper.ConfigFileNotFoundError + if !errors.As(err, &configFileNotFoundError) { fmt.Println(err) os.Exit(1) } @@ -333,14 +333,14 @@ func bindFlags(cmd *cobra.Command, nameToArg map[string]arg) { val := v.Get(viperName) err := cmd.Flags().Set(cliLong, fmt.Sprintf("%v", val)) if err != nil { - fmt.Println(fmt.Sprintf("error setting flag %s: %v", cliLong, err)) + fmt.Printf("error setting flag %s: %v\n", cliLong, err) os.Exit(1) } } }) } -func mainEntrypoint(cmd *cobra.Command, args []string) { +func mainEntrypoint(cmd *cobra.Command, _ []string) { dev.Debug("~STARTING UP~") rootOpts := getRootOpts(cmd) initialModel, options := setup(cmd, rootOpts, "", nil) diff --git a/cmd/util.go b/cmd/util.go index 6a3fe824..1a07bd98 100644 --- a/cmd/util.go +++ b/cmd/util.go @@ -44,10 +44,7 @@ func validateToken(token string) error { } func trueIfTrue(v string) bool { - if strings.ToLower(strings.TrimSpace(v)) == "true" { - return true - } - return false + return strings.ToLower(strings.TrimSpace(v)) == "true" } func retrieveLogoColor() string { diff --git a/img/screenshots/All_Jobs.png b/img/screenshots/All_Jobs.png index d1e88ba1..2113a6e3 100644 Binary files a/img/screenshots/All_Jobs.png and b/img/screenshots/All_Jobs.png differ diff --git a/img/screenshots/All_Tasks.png b/img/screenshots/All_Tasks.png index 48d48d3d..d5b0ca50 100644 Binary files a/img/screenshots/All_Tasks.png and b/img/screenshots/All_Tasks.png differ diff --git a/img/screenshots/Allocation_Statistics.png b/img/screenshots/Allocation_Statistics.png index 4a8590e5..b46ad5e4 100644 Binary files a/img/screenshots/Allocation_Statistics.png and b/img/screenshots/Allocation_Statistics.png differ diff --git a/img/screenshots/Exec.png b/img/screenshots/Exec.png index c3071ca2..3def964d 100644 Binary files a/img/screenshots/Exec.png and b/img/screenshots/Exec.png differ diff --git a/img/screenshots/Global_Events.png b/img/screenshots/Global_Events.png index df080d39..b5dbb569 100644 Binary files a/img/screenshots/Global_Events.png and b/img/screenshots/Global_Events.png differ diff --git a/img/screenshots/README.md b/img/screenshots/README.md index 8f603140..64f23709 100644 --- a/img/screenshots/README.md +++ b/img/screenshots/README.md @@ -1,16 +1,16 @@ -# All Jobs -![](./All_Jobs.png) # Global Events ![](./Global_Events.png) -# All Tasks -![](./All_Tasks.png) -# Exec -![](./Exec.png) -# Tasks for Job -![](./Tasks_for_Job.png) -# Task Logs -![](./Task_Logs.png) -# Save Any View to Local File -![](./Save_Any_View_to_Local_File.png) +# All Jobs +![](./All_Jobs.png) # Allocation Statistics ![](./Allocation_Statistics.png) +# Save Any View to Local File +![](./Save_Any_View_to_Local_File.png) +# Task Logs +![](./Task_Logs.png) +# Tasks for Job +![](./Tasks_for_Job.png) +# Exec +![](./Exec.png) +# All Tasks +![](./All_Tasks.png) diff --git a/img/screenshots/Save_Any_View_to_Local_File.png b/img/screenshots/Save_Any_View_to_Local_File.png index 51d3a4d2..f3abe2c3 100644 Binary files a/img/screenshots/Save_Any_View_to_Local_File.png and b/img/screenshots/Save_Any_View_to_Local_File.png differ diff --git a/img/screenshots/Task_Logs.png b/img/screenshots/Task_Logs.png index 70cea784..358dc60b 100644 Binary files a/img/screenshots/Task_Logs.png and b/img/screenshots/Task_Logs.png differ diff --git a/img/screenshots/Tasks_for_Job.png b/img/screenshots/Tasks_for_Job.png index db1e44f3..47a79e32 100644 Binary files a/img/screenshots/Tasks_for_Job.png and b/img/screenshots/Tasks_for_Job.png differ diff --git a/img/wander.gif b/img/wander.gif index 893d74e1..4f2cc36c 100644 Binary files a/img/wander.gif and b/img/wander.gif differ diff --git a/img/wander_flow.drawio.png b/img/wander_flow.drawio.png index 562e19db..00f9d69e 100644 Binary files a/img/wander_flow.drawio.png and b/img/wander_flow.drawio.png differ diff --git a/internal/dev/dev.go b/internal/dev/dev.go index ce5d55ea..bd96e1c9 100644 --- a/internal/dev/dev.go +++ b/internal/dev/dev.go @@ -8,11 +8,15 @@ import ( ) var debugSet = os.Getenv("WANDER_DEBUG") +var debugPath = os.Getenv("WANDER_DEBUG_PATH") // dev func Debug(msg string) { + if debugPath == "" { + debugPath = "wander.log" + } if debugSet != "" { - f, err := tea.LogToFile("wander.log", "") + f, err := tea.LogToFile(debugPath, "") if err != nil { fmt.Println("fatal:", err) os.Exit(1) diff --git a/internal/fileio/fileio.go b/internal/fileio/fileio.go index 4f1c697e..e2950797 100644 --- a/internal/fileio/fileio.go +++ b/internal/fileio/fileio.go @@ -10,6 +10,10 @@ import ( "time" ) +type SaveCompleteMessage struct { + FullPath, SuccessMessage, Err string +} + func SaveToFile(saveDialogValue string, fileContent []string) (string, error) { var path, fileName string diff --git a/internal/tui/components/app/app.go b/internal/tui/components/app/app.go index e613fe6b..be98bb99 100644 --- a/internal/tui/components/app/app.go +++ b/internal/tui/components/app/app.go @@ -2,6 +2,14 @@ package app import ( "fmt" + "github.com/robinovitch61/wander/internal/fileio" + "os" + "os/exec" + "path" + "sort" + "strings" + "time" + "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/ssh" @@ -10,17 +18,13 @@ import ( "github.com/robinovitch61/wander/internal/dev" "github.com/robinovitch61/wander/internal/tui/components/header" "github.com/robinovitch61/wander/internal/tui/components/page" + "github.com/robinovitch61/wander/internal/tui/components/toast" "github.com/robinovitch61/wander/internal/tui/constants" "github.com/robinovitch61/wander/internal/tui/formatter" "github.com/robinovitch61/wander/internal/tui/keymap" "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 { @@ -86,11 +90,13 @@ type Model struct { eventsStream nomad.EventsStream event string - meta map[string]string logsStream nomad.LogsStream lastLogFinished bool + // adminAction is a key of AllocAdminActions (or JobAdminActions, when it exists) + adminAction nomad.AdminAction + width, height int initialized bool err error @@ -174,9 +180,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.currentPageLoading() { m.getCurrentPageModel().SetViewportXOffset(0) } - if m.getCurrentPageModel().FilterWithContext { - m.getCurrentPageModel().ResetContextFilter() - } + //if m.getCurrentPageModel().FilterWithContext { + // I don't remember why I originally had this here, but it seems unequivocally bad: + // https://github.com/robinovitch61/wander/issues/128 + // Delete this soon if no issues arise from its removal. If they do, comment why you have this here :) + //m.getCurrentPageModel().ResetContextFilter() + //} m.getCurrentPageModel().SetLoading(false) if m.currentPage.CanBeFirstPage() && len(msg.AllPageRows) == 0 { @@ -184,8 +193,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) } @@ -206,6 +215,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } case nomad.ExecPage: m.getCurrentPageModel().SetInputPrefix("Enter command: ") + case nomad.AllocAdminConfirmPage, nomad.JobAdminConfirmPage: + // always make user go down one to confirm + m.getCurrentPageModel().SetViewportSelectionToTop() } cmds = append(cmds, nomad.UpdatePageDataWithDelay(m.updateID, m.currentPage, m.config.UpdateSeconds)) } @@ -307,6 +319,53 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return nomad.ExecCompleteMsg{Output: string(stdoutProxy.SavedOutput)} }) } + + case fileio.SaveCompleteMessage: + toastMsg := msg.SuccessMessage + toastStyle := style.SuccessToast + if msg.Err != "" { + toastMsg = fmt.Sprintf("Error: %s", msg.Err) + toastStyle = style.ErrorToast + } + newToast := toast.New(toastMsg) + m.getCurrentPageModel().SetToast(newToast, toastStyle) + cmds = append(cmds, tea.Tick(newToast.Timeout, func(t time.Time) tea.Msg { return toast.TimeoutMsg{ID: newToast.ID} })) + + case nomad.AllocAdminActionCompleteMsg: + toastMsg := fmt.Sprintf( + "%s completed successfully", + nomad.GetAllocAdminText(m.adminAction, msg.TaskName, msg.AllocName, msg.AllocID), + ) + toastStyle := style.SuccessToast + if msg.Err != nil { + toastMsg = fmt.Sprintf( + "%s failed with error: %s", + nomad.GetAllocAdminText(m.adminAction, msg.TaskName, msg.AllocName, msg.AllocID), + msg.Err.Error(), + ) + toastStyle = style.ErrorToast + } + newToast := toast.New(toastMsg) + m.getCurrentPageModel().SetToast(newToast, toastStyle) + cmds = append(cmds, tea.Tick(newToast.Timeout, func(t time.Time) tea.Msg { return toast.TimeoutMsg{ID: newToast.ID} })) + + case nomad.JobAdminActionCompleteMsg: + toastMsg := fmt.Sprintf( + "%s completed successfully", + nomad.GetJobAdminText(m.adminAction, msg.JobID), + ) + toastStyle := style.SuccessToast + if msg.Err != nil { + toastMsg = fmt.Sprintf( + "%s failed with error: %s", + nomad.GetJobAdminText(m.adminAction, msg.JobID), + msg.Err.Error(), + ) + toastStyle = style.ErrorToast + } + newToast := toast.New(toastMsg) + m.getCurrentPageModel().SetToast(newToast, toastStyle) + cmds = append(cmds, tea.Tick(newToast.Timeout, func(t time.Time) tea.Msg { return toast.TimeoutMsg{ID: newToast.ID} })) } currentPageModel = m.getCurrentPageModel() @@ -399,6 +458,35 @@ func (m *Model) handleKeyMsg(msg tea.KeyMsg) tea.Cmd { m.event = selectedPageRow.Key case nomad.LogsPage: m.logline = selectedPageRow.Row + case nomad.AllocAdminPage: + m.adminAction = nomad.KeyToAdminAction(selectedPageRow.Key) + case nomad.AllocAdminConfirmPage: + if selectedPageRow.Key == constants.ConfirmationKey { + cmds = append( + cmds, + nomad.GetCmdForAllocAdminAction(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...) + } + case nomad.JobAdminPage: + m.adminAction = nomad.KeyToAdminAction(selectedPageRow.Key) + case nomad.JobAdminConfirmPage: + if selectedPageRow.Key == constants.ConfirmationKey { + cmds = append( + cmds, + nomad.GetCmdForJobAdminAction( + m.client, m.adminAction, m.jobID, m.jobNamespace), + ) + } 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) @@ -410,10 +498,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...) } } @@ -438,7 +527,6 @@ func (m *Model) handleKeyMsg(msg tea.KeyMsg) tea.Cmd { return m.getCurrentPageCmd() } } - if key.Matches(msg, keymap.KeyMap.Exec) { if selectedPageRow, err := m.getCurrentPageModel().GetSelectedPageRow(); err == nil { if m.currentPage.ShowsTasks() { @@ -545,6 +633,31 @@ 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 + + if m.currentPage == nomad.JobsPage { + m.jobID, m.jobNamespace = nomad.JobIDAndNamespaceFromKey(selectedPageRow.Key) + m.setPage(nomad.JobAdminPage) + return m.getCurrentPageCmd() + } + + if m.currentPage == nomad.JobTasksPage || m.currentPage == nomad.AllTasksPage { + 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.AllocAdminPage) + return m.getCurrentPageCmd() + } + } + } + } + if m.currentPage == nomad.LogsPage { switch { case key.Matches(msg, keymap.KeyMap.StdOut): @@ -585,21 +698,6 @@ func (m *Model) getCurrentPageModel() *page.Model { return m.pageModels[m.currentPage] } -func (m *Model) appendToViewport(content string, startOnNewLine bool) { - stringRows := strings.Split(content, "\n") - var pageRows []page.Row - for _, row := range stringRows { - stripOS := formatter.StripOSCommandSequences(row) - stripped := formatter.StripANSI(stripOS) - // bell seems to mess with parent terminal - if stripped != "\a" { - pageRows = append(pageRows, page.Row{Row: stripped}) - } - } - m.getCurrentPageModel().AppendToViewport(pageRows, startOnNewLine) - m.getCurrentPageModel().ScrollViewportToBottom() -} - func (m *Model) updateKeyHelp() { newKeyHelp := nomad.GetPageKeyHelp(m.currentPage, m.currentPageFilterFocused(), m.currentPageFilterApplied(), m.currentPageViewportSaving(), m.logType, m.compact, m.inJobsMode) m.header.SetKeyHelp(newKeyHelp) @@ -665,8 +763,78 @@ 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.AllocAdminPage: + return func() tea.Msg { + // this does no async work, just constructs the task admin menu + var rows []page.Row + var sortedAllocAdminActions []int + for action := range nomad.AllocAdminActions { + sortedAllocAdminActions = append(sortedAllocAdminActions, int(action)) + } + sort.Ints(sortedAllocAdminActions) + for _, action := range sortedAllocAdminActions { + rows = append(rows, page.Row{ + Key: nomad.AdminActionToKey(nomad.AdminAction(action)), + Row: nomad.GetAllocAdminText(nomad.AdminAction(action), m.taskName, m.alloc.Name, m.alloc.ID), + }) + } + return nomad.PageLoadedMsg{ + Page: nomad.AllocAdminPage, + TableHeader: []string{"Available Admin Actions"}, + AllPageRows: rows, + } + } + case nomad.AllocAdminConfirmPage: + return func() tea.Msg { + // this does no async work, just constructs the confirmation page + confirmationText := nomad.GetAllocAdminText(m.adminAction, m.taskName, m.alloc.Name, m.alloc.ID) + confirmationText = strings.ToLower(confirmationText[:1]) + confirmationText[1:] + return nomad.PageLoadedMsg{ + Page: nomad.AllocAdminConfirmPage, + TableHeader: []string{"Are you sure?"}, + AllPageRows: []page.Row{ + {Key: "Cancel", Row: "Cancel"}, + {Key: constants.ConfirmationKey, Row: fmt.Sprintf("Yes, %s", confirmationText)}, + }, + } + } + case nomad.JobAdminPage: + return func() tea.Msg { + // this does no async work, just constructs the job admin menu + var rows []page.Row + var sortedJobAdminActions []int + for action := range nomad.JobAdminActions { + sortedJobAdminActions = append(sortedJobAdminActions, int(action)) + } + sort.Ints(sortedJobAdminActions) + for _, action := range sortedJobAdminActions { + rows = append(rows, page.Row{ + Key: nomad.AdminActionToKey(nomad.AdminAction(action)), + Row: nomad.GetJobAdminText(nomad.AdminAction(action), m.jobID), + }) + } + return nomad.PageLoadedMsg{ + Page: nomad.JobAdminPage, + TableHeader: []string{"Available Admin Actions"}, + AllPageRows: rows, + } + } + case nomad.JobAdminConfirmPage: + return func() tea.Msg { + // this does no async work, just constructs the confirmation page + confirmationText := nomad.GetJobAdminText(m.adminAction, m.jobID) + confirmationText = strings.ToLower(confirmationText[:1]) + confirmationText[1:] + return nomad.PageLoadedMsg{ + Page: nomad.JobAdminConfirmPage, + 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..c7265941 100644 --- a/internal/tui/components/page/page.go +++ b/internal/tui/components/page/page.go @@ -2,6 +2,9 @@ package page import ( "fmt" + "github.com/robinovitch61/wander/internal/fileio" + "strings" + "github.com/atotto/clipboard" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/textinput" @@ -14,7 +17,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 { @@ -112,7 +114,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { } switch msg := msg.(type) { - case viewport.SaveStatusMsg: + case fileio.SaveCompleteMessage: if m.copySavePath { cmds = append(cmds, func() tea.Msg { _ = clipboard.WriteAll(msg.FullPath) @@ -123,8 +125,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { cmds = append(cmds, cmd) case toast.TimeoutMsg: - m.viewport, cmd = m.viewport.Update(msg) - cmds = append(cmds, cmd) + m.viewport.HideToast() case tea.KeyMsg: switch { @@ -233,6 +234,10 @@ func (m *Model) SetFilterPrefix(prefix string) { m.filter.SetPrefix(prefix) } +func (m *Model) SetViewportSelectionToTop() { + m.viewport.SetSelectedContentIdx(0) +} + func (m *Model) SetViewportSelectionToBottom() { m.viewport.SetSelectedContentIdx(len(m.pageData.FilteredRows) - 1) } @@ -245,6 +250,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/page/util.go b/internal/tui/components/page/util.go index 73c94d18..2a47885e 100644 --- a/internal/tui/components/page/util.go +++ b/internal/tui/components/page/util.go @@ -35,6 +35,8 @@ func (d *data) IncrementFilteredSelectionNum() { d.FilteredSelectionNum++ if d.FilteredSelectionNum >= len(d.FilteredContentIdxs) { d.FilteredSelectionNum = 0 + } else if d.FilteredSelectionNum < 0 { + d.FilteredSelectionNum = 0 } d.CurrentFilteredContentIdx = d.FilteredContentIdxs[d.FilteredSelectionNum] } @@ -46,6 +48,8 @@ func (d *data) DecrementFilteredSelectionNum() { d.FilteredSelectionNum-- if d.FilteredSelectionNum < 0 { d.FilteredSelectionNum = len(d.FilteredContentIdxs) - 1 + } else if d.FilteredSelectionNum >= len(d.FilteredContentIdxs) { + d.FilteredSelectionNum = len(d.FilteredContentIdxs) - 1 } d.CurrentFilteredContentIdx = d.FilteredContentIdxs[d.FilteredSelectionNum] } diff --git a/internal/tui/components/toast/toast.go b/internal/tui/components/toast/toast.go index 3b94a44d..e1148078 100644 --- a/internal/tui/components/toast/toast.go +++ b/internal/tui/components/toast/toast.go @@ -17,19 +17,18 @@ var ( ) type Model struct { - id int + ID int message string - timeout time.Duration - initialized bool + Timeout time.Duration Visible bool MessageStyle lipgloss.Style } func New(message string) Model { return Model{ - id: nextID(), + ID: nextID(), message: message, - timeout: constants.ToastDuration, + Timeout: constants.ToastDuration, Visible: true, MessageStyle: style.SuccessToast, } @@ -37,20 +36,14 @@ func New(message string) Model { func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { dev.Debug(fmt.Sprintf("toast %T", msg)) - if !m.initialized { - m.initialized = true - return m, m.timeoutAfterDuration() - } - switch msg := msg.(type) { case TimeoutMsg: - if msg.ID > 0 && msg.ID != m.id { + if msg.ID > 0 && msg.ID != m.ID { return m, nil } m.Visible = false } - return m, nil } @@ -65,18 +58,10 @@ func (m Model) ViewHeight() int { return lipgloss.Height(m.View()) } -// Msg and Cmds - type TimeoutMsg struct { ID int } -func (m Model) timeoutAfterDuration() tea.Cmd { - return tea.Tick(m.timeout, func(t time.Time) tea.Msg { return TimeoutMsg{m.id} }) -} - -// Helpers - func nextID() int { idMtx.Lock() defer idMtx.Unlock() diff --git a/internal/tui/components/viewport/viewport.go b/internal/tui/components/viewport/viewport.go index 3539e8f0..84083958 100644 --- a/internal/tui/components/viewport/viewport.go +++ b/internal/tui/components/viewport/viewport.go @@ -18,10 +18,6 @@ const lineContinuationIndicator = "..." var lenLineContinuationIndicator = stringWidth(lineContinuationIndicator) -type SaveStatusMsg struct { - FullPath, SuccessMessage, Err string -} - type Model struct { header []string wrappedHeader []string @@ -134,15 +130,6 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { } } else { switch msg := msg.(type) { - case SaveStatusMsg: - if msg.Err != "" { - m.toast = toast.New(fmt.Sprintf("Error: %s", msg.Err)) - m.toast.MessageStyle = style.ErrorToast.Copy().Width(m.width) - } else { - m.toast = toast.New(msg.SuccessMessage) - m.toast.MessageStyle = style.SuccessToast.Copy().Width(m.width) - } - case tea.KeyMsg: switch { case key.Matches(msg, m.keyMap.Up): @@ -354,6 +341,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 } @@ -423,9 +415,7 @@ func (m *Model) updateWrappedHeader() { var allWrappedHeader []string for _, line := range m.header { wrappedLinesForLine := m.getWrappedLines(line) - for _, wrappedLine := range wrappedLinesForLine { - allWrappedHeader = append(allWrappedHeader, wrappedLine) - } + allWrappedHeader = append(allWrappedHeader, wrappedLinesForLine...) } m.wrappedHeader = allWrappedHeader } @@ -735,9 +725,9 @@ func (m Model) getSaveCommand() tea.Cmd { savePathWithFileName, err := fileio.SaveToFile(m.saveDialog.Value(), saveContent) if err != nil { - return SaveStatusMsg{Err: err.Error()} + return fileio.SaveCompleteMessage{Err: err.Error()} } - return SaveStatusMsg{FullPath: savePathWithFileName, SuccessMessage: fmt.Sprintf("Success: saved to %s", savePathWithFileName)} + return fileio.SaveCompleteMessage{FullPath: savePathWithFileName, SuccessMessage: fmt.Sprintf("Success: saved to %s", savePathWithFileName)} } } 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/formatter/formatter.go b/internal/tui/formatter/formatter.go index fd975dbf..3ea205a7 100644 --- a/internal/tui/formatter/formatter.go +++ b/internal/tui/formatter/formatter.go @@ -46,10 +46,6 @@ type Table struct { HeaderRows, ContentRows []string } -func (t *Table) isEmpty() bool { - return len(t.HeaderRows) == 0 && len(t.ContentRows) == 0 -} - type tableConfig struct { writer *tablewriter.Table string *strings.Builder @@ -120,7 +116,7 @@ func pluralize(s string, q float64) string { func FormatTimeNsSinceNow(t int64) string { tm := time.Unix(0, t).UTC() - since := time.Now().Sub(tm) + since := time.Since(tm) if secs := since.Seconds(); secs > 0 && secs < 60 { val := math.Floor(secs) out := fmt.Sprintf("%.0f second", val) 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/allocadmin.go b/internal/tui/nomad/allocadmin.go new file mode 100644 index 00000000..7c3681f1 --- /dev/null +++ b/internal/tui/nomad/allocadmin.go @@ -0,0 +1,120 @@ +package nomad + +import ( + "fmt" + tea "github.com/charmbracelet/bubbletea" + "github.com/hashicorp/nomad/api" + "github.com/robinovitch61/wander/internal/tui/formatter" +) + +var ( + // AllocAdminActions maps allocation-specific AdminActions to their display text + AllocAdminActions = map[AdminAction]string{ + RestartTaskAction: "Restart", + RestartAllocAction: "Restart", + StopAllocAction: "Stop", + } +) + +type AllocAdminActionCompleteMsg struct { + Err error + TaskName, AllocName, AllocID string +} + +func GetAllocAdminText(adminAction AdminAction, taskName, allocName, allocID string) string { + switch adminAction { + case RestartTaskAction: + return fmt.Sprintf( + "%s task %s in %s (%s)", + AllocAdminActions[adminAction], + taskName, allocName, formatter.ShortAllocID(allocID)) + case RestartAllocAction, StopAllocAction: + return fmt.Sprintf( + "%s allocation %s (%s)", + AllocAdminActions[adminAction], + allocName, formatter.ShortAllocID(allocID)) + default: + return "" + } +} + +func GetCmdForAllocAdminAction( + client api.Client, + adminAction AdminAction, + taskName, + allocName, + allocID string, +) tea.Cmd { + switch adminAction { + case RestartTaskAction: + return RestartTask(client, taskName, allocName, allocID) + case RestartAllocAction: + return RestartAllocation(client, allocName, allocID) + case StopAllocAction: + return StopAllocation(client, 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 AllocAdminActionCompleteMsg{ + Err: err, + TaskName: taskName, AllocName: allocName, AllocID: allocID, + } + } + err = client.Allocations().Restart(alloc, taskName, nil) + if err != nil { + return AllocAdminActionCompleteMsg{ + Err: err, + TaskName: taskName, AllocName: allocName, AllocID: allocID, + } + } + return AllocAdminActionCompleteMsg{TaskName: taskName, AllocName: allocName, AllocID: allocID} + } +} + +func RestartAllocation(client api.Client, allocName, allocID string) tea.Cmd { + return func() tea.Msg { + alloc, _, err := client.Allocations().Info(allocID, nil) + if err != nil { + return AllocAdminActionCompleteMsg{ + Err: err, + AllocName: allocName, AllocID: allocID, + } + } + + // Empty task name restarts all tasks in the allocation + err = client.Allocations().Restart(alloc, "", nil) + if err != nil { + return AllocAdminActionCompleteMsg{ + Err: err, + AllocName: allocName, AllocID: allocID, + } + } + return AllocAdminActionCompleteMsg{AllocName: allocName, AllocID: allocID} + } +} + +func StopAllocation(client api.Client, allocName, allocID string) tea.Cmd { + return func() tea.Msg { + alloc, _, err := client.Allocations().Info(allocID, nil) + if err != nil { + return AllocAdminActionCompleteMsg{ + Err: err, + AllocName: allocName, AllocID: allocID, + } + } + _, err = client.Allocations().Stop(alloc, nil) + if err != nil { + return AllocAdminActionCompleteMsg{ + Err: err, + AllocName: allocName, AllocID: allocID, + } + } + return AllocAdminActionCompleteMsg{AllocName: allocName, AllocID: allocID} + } +} diff --git a/internal/tui/nomad/events.go b/internal/tui/nomad/events.go index 021dec52..06c8abc0 100644 --- a/internal/tui/nomad/events.go +++ b/internal/tui/nomad/events.go @@ -100,7 +100,7 @@ func getEventsFromJQQuery(event string, code *gojq.Code) ([]Event, error) { if err != nil { events = append(events, Event{event, fmt.Sprintf("events jq json error: %s", err)}) } - events = append(events, Event{event, fmt.Sprintf("%s", j)}) + events = append(events, Event{event, string(j)}) } return events, nil } diff --git a/internal/tui/nomad/exec.go b/internal/tui/nomad/exec.go index 4c2b03cc..6294c046 100644 --- a/internal/tui/nomad/exec.go +++ b/internal/tui/nomad/exec.go @@ -53,7 +53,7 @@ func AllocExec(client *api.Client, allocID, task string, args []string) (int, er if len(foundAllocs) > 0 { if len(foundAllocs) == 1 && len(maps.Values(foundAllocs)[0]) == 1 && maps.Values(foundAllocs)[0][0] != nil { // only one job with one allocation found, use that - alloc, _, err = client.Allocations().Info(maps.Values(foundAllocs)[0][0].ID, nil) + alloc, _, _ = client.Allocations().Info(maps.Values(foundAllocs)[0][0].ID, nil) } else { // multiple jobs and/or allocations found, print them and exit for job, jobAllocs := range foundAllocs { @@ -78,7 +78,7 @@ func AllocExec(client *api.Client, allocID, task string, args []string) (int, er } return 1, err } else if len(shortIDAllocs) == 1 { - alloc, _, err = client.Allocations().Info(shortIDAllocs[0].ID, nil) + alloc, _, _ = client.Allocations().Info(shortIDAllocs[0].ID, nil) } else { return 1, fmt.Errorf("no allocations found for alloc id %s", allocID) } @@ -108,14 +108,21 @@ func AllocExec(client *api.Client, allocID, task string, args []string) (int, er } // execImpl invokes the Alloc Exec api call, it also prepares and restores terminal states as necessary. -func execImpl(client *api.Client, alloc *api.Allocation, task string, - command []string, escapeChar string, stdin io.Reader, stdout, stderr io.WriteCloser) (int, error) { - +func execImpl( + client *api.Client, + alloc *api.Allocation, + task string, + command []string, + escapeChar string, + stdin io.Reader, + stdout, + stderr io.WriteCloser, +) (int, error) { // attempt to clear screen time.Sleep(10 * time.Millisecond) - os.Stdout.Write([]byte("\033c")) + _, _ = os.Stdout.Write([]byte("\033c")) - fmt.Println(fmt.Sprintf("Exec session for %s (%s), task %s", alloc.Name, formatter.ShortAllocID(alloc.ID), task)) + fmt.Printf("Exec session for %s (%s), task %s\n", alloc.Name, formatter.ShortAllocID(alloc.ID), task) sizeCh := make(chan api.TerminalSize, 1) @@ -183,7 +190,7 @@ func setRawTerminal(stream interface{}) (cleanup func(), err error) { } return func() { - term.RestoreTerminal(fd, state) + _ = term.RestoreTerminal(fd, state) }, nil } @@ -200,7 +207,7 @@ func setRawTerminalOutput(stream interface{}) (cleanup func(), err error) { } return func() { - term.RestoreTerminal(fd, state) + _ = term.RestoreTerminal(fd, state) }, nil } @@ -276,7 +283,7 @@ func (r *reader) pipe() { if n > 0 { state = r.processBuf(bw, rb, n, state) - bw.Flush() + _ = bw.Flush() if state == sLookChar { // terminated with ~ - let's read one more character n, err = r.impl.Read(rb[:1]) @@ -284,14 +291,14 @@ func (r *reader) pipe() { state = sLookNewLine if rb[0] == r.escapeChar { // only emit escape character once - bw.WriteByte(rb[0]) - bw.Flush() + _ = bw.WriteByte(rb[0]) + _ = bw.Flush() } else if r.handler(rb[0]) { // skip if handled } else { - bw.WriteByte(r.escapeChar) - bw.WriteByte(rb[0]) - bw.Flush() + _ = bw.WriteByte(r.escapeChar) + _ = bw.WriteByte(rb[0]) + _ = bw.Flush() if rb[0] == '\n' || rb[0] == '\r' { state = sLookEscapeChar } @@ -303,10 +310,10 @@ func (r *reader) pipe() { if err != nil { // write ~ if it's the last thing if state == sLookChar { - bw.WriteByte(r.escapeChar) + _ = bw.WriteByte(r.escapeChar) } - bw.Flush() - r.pw.CloseWithError(err) + _ = bw.Flush() + _ = r.pw.CloseWithError(err) break } } @@ -324,19 +331,19 @@ START: if s == sLookEscapeChar && buf[i] == r.escapeChar { if i+1 >= n { // buf terminates with ~ - write all before - bw.Write(buf[wi:i]) + _, _ = bw.Write(buf[wi:i]) return sLookChar } nc := buf[i+1] if nc == r.escapeChar { // skip one escape char - bw.Write(buf[wi:i]) + _, _ = bw.Write(buf[wi:i]) i++ wi = i } else if r.handler(nc) { // skip both characters - bw.Write(buf[wi:i]) + _, _ = bw.Write(buf[wi:i]) i = i + 2 wi = i } else if nc == '\n' || nc == '\r' { @@ -353,14 +360,14 @@ START: for { if i >= n { // got to end without new line, write and return - bw.Write(buf[wi:n]) + _, _ = bw.Write(buf[wi:n]) return sLookNewLine } if buf[i] == '\n' || buf[i] == '\r' { // buf terminated at new line if i+1 >= n { - bw.Write(buf[wi:n]) + _, _ = bw.Write(buf[wi:n]) return sLookEscapeChar } diff --git a/internal/tui/nomad/jobadmin.go b/internal/tui/nomad/jobadmin.go new file mode 100644 index 00000000..97ffd1f2 --- /dev/null +++ b/internal/tui/nomad/jobadmin.go @@ -0,0 +1,61 @@ +package nomad + +import ( + "fmt" + + tea "github.com/charmbracelet/bubbletea" + "github.com/hashicorp/nomad/api" +) + +var ( + JobAdminActions = map[AdminAction]string{ + StopJobAction: "Stop", + StopAndPurgeJobAction: "Stop and purge", + } +) + +type JobAdminActionCompleteMsg struct { + Err error + JobID string +} + +func GetJobAdminText(adminAction AdminAction, jobID string) string { + switch adminAction { + case StopJobAction, StopAndPurgeJobAction: + return fmt.Sprintf( + "%s job %s", + JobAdminActions[adminAction], jobID) + default: + return "" + } +} + +func GetCmdForJobAdminAction( + client api.Client, + adminAction AdminAction, + jobID, jobNamespace string, +) tea.Cmd { + switch adminAction { + case StopJobAction: + return StopJob(client, jobID, jobNamespace, false) + case StopAndPurgeJobAction: + return StopJob(client, jobID, jobNamespace, true) + default: + return nil + } +} + +func StopJob(client api.Client, jobID, jobNamespace string, purge bool) tea.Cmd { + return func() tea.Msg { + opts := &api.WriteOptions{Namespace: jobNamespace} + _, _, err := client.Jobs().Deregister(jobID, purge, opts) + if err != nil { + return JobAdminActionCompleteMsg{ + Err: err, + JobID: jobID, + } + } + + return JobAdminActionCompleteMsg{JobID: jobID} + } +} diff --git a/internal/tui/nomad/jobs.go b/internal/tui/nomad/jobs.go index feaae85b..161534ed 100644 --- a/internal/tui/nomad/jobs.go +++ b/internal/tui/nomad/jobs.go @@ -101,5 +101,4 @@ func toJobsKey(jobResponseEntry *api.JobListStub) string { func JobIDAndNamespaceFromKey(key string) (string, string) { split := strings.Split(key, " ") return split[0], split[1] - } 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..99636688 100644 --- a/internal/tui/nomad/pages.go +++ b/internal/tui/nomad/pages.go @@ -36,6 +36,10 @@ const ( LogsPage LoglinePage StatsPage + AllocAdminPage + AllocAdminConfirmPage + JobAdminPage + JobAdminConfirmPage ) func GetAllPageConfigs(width, height int, compactTables bool) map[Page]page.Config { @@ -132,6 +136,26 @@ func GetAllPageConfigs(width, height int, compactTables bool) map[Page]page.Conf LoadingString: StatsPage.LoadingString(), SelectionEnabled: false, WrapText: false, RequestInput: false, }, + AllocAdminPage: { + Width: width, Height: height, + LoadingString: AllocAdminPage.LoadingString(), + SelectionEnabled: true, WrapText: false, RequestInput: false, + }, + AllocAdminConfirmPage: { + Width: width, Height: height, + LoadingString: AllocAdminConfirmPage.LoadingString(), + SelectionEnabled: true, WrapText: false, RequestInput: false, + }, + JobAdminPage: { + Width: width, Height: height, + LoadingString: JobAdminPage.LoadingString(), + SelectionEnabled: true, WrapText: false, RequestInput: false, + }, + JobAdminConfirmPage: { + Width: width, Height: height, + LoadingString: JobAdminConfirmPage.LoadingString(), + SelectionEnabled: true, WrapText: false, RequestInput: false, + }, } } @@ -146,7 +170,21 @@ func (p Page) DoesLoad() bool { } func (p Page) DoesReload() bool { - noReloadPages := []Page{LoglinePage, JobEventsPage, JobEventPage, AllocEventsPage, AllocEventPage, AllEventsPage, AllEventPage, ExecPage, ExecCompletePage} + noReloadPages := []Page{ + LoglinePage, + JobEventsPage, + JobEventPage, + AllocEventsPage, + AllocEventPage, + AllEventsPage, + AllEventPage, + ExecPage, + ExecCompletePage, + AllocAdminPage, + AllocAdminConfirmPage, + JobAdminPage, + JobAdminConfirmPage, + } for _, noReloadPage := range noReloadPages { if noReloadPage == p { return false @@ -165,24 +203,38 @@ func (p Page) ShowsTasks() bool { return false } +func (p Page) HasAdminMenu() bool { + adminMenuPages := []Page{AllTasksPage, JobTasksPage, JobsPage} + for _, adminMenuPage := range adminMenuPages { + if adminMenuPage == p { + return true + } + } + return false +} + func (p Page) CanBeFirstPage() bool { return p == JobsPage || p == AllTasksPage } func (p Page) doesUpdate() bool { noUpdatePages := []Page{ - LoglinePage, // doesn't load - ExecPage, // doesn't reload - ExecCompletePage, // doesn't reload - LogsPage, // currently makes scrolling impossible - solve in https://github.com/robinovitch61/wander/issues/1 - JobSpecPage, // would require changes to make scrolling possible - AllocSpecPage, // would require changes to make scrolling possible - JobEventsPage, // constant connection, streams data - JobEventPage, // doesn't load - AllocEventsPage, // constant connection, streams data - AllocEventPage, // doesn't load - AllEventsPage, // constant connection, streams data - AllEventPage, // doesn't load + LoglinePage, // doesn't load + ExecPage, // doesn't reload + ExecCompletePage, // doesn't reload + LogsPage, // currently makes scrolling impossible - solve in https://github.com/robinovitch61/wander/issues/1 + JobSpecPage, // would require changes to make scrolling possible + AllocSpecPage, // would require changes to make scrolling possible + JobEventsPage, // constant connection, streams data + JobEventPage, // doesn't load + AllocEventsPage, // constant connection, streams data + AllocEventPage, // doesn't load + AllEventsPage, // constant connection, streams data + AllEventPage, // doesn't load + AllocAdminPage, // doesn't load + AllocAdminConfirmPage, // doesn't load + JobAdminPage, // doesn't load + JobAdminConfirmPage, // doesn't load } for _, noUpdatePage := range noUpdatePages { if noUpdatePage == p { @@ -226,6 +278,12 @@ func (p Page) String() string { return "log" case StatsPage: return "stats" + case AllocAdminPage: + return "task admin menu" + case JobAdminPage: + return "job admin menu" + case AllocAdminConfirmPage, JobAdminConfirmPage: + return "execute" } return "unknown" } @@ -234,7 +292,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 +308,14 @@ func (p Page) Forward() Page { return LogsPage case LogsPage: return LoglinePage + case AllocAdminPage: + return AllocAdminConfirmPage + case AllocAdminConfirmPage: + return returnToTasksPage(inJobsMode) + case JobAdminPage: + return JobAdminConfirmPage + case JobAdminConfirmPage: + return JobsPage } return p } @@ -293,6 +359,14 @@ func (p Page) Backward(inJobsMode bool) Page { return LogsPage case StatsPage: return returnToTasksPage(inJobsMode) + case AllocAdminPage: + return returnToTasksPage(inJobsMode) + case AllocAdminConfirmPage: + return AllocAdminPage + case JobAdminPage: + return JobsPage + case JobAdminConfirmPage: + return JobAdminPage } return p } @@ -333,7 +407,7 @@ func (p Page) GetFilterPrefix(namespace, jobID, taskName, allocName, allocID str case AllEventsPage: return fmt.Sprintf("All Events in Namespace %s (%s)", eventNamespace, formatEventTopics(eventTopics)) case AllEventPage: - return fmt.Sprintf("Event") + return "Event" case JobTasksPage: return fmt.Sprintf("Tasks for Job %s", style.Bold.Render(jobID)) case ExecPage: @@ -348,6 +422,14 @@ 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 AllocAdminPage: + return fmt.Sprintf("Admin Actions for Allocation %s %s", style.Bold.Render(allocName), formatter.ShortAllocID(allocID)) + case AllocAdminConfirmPage: + return fmt.Sprintf("Confirm Admin Action for Allocation %s %s", style.Bold.Render(allocName), formatter.ShortAllocID(allocID)) + case JobAdminPage: + return fmt.Sprintf("Admin Actions for Job %s", style.Bold.Render(jobID)) + case JobAdminConfirmPage: + return fmt.Sprintf("Confirm Admin Action for Job %s", style.Bold.Render(jobID)) default: panic("page not found") } @@ -377,6 +459,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 +510,19 @@ 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()) + if currentPage == AllocAdminConfirmPage { + changeKeyHelp(&keymap.KeyMap.Forward, "choose") + } fourthRow = append(fourthRow, keymap.KeyMap.Forward) } @@ -439,7 +530,7 @@ func GetPageKeyHelp( changeKeyHelp(&keymap.KeyMap.Back, "remove filter") fourthRow = append(fourthRow, keymap.KeyMap.Back) } else if prevPage := currentPage.Backward(inJobsMode); prevPage != currentPage { - changeKeyHelp(&keymap.KeyMap.Back, fmt.Sprintf("%s", currentPage.Backward(inJobsMode).String())) + changeKeyHelp(&keymap.KeyMap.Back, currentPage.Backward(inJobsMode).String()) fourthRow = append(fourthRow, keymap.KeyMap.Back) } diff --git a/internal/tui/nomad/util.go b/internal/tui/nomad/util.go index df09ef40..148c6667 100644 --- a/internal/tui/nomad/util.go +++ b/internal/tui/nomad/util.go @@ -13,6 +13,58 @@ import ( const keySeparator = "|【=◈︿◈=】|" +type AdminAction int8 + +// all admin actions, task or job +// the definition order of these is important, as it's used for sorting +const ( + RestartTaskAction AdminAction = iota + RestartAllocAction + StopAllocAction + RestartJobAction + StopJobAction + StopAndPurgeJobAction +) + +// AdminActionToKey and KeyToAdminAction are used for admin menu serialization/deserialization +func AdminActionToKey(adminAction AdminAction) string { + switch adminAction { + case RestartTaskAction: + return "restart-task" + case RestartAllocAction: + return "restart-allocation" + case StopAllocAction: + return "stop-task" + case RestartJobAction: + return "restart-job" + case StopJobAction: + return "stop-job" + case StopAndPurgeJobAction: + return "stop-and-purge-job" + default: + return "" + } +} + +func KeyToAdminAction(adminAction string) AdminAction { + switch adminAction { + case "restart-task": + return RestartTaskAction + case "restart-allocation": + return RestartAllocAction + case "stop-task": + return StopAllocAction + case "restart-job": + return RestartJobAction + case "stop-job": + return StopJobAction + case "stop-and-purge-job": + return StopAndPurgeJobAction + default: + return -1 + } +} + type taskRowEntry struct { FullAllocationAsJSON string NodeID, JobID, ID, TaskGroup, Name, TaskName, State string diff --git a/main.go b/main.go index fe2dfd7e..22ae6944 100644 --- a/main.go +++ b/main.go @@ -5,5 +5,8 @@ import ( ) func main() { - cmd.Execute() + err := cmd.Execute() + if err != nil { + panic(err) + } } diff --git a/scripts/vhs.tape b/scripts/vhs.tape index df4345b9..7596249c 100644 --- a/scripts/vhs.tape +++ b/scripts/vhs.tape @@ -92,6 +92,7 @@ Backspace 10 Type@200ms "bash" Sleep 500ms Enter +Sleep 500ms Type@150ms "ls -la" Sleep 300ms Enter @@ -103,11 +104,25 @@ Sleep 2s Escape@300ms 2 Sleep 500ms +# Admin Actions for Task +Type "X" +Sleep 1s +Type@150ms "jj" +Sleep 1s +Enter +Sleep 1s +Type "j" +Sleep 1s +Enter +Sleep 2s + # Back to All Jobs Type "J" Sleep 1s # Tasks for Job +Type "j" +Sleep 500ms Enter Sleep 100ms Screenshot ../img/screenshots/Tasks_for_Job.png