Skip to content
Draft
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
284 changes: 274 additions & 10 deletions internal/command/machine/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,17 @@
"bytes"
"context"
"fmt"
"io"
"os"
"strconv"
"strings"

"github.com/AlecAivazis/survey/v2/terminal"
"github.com/olekukonko/tablewriter/pkg/twwidth"
"github.com/samber/lo"
"github.com/spf13/cobra"
fly "github.com/superfly/fly-go"
"github.com/superfly/fly-go/flaps"
"github.com/superfly/flyctl/internal/appconfig"
"github.com/superfly/flyctl/internal/command"
"github.com/superfly/flyctl/internal/config"
Expand All @@ -20,7 +24,30 @@
"github.com/superfly/flyctl/iostreams"
)

const defaultMachineListPager = "less -RSX -+F -P'Use left/right arrows to scroll; q to quit'"
const (
defaultMachineListPager = "less -RSX -+F -P'Use left/right arrows to scroll; q to quit'"
defaultMachineListPageSize = 500

machineListQuit machineListAction = 'q'
machineListNextPage machineListAction = 'n'
machineListPrevPage machineListAction = 'p'
)

type machineListAction rune

// machineListCachedPage caches pages of machines when paginating through a
// large result set.
type machineListCachedPage struct {
machines []*fly.Machine
nextCursor string
}

// machineListNavigation captures where we are in the paginated machine list.
type machineListNavigation struct {
hasNext bool
hasPrev bool
page int
}

func newList() *cobra.Command {
const (
Expand Down Expand Up @@ -48,6 +75,10 @@
Shorthand: "q",
Description: "Only list machine ids",
},
flag.Int{
Name: "limit",
Description: "Number of machines to return per page; 0 returns all machines",
},
)

return cmd
Expand All @@ -59,25 +90,90 @@
io = iostreams.FromContext(ctx)
silence = flag.GetBool(ctx, "quiet")
cfg = config.FromContext(ctx)
limit = flag.GetInt(ctx, "limit")
)
if limit < 0 {
return fmt.Errorf("--limit must be 0 or greater")
}

flapsClient := flapsutil.ClientFromContext(ctx)
pageLister, ok := flapsClient.(machinePageLister)
if !ok {
return fmt.Errorf("Machines API client does not support paginated machine lists")
}

machines, err := flapsClient.List(ctx, appName, "")
seenMachines := make(map[string]struct{})
seenCursors := make(map[string]struct{})
machines, nextCursor, err := loadMachineDisplayPage(ctx, pageLister, appName, limit, "", seenMachines, seenCursors)
if err != nil {
return err
}

if cfg.JSONOutput {
return render.JSON(io.Out, machines)
interactivePagination := limit > 0 && io.IsInteractive() && !silence && !cfg.JSONOutput && nextCursor != ""
if !interactivePagination {
_, err := renderMachineListPage(io, appName, machines, silence, cfg.JSONOutput, nil)
return err
}

pages := []machineListCachedPage{
{machines: machines, nextCursor: nextCursor},
}
pageIndex := 0
for {
page := pages[pageIndex]
action, err := renderMachineListPage(io, appName, page.machines, silence, cfg.JSONOutput, &machineListNavigation{
hasNext: page.nextCursor != "",
hasPrev: pageIndex > 0,
page: pageIndex + 1,
})
if err != nil {
return err
}

switch action {
case machineListQuit:
return nil
case machineListPrevPage:
if pageIndex == 0 {
continue
}
pageIndex--
case machineListNextPage:
if pageIndex+1 < len(pages) {
pageIndex++
} else if page.nextCursor != "" {
machines, nextCursor, err := loadMachineDisplayPage(ctx, pageLister, appName, limit, page.nextCursor, seenMachines, seenCursors)
if err != nil {
return err
}
if len(machines) == 0 {
pages[pageIndex].nextCursor = ""
continue
}
pages = append(pages, machineListCachedPage{machines: machines, nextCursor: nextCursor})
pageIndex++
} else {
continue
}
default:
return nil
}

clearMachineListPage(io.Out)
}
}

func renderMachineListPage(io *iostreams.IOStreams, appName string, machines []*fly.Machine, silence, jsonOutput bool, navigation *machineListNavigation) (machineListAction, error) {
if jsonOutput {
return machineListQuit, render.JSON(io.Out, machines)
}

if len(machines) == 0 {
if !silence {
fmt.Fprintf(io.Out, "No machines are available on this app %s\n", appName)
}

return nil
return machineListQuit, nil
}

rows := [][]string{}
Expand Down Expand Up @@ -175,24 +271,102 @@
"Size",
}

writeMachineListTable(io, appName, rows, headers)
footer := ""
if unreachableMachines {
fmt.Fprintln(io.Out, "* These Machines' hosts could not be reached.")
footer = "* These Machines' hosts could not be reached."
}
return writeMachineListTable(io, appName, rows, headers, footer, navigation)
}

return nil
return machineListQuit, nil
}

func writeMachineListTable(io *iostreams.IOStreams, appName string, rows [][]string, headers []string) {
type machinePageLister interface {
ListMachines(context.Context, string, *flaps.ListMachinesOpts) (*flaps.ListMachinesResponse, error)

Check failure on line 285 in internal/command/machine/list.go

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest)

undefined: flaps.ListMachinesResponse

Check failure on line 285 in internal/command/machine/list.go

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest)

undefined: flaps.ListMachinesOpts

Check failure on line 285 in internal/command/machine/list.go

View workflow job for this annotation

GitHub Actions / test (macos-latest)

undefined: flaps.ListMachinesResponse

Check failure on line 285 in internal/command/machine/list.go

View workflow job for this annotation

GitHub Actions / test (macos-latest)

undefined: flaps.ListMachinesOpts

Check failure on line 285 in internal/command/machine/list.go

View workflow job for this annotation

GitHub Actions / test (windows-latest)

undefined: flaps.ListMachinesResponse

Check failure on line 285 in internal/command/machine/list.go

View workflow job for this annotation

GitHub Actions / test (windows-latest)

undefined: flaps.ListMachinesOpts

Check failure on line 285 in internal/command/machine/list.go

View workflow job for this annotation

GitHub Actions / lint

undefined: flaps.ListMachinesResponse

Check failure on line 285 in internal/command/machine/list.go

View workflow job for this annotation

GitHub Actions / lint

undefined: flaps.ListMachinesOpts

Check failure on line 285 in internal/command/machine/list.go

View workflow job for this annotation

GitHub Actions / test / test (ubuntu-latest, ., test)

undefined: flaps.ListMachinesResponse

Check failure on line 285 in internal/command/machine/list.go

View workflow job for this annotation

GitHub Actions / test / test (ubuntu-latest, ., test)

undefined: flaps.ListMachinesOpts

Check failure on line 285 in internal/command/machine/list.go

View workflow job for this annotation

GitHub Actions / test / test (macos-latest, ., test)

undefined: flaps.ListMachinesResponse

Check failure on line 285 in internal/command/machine/list.go

View workflow job for this annotation

GitHub Actions / test / test (macos-latest, ., test)

undefined: flaps.ListMachinesOpts

Check failure on line 285 in internal/command/machine/list.go

View workflow job for this annotation

GitHub Actions / test / test (windows-latest, ., test)

undefined: flaps.ListMachinesResponse

Check failure on line 285 in internal/command/machine/list.go

View workflow job for this annotation

GitHub Actions / test / test (windows-latest, ., test)

undefined: flaps.ListMachinesOpts
}

func loadMachineDisplayPage(ctx context.Context, client machinePageLister, appName string, limit int, cursor string, seenMachines, seenCursors map[string]struct{}) ([]*fly.Machine, string, error) {
initialCapacity := defaultMachineListPageSize
if limit > 0 {
initialCapacity = min(limit, defaultMachineListPageSize)
}
machines := make([]*fly.Machine, 0, initialCapacity)

for {
if cursor != "" {
if _, ok := seenCursors[cursor]; ok {
return nil, "", fmt.Errorf("Machines API returned a repeated pagination cursor")
}
seenCursors[cursor] = struct{}{}
}
pageSize := defaultMachineListPageSize
if limit > 0 {
pageSize = min(pageSize, limit-len(machines))
}
resp, err := client.ListMachines(ctx, appName, &flaps.ListMachinesOpts{

Check failure on line 306 in internal/command/machine/list.go

View workflow job for this annotation

GitHub Actions / test (ubuntu-latest)

undefined: flaps.ListMachinesOpts

Check failure on line 306 in internal/command/machine/list.go

View workflow job for this annotation

GitHub Actions / test (macos-latest)

undefined: flaps.ListMachinesOpts

Check failure on line 306 in internal/command/machine/list.go

View workflow job for this annotation

GitHub Actions / test (windows-latest)

undefined: flaps.ListMachinesOpts

Check failure on line 306 in internal/command/machine/list.go

View workflow job for this annotation

GitHub Actions / lint

undefined: flaps.ListMachinesOpts

Check failure on line 306 in internal/command/machine/list.go

View workflow job for this annotation

GitHub Actions / test / test (ubuntu-latest, ., test)

undefined: flaps.ListMachinesOpts

Check failure on line 306 in internal/command/machine/list.go

View workflow job for this annotation

GitHub Actions / test / test (macos-latest, ., test)

undefined: flaps.ListMachinesOpts

Check failure on line 306 in internal/command/machine/list.go

View workflow job for this annotation

GitHub Actions / test / test (windows-latest, ., test)

undefined: flaps.ListMachinesOpts
Limit: pageSize,
Cursor: cursor,
})
if err != nil {
return nil, "", err
}

for _, machine := range resp.Machines {
if _, ok := seenMachines[machine.ID]; ok {
continue
}
seenMachines[machine.ID] = struct{}{}
machines = append(machines, machine)
// We have all the machines we need to display the next machine
// page: return the batch.
if limit > 0 && len(machines) == limit {
return machines, resp.NextCursor, nil
}
}
// This is the last batch of machines.
if resp.NextCursor == "" {
return machines, "", nil
}
cursor = resp.NextCursor
}
}

func writeMachineListTable(io *iostreams.IOStreams, appName string, rows [][]string, headers []string, footer string, navigation *machineListNavigation) (machineListAction, error) {
if !io.IsInteractive() {
_ = render.Table(io.Out, appName, rows, headers...)
if footer != "" {
fmt.Fprintln(io.Out, footer)
}

return
return machineListQuit, nil
}

var output bytes.Buffer
_ = render.Table(&output, appName, rows, headers...)
if footer != "" {
fmt.Fprintln(&output, footer)
}

if navigation != nil {
io.SetPager(machineListNavigationPager(*navigation))
if err := io.StartPager(); err != nil {
_, _ = io.Out.Write(output.Bytes())
return readMachineListNavigation(io, *navigation)
}
if _, err := io.Out.Write(output.Bytes()); err != nil {
io.StopPager()
return machineListQuit, err
}

// Use the pager's exit code to figure out the next action.
exitCode := io.StopPagerWithExitCode()
if exitCode == 1 {
_, _ = io.Out.Write(output.Bytes())
return readMachineListNavigation(io, *navigation)
}

return machineListActionFromExitCode(exitCode), nil
}

if shouldPageMachineListTable(output.String(), io.TerminalWidth()) {
if _, pagerSet := os.LookupEnv("PAGER"); !pagerSet {
Expand All @@ -204,6 +378,96 @@
}

_, _ = io.Out.Write(output.Bytes())
return machineListQuit, nil
}

func machineListNavigationPager(navigation machineListNavigation) string {
return fmt.Sprintf("less --lesskey-content=%s -RSX -+F -P%s",
strconv.Quote(machineListNavigationKeys(navigation)),
strconv.Quote(machineListNavigationPrompt(navigation, true)),
)
}

func machineListNavigationKeys(navigation machineListNavigation) string {
// Create custom keybindings for the pager, so we can figure out what key
// the user pressed.
bindings := []string{"#command", "q quit q"}
if navigation.hasNext {
bindings = append(bindings, "n quit n")
}
if navigation.hasPrev {
bindings = append(bindings, "p quit p")
}

return strings.Join(bindings, ";")
}

func readMachineListNavigation(io *iostreams.IOStreams, navigation machineListNavigation) (machineListAction, error) {
in, inOK := io.In.(terminal.FileReader)
out, outOK := io.Out.(terminal.FileWriter)
if !inOK || !outOK {
return machineListQuit, nil
}

reader := terminal.NewRuneReader(terminal.Stdio{In: in, Out: out, Err: io.ErrOut})
if err := reader.SetTermMode(); err != nil {
return machineListQuit, nil
}
defer reader.RestoreTermMode() //nolint:errcheck

menu := machineListNavigationPrompt(navigation, false)
fmt.Fprint(io.Out, menu)
defer fmt.Fprintln(io.Out)
for {
key, _, err := reader.ReadRune()
if err != nil {
return machineListQuit, err
}
switch machineListAction(key) {
case machineListNextPage:
if navigation.hasNext {
return machineListNextPage, nil
}
case machineListPrevPage:
if navigation.hasPrev {
return machineListPrevPage, nil
}
case machineListQuit, machineListAction(terminal.KeyInterrupt), machineListAction(terminal.KeyEscape):
return machineListQuit, nil
}
}
}

func machineListNavigationPrompt(navigation machineListNavigation, canScroll bool) string {
actions := []string{fmt.Sprintf("page %d", navigation.page)}
if navigation.hasNext {
actions = append(actions, "[n] next")
}
if navigation.hasPrev {
actions = append(actions, "[p] previous")
}
actions = append(actions, "[q] quit")
if canScroll {
actions = append(actions, "arrows scroll")
}

return strings.Join(actions, ", ")
}

func machineListActionFromExitCode(exitCode int) machineListAction {
switch machineListAction(exitCode) {
case machineListNextPage:
return machineListNextPage
case machineListPrevPage:
return machineListPrevPage
default:
return machineListQuit
}
}

func clearMachineListPage(out io.Writer) {
// Clear the terminal screen and move cursor to the top-left corner.
_, _ = io.WriteString(out, "\x1b[2J\x1b[H")
}

func shouldPageMachineListTable(output string, terminalWidth int) bool {
Expand Down
Loading
Loading