diff --git a/README.md b/README.md index 064fd87..5c1a475 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,12 @@ t3.medium 2 4 nitro true true t3a.medium 2 4 nitro true true x86_64 Up to 5 Gigabit 3 0 0 none -Not Fetched- $0.01246 ``` +**Interactive Output** +``` +$ ec2-instance-selector -o interactive +``` +https://user-images.githubusercontent.com/68402662/184218343-6b236d4a-3fe6-42ae-9fe3-3fd3ee92a4b5.mov + **Sort by memory in ascending order using shorthand** ``` $ ec2-instance-selector -r us-east-1 -o table-wide --max-results 10 --sort-by memory --sort-direction asc diff --git a/THIRD_PARTY_LICENSES b/THIRD_PARTY_LICENSES index 224d356..8760d32 100644 --- a/THIRD_PARTY_LICENSES +++ b/THIRD_PARTY_LICENSES @@ -1445,6 +1445,33 @@ furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +------ + +** github.com/sahilm/fuzzy; v0.1.0 -- +https://github.com/sahilm/fuzzy + +The MIT License (MIT) + +Copyright (c) 2017 Sahil Muthoo + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE diff --git a/cmd/main.go b/cmd/main.go index c34e873..c2dfede 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -49,6 +49,9 @@ const ( tableWideOutput = "table-wide" oneLine = "one-line" bubbleTeaOutput = "interactive" + + // Sort filter default + instanceNamePath = ".InstanceType" ) // Filter Flag Constants @@ -120,33 +123,6 @@ const ( sortBy = "sort-by" ) -// Sorting Constants -const ( - // Direction - - sortAscending = "ascending" - sortAsc = "asc" - sortDescending = "descending" - sortDesc = "desc" - - // Sorting Fields - spotPrice = "spot-price" - odPrice = "on-demand-price" - - // JSON field paths - instanceNamePath = ".InstanceType" - vcpuPath = ".VCpuInfo.DefaultVCpus" - memoryPath = ".MemoryInfo.SizeInMiB" - gpuMemoryTotalPath = ".GpuInfo.TotalGpuMemoryInMiB" - networkInterfacesPath = ".NetworkInfo.MaximumNetworkInterfaces" - spotPricePath = ".SpotPrice" - odPricePath = ".OndemandPricePerHour" - instanceStoragePath = ".InstanceStorageInfo.TotalSizeInGB" - ebsOptimizedBaselineBandwidthPath = ".EbsInfo.EbsOptimizedInfo.BaselineBandwidthInMbps" - ebsOptimizedBaselineThroughputPath = ".EbsInfo.EbsOptimizedInfo.BaselineThroughputInMBps" - ebsOptimizedBaselineIOPSPath = ".EbsInfo.EbsOptimizedInfo.BaselineIops" -) - var ( // versionID is overridden at compilation with the version based on the git tag versionID = "dev" @@ -177,26 +153,10 @@ Full docs can be found at github.com/aws/amazon-` + binName resultsOutputFn := outputs.SimpleInstanceTypeOutput cliSortDirections := []string{ - sortAscending, - sortAsc, - sortDescending, - sortDesc, - } - - // map quantity cli flags to json paths for easier cli sorting - sortingKeysMap := map[string]string{ - vcpus: vcpuPath, - memory: memoryPath, - gpuMemoryTotal: gpuMemoryTotalPath, - networkInterfaces: networkInterfacesPath, - spotPrice: spotPricePath, - odPrice: odPricePath, - instanceStorage: instanceStoragePath, - ebsOptimizedBaselineBandwidth: ebsOptimizedBaselineBandwidthPath, - ebsOptimizedBaselineThroughput: ebsOptimizedBaselineThroughputPath, - ebsOptimizedBaselineIOPS: ebsOptimizedBaselineIOPSPath, - gpus: gpus, - inferenceAccelerators: inferenceAccelerators, + sorter.SortAscending, + sorter.SortAsc, + sorter.SortDescending, + sorter.SortDesc, } // Registers flags with specific input types from the cli pkg @@ -263,7 +223,7 @@ Full docs can be found at github.com/aws/amazon-` + binName cli.ConfigBoolFlag(verbose, cli.StringMe("v"), nil, "Verbose - will print out full instance specs") cli.ConfigBoolFlag(help, cli.StringMe("h"), nil, "Help") cli.ConfigBoolFlag(version, nil, nil, "Prints CLI version") - cli.ConfigStringOptionsFlag(sortDirection, nil, cli.StringMe(sortAscending), fmt.Sprintf("Specify the direction to sort in (%s)", strings.Join(cliSortDirections, ", ")), cliSortDirections) + cli.ConfigStringOptionsFlag(sortDirection, nil, cli.StringMe(sorter.SortAscending), fmt.Sprintf("Specify the direction to sort in (%s)", strings.Join(cliSortDirections, ", ")), cliSortDirections) cli.ConfigStringFlag(sortBy, nil, cli.StringMe(instanceNamePath), "Specify the field to sort by. Quantity flags present in this CLI (memory, gpus, etc.) or a JSON path to the appropriate instance type field (Ex: \".MemoryInfo.SizeInMiB\") is acceptable.", nil) // Parses the user input with the registered flags and runs type specific validation on the user input @@ -419,11 +379,6 @@ Full docs can be found at github.com/aws/amazon-` + binName } } - // determine if user used a shorthand for sorting flag - if sortFieldShorthandPath, ok := sortingKeysMap[*sortField]; ok { - sortField = &sortFieldShorthandPath - } - // fetch instance types without truncating results prevMaxResults := filters.MaxResults filters.MaxResults = nil diff --git a/go.mod b/go.mod index 71bd1b3..9409748 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.18 require ( github.com/aws/aws-sdk-go v1.44.59 github.com/blang/semver/v4 v4.0.0 - github.com/charmbracelet/bubbles v0.11.0 + github.com/charmbracelet/bubbles v0.13.0 github.com/charmbracelet/bubbletea v0.21.0 github.com/charmbracelet/lipgloss v0.5.0 github.com/evertras/bubble-table v0.14.4 @@ -32,6 +32,7 @@ require ( github.com/muesli/cancelreader v0.2.0 // indirect github.com/muesli/reflow v0.3.0 // indirect github.com/rivo/uniseg v0.2.0 // indirect + github.com/sahilm/fuzzy v0.1.0 // indirect github.com/smartystreets/goconvey v1.6.4 // indirect go.uber.org/atomic v1.4.0 // indirect golang.org/x/sys v0.0.0-20220209214540-3681064d5158 // indirect diff --git a/go.sum b/go.sum index fd1c56c..3e5ec05 100644 --- a/go.sum +++ b/go.sum @@ -15,6 +15,8 @@ github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2y github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= github.com/charmbracelet/bubbles v0.11.0 h1:fBLyY0PvJnd56Vlu5L84JJH6f4axhgIJ9P3NET78f0Q= github.com/charmbracelet/bubbles v0.11.0/go.mod h1:bbeTiXwPww4M031aGi8UK2HT9RDWoiNibae+1yCMtcc= +github.com/charmbracelet/bubbles v0.13.0 h1:zP/ROH3wJEBqZWKIsD50ZKKlx3ydLInq3LdD/Nrlb8w= +github.com/charmbracelet/bubbles v0.13.0/go.mod h1:bbeTiXwPww4M031aGi8UK2HT9RDWoiNibae+1yCMtcc= github.com/charmbracelet/bubbletea v0.21.0 h1:f3y+kanzgev5PA916qxmDybSHU3N804uOnKnhRPXTcI= github.com/charmbracelet/bubbletea v0.21.0/go.mod h1:GgmJMec61d08zXsOhqRC/AiOx4K4pmz+VIcRIm1FKr4= github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= @@ -126,6 +128,7 @@ github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI= github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= diff --git a/pkg/selector/outputs/bubbletea.go b/pkg/selector/outputs/bubbletea.go index 65fe7b0..7407687 100644 --- a/pkg/selector/outputs/bubbletea.go +++ b/pkg/selector/outputs/bubbletea.go @@ -15,19 +15,29 @@ package outputs import ( "github.com/aws/amazon-ec2-instance-selector/v2/pkg/instancetypes" + "github.com/aws/amazon-ec2-instance-selector/v2/pkg/sorter" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" "github.com/muesli/termenv" ) const ( // can't get terminal dimensions on startup, so use this initialDimensionVal = 30 + + instanceTypeKey = "instance type" + selectedKey = "selected" ) const ( // table states stateTable = "table" stateVerbose = "verbose" + stateSorting = "sorting" +) + +var ( + controlsStyle = lipgloss.NewStyle().Faint(true) ) // BubbleTeaModel is used to hold the state of the bubble tea TUI @@ -40,6 +50,9 @@ type BubbleTeaModel struct { // holds state for the verbose view verboseModel verboseModel + + // holds the state for the sorting view + sortingModel sortingModel } // NewBubbleTeaModel initializes a new bubble tea Model which represents @@ -49,6 +62,7 @@ func NewBubbleTeaModel(instanceTypes []*instancetypes.Details) BubbleTeaModel { currentState: stateTable, tableModel: *initTableModel(instanceTypes), verboseModel: *initVerboseModel(instanceTypes), + sortingModel: *initSortingModel(instanceTypes), } } @@ -62,19 +76,47 @@ func (m BubbleTeaModel) Init() tea.Cmd { func (m BubbleTeaModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: + // don't listen for input if currently typing into text field + if m.tableModel.filterTextInput.Focused() { + break + } else if m.sortingModel.sortTextInput.Focused() { + // see if we should sort and switch states to table + if m.currentState == stateSorting && msg.String() == "enter" { + jsonPath := m.sortingModel.sortTextInput.Value() + + sortDirection := sorter.SortAscending + if m.sortingModel.isDescending { + sortDirection = sorter.SortDescending + } + + var err error + m.tableModel, err = m.tableModel.sortTable(jsonPath, sortDirection) + if err != nil { + m.sortingModel.sortTextInput.SetValue(jsonPathError) + break + } + + m.currentState = stateTable + + m.sortingModel.sortTextInput.Blur() + } + + break + } + // check for quit or change in state switch msg.String() { case "ctrl+c", "q": return m, tea.Quit - case "enter": - switch m.currentState { - case stateTable: - // switch from table state to verbose state - m.currentState = stateVerbose - + case "e": + // switch from table state to verbose state + if m.currentState == stateTable { // get focused instance type - rowIndex := m.tableModel.table.GetHighlightedRowIndex() - focusedInstance := m.verboseModel.instanceTypes[rowIndex] + focusedRow := m.tableModel.table.HighlightedRow() + focusedInstance, ok := focusedRow.Data[instanceTypeKey].(*instancetypes.Details) + if !ok { + break + } // set content of view m.verboseModel.focusedInstanceName = focusedInstance.InstanceType @@ -82,8 +124,39 @@ func (m BubbleTeaModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // move viewport to top of printout m.verboseModel.viewport.SetYOffset(0) - case stateVerbose: - // switch from verbose state to table state + + // switch from table state to verbose state + m.currentState = stateVerbose + } + case "s": + // switch from table view to sorting view + if m.currentState == stateTable { + m.currentState = stateSorting + } + case "enter": + // sort and switch states to table + if m.currentState == stateSorting { + sortFilter := string(m.sortingModel.shorthandList.SelectedItem().(item)) + + sortDirection := sorter.SortAscending + if m.sortingModel.isDescending { + sortDirection = sorter.SortDescending + } + + var err error + m.tableModel, err = m.tableModel.sortTable(sortFilter, sortDirection) + if err != nil { + m.sortingModel.sortTextInput.SetValue("INVALID SHORTHAND VALUE") + break + } + + m.currentState = stateTable + + m.sortingModel.sortTextInput.Blur() + } + case "esc": + // switch from sorting state or verbose state to table state + if m.currentState == stateSorting || m.currentState == stateVerbose { m.currentState = stateTable } } @@ -95,23 +168,21 @@ func (m BubbleTeaModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // handle screen resizing m.tableModel = m.tableModel.resizeView(msg) m.verboseModel = m.verboseModel.resizeView(msg) + m.sortingModel = m.sortingModel.resizeView(msg) } + var cmd tea.Cmd + // update currently active state switch m.currentState { case stateTable: - // update table - var cmd tea.Cmd m.tableModel, cmd = m.tableModel.update(msg) - - return m, cmd case stateVerbose: - // update viewport - var cmd tea.Cmd m.verboseModel, cmd = m.verboseModel.update(msg) - return m, cmd + case stateSorting: + m.sortingModel, cmd = m.sortingModel.update(msg) } - return m, nil + return m, cmd } // View is used by bubble tea to render the bubble tea model @@ -121,6 +192,8 @@ func (m BubbleTeaModel) View() string { return m.tableModel.view() case stateVerbose: return m.verboseModel.view() + case stateSorting: + return m.sortingModel.view() } return "" diff --git a/pkg/selector/outputs/sortingView.go b/pkg/selector/outputs/sortingView.go new file mode 100644 index 0000000..082a5ed --- /dev/null +++ b/pkg/selector/outputs/sortingView.go @@ -0,0 +1,257 @@ +// Copyright Amazon.com Inc. or its affiliates. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"). You may +// not use this file except in compliance with the License. A copy of the +// License is located at +// +// http://aws.amazon.com/apache2.0/ +// +// or in the "license" file accompanying this file. This file is distributed +// on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +// express or implied. See the License for the specific language governing +// permissions and limitations under the License. + +package outputs + +import ( + "fmt" + "io" + "strings" + + "github.com/aws/amazon-ec2-instance-selector/v2/pkg/instancetypes" + "github.com/aws/amazon-ec2-instance-selector/v2/pkg/sorter" + "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +const ( + // formatting + sortDirectionPadding = 2 + sortingTitlePadding = 3 + sortingFooterPadding = 2 + + // controls + sortingListControls = "Controls: ↑/↓ - up/down • enter - select filter • tab - toggle direction • esc - return to table • q - quit" + sortingTextControls = "Controls: ↑/↓ - up/down • tab - toggle direction • enter - enter json path" + + // sort direction text + ascendingText = "ASCENDING" + descendingText = "DESCENDING" +) + +// sortingModel holds the state for the sorting view +type sortingModel struct { + // list which holds the available shorting shorthands + shorthandList list.Model + + // text input for json paths + sortTextInput textinput.Model + + instanceTypes []*instancetypes.Details + + isDescending bool +} + +// format styles +var ( + // list + listTitleStyle = lipgloss.NewStyle().Bold(true).Underline(true) + listItemStyle = lipgloss.NewStyle().PaddingLeft(4) + selectedItemStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(lipgloss.Color("170")) + + // text + descendingStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#0096FF")) + ascendingStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#DAF7A6")) + sortDirectionStyle = lipgloss.NewStyle().Bold(true).Underline(true).PaddingLeft(2) +) + +// implement Item interface for list +type item string + +func (i item) FilterValue() string { return "" } +func (i item) Title() string { return string(i) } +func (i item) Description() string { return "" } + +// implement ItemDelegate for list +type itemDelegate struct{} + +func (d itemDelegate) Height() int { return 1 } +func (d itemDelegate) Spacing() int { return 0 } +func (d itemDelegate) Update(msg tea.Msg, m *list.Model) tea.Cmd { return nil } +func (d itemDelegate) Render(w io.Writer, m list.Model, index int, listItem list.Item) { + i, ok := listItem.(item) + if !ok { + return + } + + str := fmt.Sprintf("%d. %s", index+1, i) + + fn := listItemStyle.Render + if index == m.Index() { + fn = func(s string) string { + return selectedItemStyle.Render("> " + s) + } + } + + fmt.Fprintf(w, fn(str)) +} + +// initSortingModel initializes and returns a new tableModel based on the given +// instance type details +func initSortingModel(instanceTypes []*instancetypes.Details) *sortingModel { + shorthandList := list.New(*createListItems(), itemDelegate{}, initialDimensionVal, initialDimensionVal) + shorthandList.Title = "Select sorting filter:" + shorthandList.Styles.Title = listTitleStyle + shorthandList.SetFilteringEnabled(false) + shorthandList.SetShowStatusBar(false) + shorthandList.SetShowHelp(false) + shorthandList.SetShowPagination(false) + shorthandList.KeyMap = createListKeyMap() + + sortTextInput := textinput.New() + sortTextInput.Prompt = "JSON Path: " + sortTextInput.PromptStyle = lipgloss.NewStyle().Bold(true) + + return &sortingModel{ + shorthandList: shorthandList, + sortTextInput: sortTextInput, + instanceTypes: instanceTypes, + isDescending: false, + } +} + +// createListKeyMap creates a KeyMap with the controls for the shorthand list +func createListKeyMap() list.KeyMap { + return list.KeyMap{ + CursorDown: key.NewBinding( + key.WithKeys("down"), + ), + CursorUp: key.NewBinding( + key.WithKeys("up"), + ), + } +} + +// createListItems creates a list item for shorthand sorting flag +func createListItems() *[]list.Item { + shorthandFlags := []string{ + sorter.GPUCountField, + sorter.InferenceAcceleratorsField, + sorter.VCPUs, + sorter.Memory, + sorter.GPUMemoryTotal, + sorter.NetworkInterfaces, + sorter.SpotPrice, + sorter.ODPrice, + sorter.InstanceStorage, + sorter.EBSOptimizedBaselineBandwidth, + sorter.EBSOptimizedBaselineThroughput, + sorter.EBSOptimizedBaselineIOPS, + } + + items := []list.Item{} + + for _, flag := range shorthandFlags { + items = append(items, item(flag)) + } + + return &items +} + +// resizeSortingView will change the dimensions of the sorting view +// in order to accommodate the new window dimensions represented by +// the given tea.WindowSizeMsg +func (m sortingModel) resizeView(msg tea.WindowSizeMsg) sortingModel { + shorthandList := &m.shorthandList + shorthandList.SetWidth(msg.Width) + // ensure that text input is right below last option + if msg.Height >= len(shorthandList.Items())+sortingTitlePadding+sortingFooterPadding { + shorthandList.SetHeight(len(shorthandList.Items()) + sortingTitlePadding) + } else if msg.Height-sortingFooterPadding-sortDirectionPadding > 0 { + shorthandList.SetHeight(msg.Height - sortingFooterPadding - sortDirectionPadding) + } else { + shorthandList.SetHeight(1) + } + + // ensure cursor of list is still hidden after resize + if m.sortTextInput.Focused() { + shorthandList.Select(len(m.shorthandList.Items())) + } + + m.shorthandList = *shorthandList + + return m +} + +// update updates the state of the sortingModel +func (m sortingModel) update(msg tea.Msg) (sortingModel, tea.Cmd) { + var cmd tea.Cmd + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "down": + if m.shorthandList.Index() == len(m.shorthandList.Items())-1 { + // focus text input and hide cursor in shorthand list + m.shorthandList.Select(len(m.shorthandList.Items())) + m.sortTextInput.Focus() + } + case "up": + if m.sortTextInput.Focused() { + // go back to list from text input + m.shorthandList.Select(len(m.shorthandList.Items())) + m.sortTextInput.Blur() + } + case "tab": + m.isDescending = !m.isDescending + } + + if m.sortTextInput.Focused() { + m.sortTextInput, cmd = m.sortTextInput.Update(msg) + cmds = append(cmds, cmd) + } + } + + if !m.sortTextInput.Focused() { + m.shorthandList, cmd = m.shorthandList.Update(msg) + cmds = append(cmds, cmd) + } + + return m, tea.Batch(cmds...) +} + +// view returns a string representing the sorting view +func (m sortingModel) view() string { + outputStr := strings.Builder{} + + // draw sort direction + outputStr.WriteString(sortDirectionStyle.Render("Sort Direction:")) + outputStr.WriteString(" ") + if m.isDescending { + outputStr.WriteString(descendingStyle.Render(descendingText)) + } else { + outputStr.WriteString(ascendingStyle.Render(ascendingText)) + } + outputStr.WriteString("\n\n") + + // draw list + outputStr.WriteString(m.shorthandList.View()) + outputStr.WriteString("\n") + + // draw text input + outputStr.WriteString(m.sortTextInput.View()) + outputStr.WriteString("\n") + + // draw controls + if m.sortTextInput.Focused() { + outputStr.WriteString(controlsStyle.Render(sortingTextControls)) + } else { + outputStr.WriteString(controlsStyle.Render(sortingListControls)) + } + + return outputStr.String() +} diff --git a/pkg/selector/outputs/tableView.go b/pkg/selector/outputs/tableView.go index 30de42d..2d9213b 100644 --- a/pkg/selector/outputs/tableView.go +++ b/pkg/selector/outputs/tableView.go @@ -16,9 +16,12 @@ package outputs import ( "fmt" "reflect" + "strings" "github.com/aws/amazon-ec2-instance-selector/v2/pkg/instancetypes" + "github.com/aws/amazon-ec2-instance-selector/v2/pkg/sorter" "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/evertras/bubble-table/table" @@ -26,19 +29,35 @@ import ( const ( // table formatting - headerAndFooterPadding = 7 + headerAndFooterPadding = 8 headerPadding = 2 // controls - tableControls = "Controls: ↑/↓ - up/down • ←/→ - left/right • shift + ←/→ - pg up/down • enter - expand • q - quit" + tableControls = "Controls: ↑/↓ - up/down • ←/→ - left/right • shift + ←/→ - pg up/down • e - expand • f - filter • t - trim toggle • space - select • s - sort • q - quit" ellipses = "..." + + jsonPathError = "INVALID JSON PATH" ) type tableModel struct { // the model for the table output table table.Model - tableWidth int + // width and rows per page are inaccessible through + // bubble tea implementation, so expose them here + tableWidth int + tableRowsPerPage int + + // the model for the filtering text input + filterTextInput textinput.Model + + // shows whether the rows are currently trimmed or not + isTrimmed bool + + // the rows that existed on the table's creation + originalRows []table.Row + + canSelectRows bool } var ( @@ -66,18 +85,33 @@ var ( // initTableModel initializes and returns a new tableModel based on the given // instance type details func initTableModel(instanceTypes []*instancetypes.Details) *tableModel { + table := createTable(instanceTypes) + return &tableModel{ - table: createTable(instanceTypes), - tableWidth: initialDimensionVal, + table: table, + tableWidth: initialDimensionVal, + filterTextInput: createFilterTextInput(), + isTrimmed: false, + originalRows: table.GetVisibleRows(), + canSelectRows: true, } } +// createFilterTextInput creates and styles a text input for filtering +func createFilterTextInput() textinput.Model { + filterTextInput := textinput.New() + filterTextInput.Prompt = "Filter: " + filterTextInput.PromptStyle = lipgloss.NewStyle().Bold(true) + + return filterTextInput +} + // createRows creates a row for each instance type in the passed in list -func createRows(columnsData []*wideColumnsData) *[]table.Row { +func createRows(columnsData []*wideColumnsData, instanceTypes []*instancetypes.Details) *[]table.Row { rows := []table.Row{} // create a row for each instance type - for _, data := range columnsData { + for i, data := range columnsData { rowData := table.RowData{} // create a new row by iterating through the column data @@ -91,6 +125,12 @@ func createRows(columnsData []*wideColumnsData) *[]table.Row { rowData[columnName] = getUnderlyingValue(colValue) } + // add instance type as metaData + rowData[instanceTypeKey] = instanceTypes[i] + + // add selected flag as metadata + rowData[selectedKey] = false + newRow := table.NewRow(rowData) rows = append(rows, newRow) @@ -140,7 +180,8 @@ func createColumns(columnsData []*wideColumnsData) *[]table.Column { structType := reflect.TypeOf(columnDataStruct) for i := 0; i < structType.NumField(); i++ { columnHeader := structType.Field(i).Tag.Get(columnTag) - newCol := table.NewColumn(columnHeader, columnHeader, maxColWidth(columnsData, columnHeader)) + newCol := table.NewColumn(columnHeader, columnHeader, maxColWidth(columnsData, columnHeader)). + WithFiltered(true) columns = append(columns, newCol) } @@ -148,8 +189,8 @@ func createColumns(columnsData []*wideColumnsData) *[]table.Column { return &columns } -// createKeyMap creates a KeyMap with the controls for the table -func createKeyMap() *table.KeyMap { +// createTableKeyMap creates a KeyMap with the controls for the table +func createTableKeyMap() *table.KeyMap { keys := table.KeyMap{ RowDown: key.NewBinding( key.WithKeys("down"), @@ -181,8 +222,8 @@ func createTable(instanceTypes []*instancetypes.Details) table.Model { columnsData := getWideColumnsData(instanceTypes) newTable := table.New(*createColumns(columnsData)). - WithRows(*createRows(columnsData)). - WithKeyMap(*createKeyMap()). + WithRows(*createRows(columnsData, instanceTypes)). + WithKeyMap(*createTableKeyMap()). WithPageSize(initialDimensionVal). Focused(true). Border(customBorder). @@ -192,7 +233,9 @@ func createTable(instanceTypes []*instancetypes.Details) table.Model { lipgloss.NewStyle(). Align((lipgloss.Left)), ). - HeaderStyle(lipgloss.NewStyle().Align(lipgloss.Center).Bold(true)) + HeaderStyle(lipgloss.NewStyle().Align(lipgloss.Center).Bold(true)). + Filtered(true). + SelectableRows(true) return newTable } @@ -208,9 +251,11 @@ func (m tableModel) resizeView(msg tea.WindowSizeMsg) tableModel { if headerAndFooterPadding >= msg.Height { // height too short to fit rows m.table = m.table.WithPageSize(0) + m.tableRowsPerPage = 0 } else { newRowsPerPage := msg.Height - headerAndFooterPadding m.table = m.table.WithPageSize(newRowsPerPage) + m.tableRowsPerPage = newRowsPerPage } return m @@ -232,7 +277,7 @@ func (m tableModel) updateFooter() tableModel { controlsStr = tableControls[0:controlsWidth] + ellipses } - renderedControls := lipgloss.NewStyle().Faint(true).Render(controlsStr) + renderedControls := controlsStyle.Render(controlsStr) footerStr := fmt.Sprintf("%s%s", pageStr, renderedControls) m.table = m.table.WithStaticFooter(footerStr) @@ -241,6 +286,70 @@ func (m tableModel) updateFooter() tableModel { // update updates the state of the tableModel func (m tableModel) update(msg tea.Msg) (tableModel, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + // update filtering input field + if m.filterTextInput.Focused() { + var cmd tea.Cmd + if msg.String() == "enter" || msg.String() == "esc" { + // exit filter input and update controls string + m.filterTextInput.Blur() + m = m.updateFooter() + } else { + m.filterTextInput, cmd = m.filterTextInput.Update(msg) + } + + m.table = m.table.WithFilterInput(m.filterTextInput) + return m, cmd + } + + // listen for specific inputs + switch msg.String() { + case "f": + // focus filter input field + m.filterTextInput.Focus() + case "t": + // handle trimming to selected rows + if m.isTrimmed { + // undo trim + m = m.untrim() + m.isTrimmed = false + } else { + // trim + m = m.trim() + m.isTrimmed = true + } + case " ": + // custom toggling of selected rows because bubble tea implementation + // breaks trimming + if m.canSelectRows { + originalRows := m.getUnfilteredRows() + + selectedRow := m.table.HighlightedRow() + isSelected, ok := selectedRow.Data[selectedKey].(bool) + if !ok { + break + } + + // flip selected flag + selectedRow.Data[selectedKey] = !isSelected + selectedRow = selectedRow.Selected(!isSelected) + + // update selected row with new selected state. Must iterate through + // original rows since the cursor index in the bubble tea table + // takes the filter into account and therefore returns an incorrect index + for i, row := range originalRows { + if row.Data[instanceTypeKey] == selectedRow.Data[instanceTypeKey] { + originalRows[i] = selectedRow + break + } + } + + m.table = m.table.WithRows(originalRows) + } + } + } + var cmd tea.Cmd m.table, cmd = m.table.Update(msg) @@ -252,5 +361,108 @@ func (m tableModel) update(msg tea.Msg) (tableModel, tea.Cmd) { // view returns a string representing the table view func (m tableModel) view() string { - return m.table.View() + "\n" + outputStr := strings.Builder{} + + outputStr.WriteString(m.table.View()) + outputStr.WriteString("\n") + + if m.table.GetIsFilterActive() || m.filterTextInput.Focused() { + outputStr.WriteString(m.filterTextInput.View()) + outputStr.WriteString("\n") + } + + return outputStr.String() +} + +// sortTable sorts the table based on the sorting direction and sorting filter +func (m tableModel) sortTable(sortFilter string, sortDirection string) (tableModel, error) { + instanceTypes, rowMap := m.getInstanceTypeFromRows() + _ = rowMap + + // sort instance types + instanceTypes, err := sorter.Sort(instanceTypes, sortFilter, sortDirection) + if err != nil { + return m, err + } + + // get sorted rows from sorted instance types + rows := []table.Row{} + for _, instance := range instanceTypes { + currRow := rowMap[*instance.InstanceType] + rows = append(rows, currRow) + } + + m.table = m.table.WithRows(rows) + + // apply truncation if needed + if m.isTrimmed { + m = m.trim() + } + + return m, nil +} + +// getInstanceTypeFromRows goes through the rows of the table model and returns both a list of instance +// types and a mapping of instances to rows +func (m tableModel) getInstanceTypeFromRows() ([]*instancetypes.Details, map[string]table.Row) { + instanceTypes := []*instancetypes.Details{} + rowMap := make(map[string]table.Row) + + // get current rows + var rows []table.Row + if m.isTrimmed { + // if current table is trimmed, get the stored untrimmed rows + rows = m.originalRows + } else { + // since table isn't trimmed, we should get the unfiltered rows + // so that our rows have the most updated selected flags + rows = m.getUnfilteredRows() + } + + for _, row := range rows { + currInstance, ok := row.Data[instanceTypeKey].(*instancetypes.Details) + if !ok { + continue + } + + instanceTypes = append(instanceTypes, currInstance) + rowMap[*currInstance.InstanceType] = row + } + + return instanceTypes, rowMap +} + +// getUnfilteredRows gets the rows in the given table model without any filtering applied +func (m tableModel) getUnfilteredRows() []table.Row { + m.table = m.table.Filtered(false) + rows := m.table.GetVisibleRows() + + return rows +} + +// trim will trim the table to only the selected rows +func (m tableModel) trim() tableModel { + // store current state of rows before trimming + m.originalRows = m.getUnfilteredRows() + + // prevent rows from being selected until trim is + // undone + m.table = m.table.SelectableRows(false) + m.canSelectRows = false + + m.table = m.table.WithRows(m.table.SelectedRows()) + m.isTrimmed = true + + return m +} + +// untrim will return the table to the original rows +func (m tableModel) untrim() tableModel { + m.table = m.table.WithRows(m.originalRows) + + // allow rows to be selected again + m.table = m.table.SelectableRows(true) + m.canSelectRows = true + + return m } diff --git a/pkg/selector/outputs/verboseView.go b/pkg/selector/outputs/verboseView.go index 01f23c7..721e3a3 100644 --- a/pkg/selector/outputs/verboseView.go +++ b/pkg/selector/outputs/verboseView.go @@ -29,7 +29,7 @@ const ( outlinePadding = 8 // controls - verboseControls = "Controls: ↑/↓ - up/down • enter - return to table • q - quit" + verboseControls = "Controls: ↑/↓ - up/down • esc - return to table • q - quit" ) // verboseModel represents the current state of the verbose view @@ -37,8 +37,6 @@ type verboseModel struct { // model for verbose output viewport viewport viewport.Model - instanceTypes []*instancetypes.Details - // the instance which the verbose output is focused on focusedInstanceName *string } @@ -65,8 +63,7 @@ func initVerboseModel(instanceTypes []*instancetypes.Details) *verboseModel { viewportModel.MouseWheelEnabled = true return &verboseModel{ - viewport: viewportModel, - instanceTypes: instanceTypes, + viewport: viewportModel, } } @@ -114,7 +111,7 @@ func (m verboseModel) view() string { outputStr.WriteString("\n") // controls - outputStr.WriteString(lipgloss.NewStyle().Faint(true).Render(verboseControls)) + outputStr.WriteString(controlsStyle.Render(verboseControls)) outputStr.WriteString("\n") return outputStr.String() diff --git a/pkg/sorter/sorter.go b/pkg/sorter/sorter.go index 62e067e..829a74c 100644 --- a/pkg/sorter/sorter.go +++ b/pkg/sorter/sorter.go @@ -28,16 +28,43 @@ import ( const ( // Sort direction - sortAscending = "ascending" - sortAsc = "asc" - sortDescending = "descending" - sortDesc = "desc" + SortAscending = "ascending" + SortAsc = "asc" + SortDescending = "descending" + SortDesc = "desc" // Not all fields can be reached through a json path (Ex: gpu count) // so we have special flags for such cases. - gpuCountField = "gpus" - inferenceAcceleratorsField = "inference-accelerators" + GPUCountField = "gpus" + InferenceAcceleratorsField = "inference-accelerators" + + // shorthand flags + + VCPUs = "vcpus" + Memory = "memory" + GPUMemoryTotal = "gpu-memory-total" + NetworkInterfaces = "network-interfaces" + SpotPrice = "spot-price" + ODPrice = "on-demand-price" + InstanceStorage = "instance-storage" + EBSOptimizedBaselineBandwidth = "ebs-optimized-baseline-bandwidth" + EBSOptimizedBaselineThroughput = "ebs-optimized-baseline-throughput" + EBSOptimizedBaselineIOPS = "ebs-optimized-baseline-iops" + + // JSON field paths for shorthand flags + + instanceNamePath = ".InstanceType" + vcpuPath = ".VCpuInfo.DefaultVCpus" + memoryPath = ".MemoryInfo.SizeInMiB" + gpuMemoryTotalPath = ".GpuInfo.TotalGpuMemoryInMiB" + networkInterfacesPath = ".NetworkInfo.MaximumNetworkInterfaces" + spotPricePath = ".SpotPrice" + odPricePath = ".OndemandPricePerHour" + instanceStoragePath = ".InstanceStorageInfo.TotalSizeInGB" + ebsOptimizedBaselineBandwidthPath = ".EbsInfo.EbsOptimizedInfo.BaselineBandwidthInMbps" + ebsOptimizedBaselineThroughputPath = ".EbsInfo.EbsOptimizedInfo.BaselineThroughputInMBps" + ebsOptimizedBaselineIOPSPath = ".EbsInfo.EbsOptimizedInfo.BaselineIops" ) // sorterNode represents a sortable instance type which holds the value @@ -58,10 +85,29 @@ type sorter struct { // Sort sorts the given instance types by the given field in the given direction // // sortField is a json path to a field in the instancetypes.Details struct which represents -// the field to sort instance types by (Ex: ".MemoryInfo.SizeInMiB"). +// the field to sort instance types by (Ex: ".MemoryInfo.SizeInMiB"). Quantity flags present +// in the CLI (memory, gpus, etc.) are also accepted. // // sortDirection represents the direction to sort in. Valid options: "ascending", "asc", "descending", "desc". func Sort(instanceTypes []*instancetypes.Details, sortField string, sortDirection string) ([]*instancetypes.Details, error) { + sortingKeysMap := map[string]string{ + VCPUs: vcpuPath, + Memory: memoryPath, + GPUMemoryTotal: gpuMemoryTotalPath, + NetworkInterfaces: networkInterfacesPath, + SpotPrice: spotPricePath, + ODPrice: odPricePath, + InstanceStorage: instanceStoragePath, + EBSOptimizedBaselineBandwidth: ebsOptimizedBaselineBandwidthPath, + EBSOptimizedBaselineThroughput: ebsOptimizedBaselineThroughputPath, + EBSOptimizedBaselineIOPS: ebsOptimizedBaselineIOPSPath, + } + + // determine if user used a shorthand for sorting flag + if sortFieldShorthandPath, ok := sortingKeysMap[sortField]; ok { + sortField = sortFieldShorthandPath + } + sorter, err := newSorter(instanceTypes, sortField, sortDirection) if err != nil { return nil, fmt.Errorf("an error occurred when preparing to sort instance types: %v", err) @@ -84,12 +130,12 @@ func Sort(instanceTypes []*instancetypes.Details, sortField string, sortDirectio func newSorter(instanceTypes []*instancetypes.Details, sortField string, sortDirection string) (*sorter, error) { var isDescending bool switch sortDirection { - case sortDescending, sortDesc: + case SortDescending, SortDesc: isDescending = true - case sortAscending, sortAsc: + case SortAscending, SortAsc: isDescending = false default: - return nil, fmt.Errorf("invalid sort direction: %s (valid options: %s, %s, %s, %s)", sortDirection, sortAscending, sortAsc, sortDescending, sortDesc) + return nil, fmt.Errorf("invalid sort direction: %s (valid options: %s, %s, %s, %s)", sortDirection, SortAscending, SortAsc, SortDescending, SortDesc) } sortField = formatSortField(sortField) @@ -117,7 +163,7 @@ func newSorter(instanceTypes []*instancetypes.Details, sortField string, sortDir // matches one of the special flags. func formatSortField(sortField string) string { // check to see if the sorting field matched one of the special exceptions - if sortField == gpuCountField || sortField == inferenceAcceleratorsField { + if sortField == GPUCountField || sortField == InferenceAcceleratorsField { return sortField } @@ -130,13 +176,13 @@ func newSorterNode(instanceType *instancetypes.Details, sortField string) (*sort // some important fields (such as gpu count) can not be accessed directly in the instancetypes.Details // struct, so we have special hard-coded flags to handle such cases switch sortField { - case gpuCountField: + case GPUCountField: gpuCount := getTotalGpusCount(instanceType) return &sorterNode{ instanceType: instanceType, fieldValue: reflect.ValueOf(gpuCount), }, nil - case inferenceAcceleratorsField: + case InferenceAcceleratorsField: acceleratorsCount := getTotalAcceleratorsCount(instanceType) return &sorterNode{ instanceType: instanceType,