Skip to content

Commit

Permalink
Allow to draw a basic worker pools view
Browse files Browse the repository at this point in the history
  • Loading branch information
tomasmik committed Oct 6, 2023
1 parent 4b0a77d commit 2b5cea8
Show file tree
Hide file tree
Showing 6 changed files with 379 additions and 0 deletions.
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
}

0 comments on commit 2b5cea8

Please sign in to comment.