Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow to draw a basic worker pools view #190

Merged
merged 1 commit into from
Oct 9, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions client/structs/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
131 changes: 131 additions & 0 deletions internal/cmd/draw/data/workerpools.go
Original file line number Diff line number Diff line change
@@ -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"`
}
154 changes: 154 additions & 0 deletions internal/cmd/draw/table.go
Original file line number Diff line number Diff line change
@@ -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
}
15 changes: 15 additions & 0 deletions internal/cmd/draw/tick.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
6 changes: 6 additions & 0 deletions internal/cmd/workerpools/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
65 changes: 65 additions & 0 deletions internal/cmd/workerpools/watch.go
Original file line number Diff line number Diff line change
@@ -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
}