From 2b5cea8fa10c69262e0d1507f12768b7f3b30905 Mon Sep 17 00:00:00 2001 From: tomasmik Date: Fri, 6 Oct 2023 14:46:13 +0300 Subject: [PATCH] Allow to draw a basic worker pools view --- client/structs/search.go | 8 ++ internal/cmd/draw/data/workerpools.go | 131 ++++++++++++++++++++++ internal/cmd/draw/table.go | 154 ++++++++++++++++++++++++++ internal/cmd/draw/tick.go | 15 +++ internal/cmd/workerpools/cmd.go | 6 + internal/cmd/workerpools/watch.go | 65 +++++++++++ 6 files changed, 379 insertions(+) create mode 100644 internal/cmd/draw/data/workerpools.go create mode 100644 internal/cmd/draw/table.go create mode 100644 internal/cmd/draw/tick.go create mode 100644 internal/cmd/workerpools/watch.go diff --git a/client/structs/search.go b/client/structs/search.go index 32aed9f..ab209fc 100644 --- a/client/structs/search.go +++ b/client/structs/search.go @@ -8,6 +8,14 @@ type SearchInput struct { After *graphql.String `json:"after"` FullTextSearch *graphql.String `json:"fullTextSearch"` Predicates *[]QueryPredicate `json:"predicates"` + OrderBy *QueryOrder `json:"orderBy"` +} + +// QueryOrder is the order in which the results +// should be returned. +type QueryOrder struct { + Field graphql.String `json:"field"` + Direction graphql.String `json:"direction"` } // QueryPredicate Field and Constraint pair diff --git a/internal/cmd/draw/data/workerpools.go b/internal/cmd/draw/data/workerpools.go new file mode 100644 index 0000000..fdeabe8 --- /dev/null +++ b/internal/cmd/draw/data/workerpools.go @@ -0,0 +1,131 @@ +package data + +import ( + "context" + "fmt" + "time" + + "github.com/charmbracelet/bubbles/table" + "github.com/pkg/browser" + "github.com/pkg/errors" + "github.com/shurcooL/graphql" + + "github.com/spacelift-io/spacectl/client/structs" + "github.com/spacelift-io/spacectl/internal/cmd/authenticated" +) + +// WorkerPool allows to interact with a worker pool. +type WorkerPool struct { + WokerPoolID string +} + +// Selected opens the selected worker pool in the browser. +func (q *WorkerPool) Selected(row table.Row) error { + return browser.OpenURL(authenticated.Client.URL("/stack/%s/run/%s", row[1], row[2])) +} + +// Columns returns the columns of the worker pool table. +func (q *WorkerPool) Columns() []table.Column { + return []table.Column{ + {Title: "#", Width: 2}, + {Title: "Stack", Width: 25}, + {Title: "Run", Width: 32}, + {Title: "State", Width: 15}, + {Title: "Type", Width: 10}, + {Title: "Created At", Width: 27}, + } +} + +// Rows returns the rows of the worker pool table. +func (q *WorkerPool) Rows(ctx context.Context) (rows []table.Row, err error) { + var runs []runsEdge + if q.WokerPoolID == "" { + runs, err = q.getPublicPoolRuns(ctx) + if err != nil { + return nil, err + } + } else { + runs, err = q.getPrivatePoolRuns(ctx) + if err != nil { + return nil, err + } + } + + for _, edge := range runs { + tm := time.Unix(int64(edge.Node.Run.CreatedAt), 0) + rows = append(rows, table.Row{ + fmt.Sprint(edge.Node.Position), + edge.Node.StackID, + edge.Node.Run.ID, + edge.Node.Run.State, + edge.Node.Run.Type, + tm.Format(time.DateTime), + }) + } + + return rows, nil +} + +func (q *WorkerPool) getPublicPoolRuns(ctx context.Context) ([]runsEdge, error) { + var query struct { + WorkerPool struct { + Runs runsQuery `graphql:"searchSchedulableRuns(input: $input)"` + } `graphql:"publicWorkerPool"` + } + + if err := authenticated.Client.Query(ctx, &query, q.baseSearchParams()); err != nil { + return nil, errors.Wrap(err, "failed to query run list") + } + + return query.WorkerPool.Runs.Edges, nil +} + +func (q *WorkerPool) getPrivatePoolRuns(ctx context.Context) ([]runsEdge, error) { + var query struct { + WorkerPool struct { + Runs runsQuery `graphql:"searchSchedulableRuns(input: $input)"` + } `graphql:"workerPool(id: $id)"` + } + + vars := q.baseSearchParams() + vars["id"] = q.WokerPoolID + + if err := authenticated.Client.Query(ctx, &query, vars); err != nil { + return nil, errors.Wrap(err, "failed to query run list") + } + + return query.WorkerPool.Runs.Edges, nil +} + +func (q *WorkerPool) baseSearchParams() map[string]interface{} { + return map[string]interface{}{ + "input": structs.SearchInput{ + First: graphql.NewInt(graphql.Int(100)), + OrderBy: &structs.QueryOrder{ + Field: graphql.String("position"), + Direction: graphql.String("ASC"), + }, + }, + } +} + +type runsQuery struct { + Edges []runsEdge `graphql:"edges"` + PageInfo structs.PageInfo `graphql:"pageInfo"` +} + +type runsEdge struct { + Node struct { + StackID string `graphql:"stackId"` + Run run `graphql:"run"` + Position int `graphql:"position"` + } `graphql:"node"` +} + +type run struct { + ID string `graphql:"id" json:"id"` + CreatedAt int `graphql:"createdAt" json:"createdAt"` + State string `graphql:"state" json:"state"` + Type string `graphql:"type" json:"type"` + Title string `graphql:"title" json:"title"` +} diff --git a/internal/cmd/draw/table.go b/internal/cmd/draw/table.go new file mode 100644 index 0000000..a15351e --- /dev/null +++ b/internal/cmd/draw/table.go @@ -0,0 +1,154 @@ +package draw + +import ( + "context" + "fmt" + "time" + + "github.com/charmbracelet/bubbles/table" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "golang.org/x/term" +) + +// Table is a table that can be drawn. +type Table struct { + table table.Model + td TableData + + width int + height int + baseStyle lipgloss.Style + + lastErr error +} + +// TableData is the data for a table. +type TableData interface { + // Columns returns the columns of the table. + Columns() []table.Column + + // Rows returns the rows of the table. + Rows(ctx context.Context) ([]table.Row, error) + + // Selected is called when a row is selected. + // The entire row is passed to the function. + Selected(table.Row) error +} + +// NewTable creates a new table. +func NewTable(ctx context.Context, d TableData) (*Table, error) { + rows, err := d.Rows(ctx) + if err != nil { + return nil, err + } + + t := table.New( + table.WithColumns(d.Columns()), + table.WithRows(rows), + table.WithFocused(true), + table.WithHeight(25), + ) + + s := table.DefaultStyles() + s.Header = s.Header. + BorderStyle(lipgloss.ThickBorder()). + BorderForeground(lipgloss.Color("240")). + BorderBottom(true). + Bold(true) + + s.Selected = s.Selected. + Foreground(lipgloss.Color("#FAFAFA")). + Background(lipgloss.Color("#7C47FC")). + Bold(false) + t.SetStyles(s) + + bs := lipgloss.NewStyle(). + BorderStyle(lipgloss.ThickBorder()). + BorderForeground(lipgloss.Color("240")) + + width, height, err := term.GetSize(0) + if err != nil { + return nil, err + } + + return &Table{ + table: t, + td: d, + + width: width, + height: height, + baseStyle: bs, + + lastErr: nil, + }, nil +} + +// DrawTable should be called to draw the table. +func (t *Table) DrawTable() error { + if _, err := tea.NewProgram(t).Run(); err != nil { + return fmt.Errorf("error running program: %w", err) + } + + return nil +} + +// Init implements tea.Model.Init. +// Should not be called directly. +func (t Table) Init() tea.Cmd { + return tickCmd() +} + +// Update implements tea.Model.Update. +// Should not be called directly. +func (t Table) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + switch msg := msg.(type) { + case tea.WindowSizeMsg: + t.width = msg.Width + t.height = msg.Height + case tea.KeyMsg: + switch msg.String() { + case "esc", "ctrl+c", "q": + return t, tea.Quit + case "enter": + err := t.td.Selected(t.table.SelectedRow()) + if err != nil { + return t, t.saveErrorAndExit(err) + } + } + case tickMsg: + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + + rows, err := t.td.Rows(ctx) + if err != nil { + return t, t.saveErrorAndExit(err) + } + + t.table.SetRows(rows) + return t, tickCmd() + } + + t.table, cmd = t.table.Update(msg) + return t, cmd +} + +// View implements tea.Model.View. +// Should not be called directly. +func (t Table) View() string { + if t.lastErr != nil { + return fmt.Sprintln("Exited with an error:", t.lastErr) + } + + return lipgloss.Place( + t.width, t.height, + lipgloss.Center, lipgloss.Center, + t.baseStyle.Render(t.table.View())+"\n", + ) +} + +func (t *Table) saveErrorAndExit(err error) tea.Cmd { + t.lastErr = err + return tea.Quit +} diff --git a/internal/cmd/draw/tick.go b/internal/cmd/draw/tick.go new file mode 100644 index 0000000..2e8326f --- /dev/null +++ b/internal/cmd/draw/tick.go @@ -0,0 +1,15 @@ +package draw + +import ( + "time" + + tea "github.com/charmbracelet/bubbletea" +) + +type tickMsg time.Time + +func tickCmd() tea.Cmd { + return tea.Tick(time.Second*5, func(t time.Time) tea.Msg { + return tickMsg(t) + }) +} diff --git a/internal/cmd/workerpools/cmd.go b/internal/cmd/workerpools/cmd.go index 4e9c8c3..0a9a45e 100644 --- a/internal/cmd/workerpools/cmd.go +++ b/internal/cmd/workerpools/cmd.go @@ -21,6 +21,12 @@ func Command() *cli.Command { Action: (&listPoolsCommand{}).listPools, Before: authenticated.Ensure, }, + { + Name: "watch", + Usage: "Starts an interactive watcher for a worker pool", + Action: watch, + Before: authenticated.Ensure, + }, { Name: "worker", Usage: "Contains commands for managing workers within a pool.", diff --git a/internal/cmd/workerpools/watch.go b/internal/cmd/workerpools/watch.go new file mode 100644 index 0000000..9b36c90 --- /dev/null +++ b/internal/cmd/workerpools/watch.go @@ -0,0 +1,65 @@ +package workerpools + +import ( + "fmt" + "strings" + + "github.com/manifoldco/promptui" + "github.com/urfave/cli/v2" + + "github.com/spacelift-io/spacectl/internal/cmd/authenticated" + "github.com/spacelift-io/spacectl/internal/cmd/draw" + "github.com/spacelift-io/spacectl/internal/cmd/draw/data" +) + +func watch(cliCtx *cli.Context) error { + got, err := findAndSelectWorkerPool(cliCtx) + if err != nil { + return err + } + + wp := &data.WorkerPool{WokerPoolID: got} + t, err := draw.NewTable(cliCtx.Context, wp) + if err != nil { + return err + } + + return t.DrawTable() +} + +// findAndSelectWorkerPool finds all worker pools and lets the user select one. +// +// Returns the ID of the selected worker pool. +// If public worker pool is selected and empty string is returned. +func findAndSelectWorkerPool(cliCtx *cli.Context) (string, error) { + var query listPoolsQuery + if err := authenticated.Client.Query(cliCtx.Context, &query, map[string]interface{}{}); err != nil { + return "", err + } + + items := []string{"Public worker pool"} + found := map[string]string{ + "Public worker pool": "", + } + for _, p := range query.Pools { + items = append(items, p.Name) + found[p.Name] = p.ID + } + + prompt := promptui.Select{ + Label: fmt.Sprintf("Found %d worker pools, select one", len(items)), + Items: items, + Size: 10, + StartInSearchMode: len(items) > 5, + Searcher: func(input string, index int) bool { + return strings.Contains(items[index], input) + }, + } + + _, result, err := prompt.Run() + if err != nil { + return "", err + } + + return found[result], nil +}