diff --git a/README.md b/README.md index c9d182d..064fd87 100644 --- a/README.md +++ b/README.md @@ -133,17 +133,18 @@ t3a.medium 2 4 **Wide Table Output** ``` $ ec2-instance-selector --memory 4 --vcpus 2 --cpu-architecture x86_64 -r us-east-1 -o table-wide -Instance Type VCPUs Mem (GiB) Hypervisor Current Gen Hibernation Support CPU Arch Network Performance ENIs GPUs GPU Mem (GiB) GPU Info On-Demand Price/Hr Spot Price/Hr (30d avg) -------------- ----- --------- ---------- ----------- ------------------- -------- ------------------- ---- ---- ------------- -------- ------------------ ----------------------- -c5.large 2 4 nitro true true x86_64 Up to 10 Gigabit 3 0 0 $0.085 $0.04708 -c5a.large 2 4 nitro true false x86_64 Up to 10 Gigabit 3 0 0 $0.077 $0.03249 -c5ad.large 2 4 nitro true false x86_64 Up to 10 Gigabit 3 0 0 $0.086 $0.0324 -c5d.large 2 4 nitro true true x86_64 Up to 10 Gigabit 3 0 0 $0.096 $0.03525 -c6a.large 2 4 nitro true false x86_64 Up to 12.5 Gigabit 3 0 0 $0.0765 $0.034 -c6i.large 2 4 nitro true false x86_64 Up to 12.5 Gigabit 3 0 0 $0.085 $0.03416 -t2.medium 2 4 xen true true i386, x86_64 Low to Moderate 3 0 0 $0.0464 $0.01407 -t3.medium 2 4 nitro true true x86_64 Up to 5 Gigabit 3 0 0 $0.0416 $0.0125 -t3a.medium 2 4 nitro true true x86_64 Up to 5 Gigabit 3 0 0 $0.0376 $0.01431 +Instance Type VCPUs Mem (GiB) Hypervisor Current Gen Hibernation Support CPU Arch Network Performance ENIs GPUs GPU Mem (GiB) GPU Info On-Demand Price/Hr Spot Price/Hr (30d avg) +------------- ----- --------- ---------- ----------- ------------------- -------- ------------------- ---- ---- ------------- -------- ------------------ ----------------------- +c5.large 2 4 nitro true true x86_64 Up to 10 Gigabit 3 0 0 none -Not Fetched- $0.03932 +c5a.large 2 4 nitro true false x86_64 Up to 10 Gigabit 3 0 0 none -Not Fetched- $0.03822 +c5ad.large 2 4 nitro true false x86_64 Up to 10 Gigabit 3 0 0 none -Not Fetched- $0.03449 +c5d.large 2 4 nitro true true x86_64 Up to 10 Gigabit 3 0 0 none $0.096 $0.03983 +c6a.large 2 4 nitro true false x86_64 Up to 12.5 Gigabit 3 0 0 none $0.0765 $0.034 +c6i.large 2 4 nitro true false x86_64 Up to 12.5 Gigabit 3 0 0 none $0.085 $0.03605 +c6id.large 2 4 nitro true false x86_64 Up to 12.5 Gigabit 3 0 0 none -Not Fetched- $0.034 +t2.medium 2 4 xen true true i386, x86_64 Low to Moderate 3 0 0 none $0.0464 $0.0139 +t3.medium 2 4 nitro true true x86_64 Up to 5 Gigabit 3 0 0 none $0.0416 $0.0125 +t3a.medium 2 4 nitro true true x86_64 Up to 5 Gigabit 3 0 0 none -Not Fetched- $0.01246 ``` **Sort by memory in ascending order using shorthand** @@ -151,16 +152,16 @@ t3a.medium 2 4 nitro true true $ ec2-instance-selector -r us-east-1 -o table-wide --max-results 10 --sort-by memory --sort-direction asc Instance Type VCPUs Mem (GiB) Hypervisor Current Gen Hibernation Support CPU Arch Network Performance ENIs GPUs GPU Mem (GiB) GPU Info On-Demand Price/Hr Spot Price/Hr (30d avg) ------------- ----- --------- ---------- ----------- ------------------- -------- ------------------- ---- ---- ------------- -------- ------------------ ----------------------- -t2.nano 1 0.5 xen true true i386, x86_64 Low to Moderate 2 0 0 $0.0058 -Not Fetched- -t4g.nano 2 0.5 nitro true false arm64 Up to 5 Gigabit 2 0 0 $0.0042 $0.0013 -t3a.nano 2 0.5 nitro true true x86_64 Up to 5 Gigabit 2 0 0 $0.0047 $0.00178 -t3.nano 2 0.5 nitro true true x86_64 Up to 5 Gigabit 2 0 0 $0.0052 $0.0016 -t1.micro 1 0.6123 xen false false i386, x86_64 Very Low 2 0 0 $0.02 $0.00213 -t3a.micro 2 1 nitro true true x86_64 Up to 5 Gigabit 2 0 0 $0.0094 $0.00332 -t3.micro 2 1 nitro true true x86_64 Up to 5 Gigabit 2 0 0 $0.0104 $0.0031 -t2.micro 1 1 xen true true i386, x86_64 Low to Moderate 2 0 0 $0.0116 $0.0035 -t4g.micro 2 1 nitro true false arm64 Up to 5 Gigabit 2 0 0 $0.0084 $0.0025 -m1.small 1 1.69922 xen false false i386, x86_64 Low 2 0 0 $0.044 $0.00865 +t2.nano 1 0.5 xen true true i386, x86_64 Low to Moderate 2 0 0 none $0.0058 -Not Fetched- +t4g.nano 2 0.5 nitro true false arm64 Up to 5 Gigabit 2 0 0 none $0.0042 $0.0013 +t3a.nano 2 0.5 nitro true true x86_64 Up to 5 Gigabit 2 0 0 none -Not Fetched- $0.00328 +t3.nano 2 0.5 nitro true true x86_64 Up to 5 Gigabit 2 0 0 none $0.0052 $0.0016 +t1.micro 1 0.6123 xen false false i386, x86_64 Very Low 2 0 0 none -Not Fetched- $0.00205 +t3a.micro 2 1 nitro true true x86_64 Up to 5 Gigabit 2 0 0 none -Not Fetched- $0.00284 +t3.micro 2 1 nitro true true x86_64 Up to 5 Gigabit 2 0 0 none $0.0104 $0.0031 +t2.micro 1 1 xen true true i386, x86_64 Low to Moderate 2 0 0 none -Not Fetched- $0.0035 +t4g.micro 2 1 nitro true false arm64 Up to 5 Gigabit 2 0 0 none -Not Fetched- $0.0025 +m1.small 1 1.69922 xen false false i386, x86_64 Low 2 0 0 none -Not Fetched- $0.01876 NOTE: 547 entries were truncated, increase --max-results to see more ``` Available shorthand flags: vcpus, memory, gpu-memory-total, network-interfaces, spot-price, on-demand-price, instance-storage, ebs-optimized-baseline-bandwidth, ebs-optimized-baseline-throughput, ebs-optimized-baseline-iops, gpus, inference-accelerators @@ -170,16 +171,16 @@ Available shorthand flags: vcpus, memory, gpu-memory-total, network-interfaces, $ ec2-instance-selector -r us-east-1 -o table-wide --max-results 10 --sort-by .MemoryInfo.SizeInMiB --sort-direction desc Instance Type VCPUs Mem (GiB) Hypervisor Current Gen Hibernation Support CPU Arch Network Performance ENIs GPUs GPU Mem (GiB) GPU Info On-Demand Price/Hr Spot Price/Hr (30d avg) ------------- ----- --------- ---------- ----------- ------------------- -------- ------------------- ---- ---- ------------- -------- ------------------ ----------------------- -u-12tb1.112xlarge 448 12,288 nitro true false x86_64 100 Gigabit 15 0 0 $109.2 -Not Fetched- -u-9tb1.112xlarge 448 9,216 nitro true false x86_64 100 Gigabit 15 0 0 $81.9 -Not Fetched- -u-6tb1.112xlarge 448 6,144 nitro true false x86_64 100 Gigabit 15 0 0 $54.6 -Not Fetched- -u-6tb1.56xlarge 224 6,144 nitro true false x86_64 100 Gigabit 15 0 0 $46.40391 -Not Fetched- -x2iedn.metal 128 4,096 none true false x86_64 100 Gigabit 15 0 0 $26.676 $8.0028 -x2iedn.32xlarge 128 4,096 nitro true false x86_64 100 Gigabit 15 0 0 $26.676 $8.0028 -x1e.32xlarge 128 3,904 xen true false x86_64 25 Gigabit 8 0 0 $26.688 $8.03461 -x2iedn.24xlarge 96 3,072 nitro true false x86_64 75 Gigabit 15 0 0 $20.007 $13.23032 -u-3tb1.56xlarge 224 3,072 nitro true false x86_64 50 Gigabit 8 0 0 $27.3 -Not Fetched- -x2idn.metal 128 2,048 none true false x86_64 100 Gigabit 15 0 0 $13.338 $4.67017 +u-12tb1.112xlarge 448 12,288 nitro true false x86_64 100 Gigabit 15 0 0 none $109.2 -Not Fetched- +u-9tb1.112xlarge 448 9,216 nitro true false x86_64 100 Gigabit 15 0 0 none -Not Fetched- -Not Fetched- +u-6tb1.112xlarge 448 6,144 nitro true false x86_64 100 Gigabit 15 0 0 none $54.6 -Not Fetched- +u-6tb1.56xlarge 224 6,144 nitro true false x86_64 100 Gigabit 15 0 0 none $46.40391 -Not Fetched- +x2iedn.metal 128 4,096 none true false x86_64 100 Gigabit 15 0 0 none $26.676 $20.92296 +x2iedn.32xlarge 128 4,096 nitro true false x86_64 100 Gigabit 15 0 0 none $26.676 $8.70294 +x1e.32xlarge 128 3,904 xen true false x86_64 25 Gigabit 8 0 0 none $26.688 $8.0064 +x2iedn.24xlarge 96 3,072 nitro true false x86_64 75 Gigabit 15 0 0 none $20.007 $6.0021 +u-3tb1.56xlarge 224 3,072 nitro true false x86_64 50 Gigabit 8 0 0 none $27.3 -Not Fetched- +x2idn.metal 128 2,048 none true false x86_64 100 Gigabit 15 0 0 none $13.338 $7.46603 NOTE: 547 entries were truncated, increase --max-results to see more ``` JSON path must point to a field in the [instancetype.Details struct](https://github.com/aws/amazon-ec2-instance-selector/blob/5bffbf2750ee09f5f1308bdc8d4b635a2c6e2721/pkg/instancetypes/instancetypes.go#L37). diff --git a/cmd/main.go b/cmd/main.go index 9bb4c30..c34e873 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -445,7 +445,7 @@ Full docs can be found at github.com/aws/amazon-` + binName var itemsTruncated int var instanceTypes []string if outputFlag != nil && *outputFlag == bubbleTeaOutput { - p := tea.NewProgram(outputs.NewBubbleTeaModel(instanceTypesDetails)) + p := tea.NewProgram(outputs.NewBubbleTeaModel(instanceTypesDetails), tea.WithMouseCellMotion()) if err := p.Start(); err != nil { fmt.Printf("An error occurred when starting bubble tea: %v", err) os.Exit(1) diff --git a/pkg/selector/outputs/bubbletea.go b/pkg/selector/outputs/bubbletea.go index 28bc4b8..65fe7b0 100644 --- a/pkg/selector/outputs/bubbletea.go +++ b/pkg/selector/outputs/bubbletea.go @@ -14,60 +14,41 @@ package outputs import ( - "fmt" - "reflect" - "strings" - "github.com/aws/amazon-ec2-instance-selector/v2/pkg/instancetypes" - "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/evertras/bubble-table/table" "github.com/muesli/termenv" ) const ( - // table formatting - headerAndFooterPadding = 7 - headerPadding = 2 - - // controls - controlsString = "Controls: ↑/↓ - up/down • ←/→ - left/right • shift + ←/→ - pg up/down • q - quit" + // can't get terminal dimensions on startup, so use this + initialDimensionVal = 30 ) -var ( - customBorder = table.Border{ - Top: "─", - Left: "│", - Right: "│", - Bottom: "─", - - TopRight: "╮", - TopLeft: "╭", - BottomRight: "╯", - BottomLeft: "╰", - - TopJunction: "┬", - LeftJunction: "├", - RightJunction: "┤", - BottomJunction: "┴", - InnerJunction: "┼", - - InnerDivider: "│", - } +const ( + // table states + stateTable = "table" + stateVerbose = "verbose" ) // BubbleTeaModel is used to hold the state of the bubble tea TUI type BubbleTeaModel struct { - // the model for the table output - TableModel table.Model + // holds the output currentState of the model + currentState string + + // the model for the table view + tableModel tableModel + + // holds state for the verbose view + verboseModel verboseModel } // NewBubbleTeaModel initializes a new bubble tea Model which represents // a stylized table to display instance types func NewBubbleTeaModel(instanceTypes []*instancetypes.Details) BubbleTeaModel { return BubbleTeaModel{ - TableModel: createTable(instanceTypes), + currentState: stateTable, + tableModel: *initTableModel(instanceTypes), + verboseModel: *initVerboseModel(instanceTypes), } } @@ -79,181 +60,68 @@ func (m BubbleTeaModel) Init() tea.Cmd { // Update is used by bubble tea to update the state of the bubble // tea model based on user input func (m BubbleTeaModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - // check for quit switch msg := msg.(type) { case tea.KeyMsg: + // check for quit or change in state switch msg.String() { - case "ctrl+c", "esc", "q": + 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 + + // get focused instance type + rowIndex := m.tableModel.table.GetHighlightedRowIndex() + focusedInstance := m.verboseModel.instanceTypes[rowIndex] + + // set content of view + m.verboseModel.focusedInstanceName = focusedInstance.InstanceType + m.verboseModel.viewport.SetContent(VerboseInstanceTypeOutput([]*instancetypes.Details{focusedInstance})[0]) + + // move viewport to top of printout + m.verboseModel.viewport.SetYOffset(0) + case stateVerbose: + // switch from verbose state to table state + m.currentState = stateTable + } } case tea.WindowSizeMsg: - // handle screen resizing - // This is needed to handle a bug with bubble tea // where resizing causes misprints (https://github.com/Evertras/bubble-table/issues/121) termenv.ClearScreen() - // handle width changes - m.TableModel = m.TableModel.WithMaxTotalWidth(msg.Width) - - // handle height changes - if headerAndFooterPadding >= msg.Height { - // height too short to fit rows - m.TableModel = m.TableModel.WithPageSize(0) - } else { - newRowsPerPage := msg.Height - headerAndFooterPadding - m.TableModel = m.TableModel.WithPageSize(newRowsPerPage) - } + // handle screen resizing + m.tableModel = m.tableModel.resizeView(msg) + m.verboseModel = m.verboseModel.resizeView(msg) } - // update table - var cmd tea.Cmd - m.TableModel, cmd = m.TableModel.Update(msg) - - // update footer - controlsStr := lipgloss.NewStyle().Faint(true).Render(controlsString) - footerStr := fmt.Sprintf("Page: %d/%d | %s", m.TableModel.CurrentPage(), m.TableModel.MaxPages(), controlsStr) - m.TableModel = m.TableModel.WithStaticFooter(footerStr) + 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 + } - return m, cmd + return m, nil } // View is used by bubble tea to render the bubble tea model func (m BubbleTeaModel) View() string { - outputStr := strings.Builder{} - - outputStr.WriteString(m.TableModel.View()) - outputStr.WriteString("\n") - - return outputStr.String() -} - -// table creation helpers: - -// createRows creates a row for each instance type in the passed in list -func createRows(columnsData []*wideColumnsData) *[]table.Row { - rows := []table.Row{} - - // create a row for each instance type - for _, data := range columnsData { - rowData := table.RowData{} - - // create a new row by iterating through the column data - // struct and using struct tags as column keys - structType := reflect.TypeOf(*data) - structValue := reflect.ValueOf(*data) - for i := 0; i < structType.NumField(); i++ { - currField := structType.Field(i) - columnName := currField.Tag.Get(columnTag) - colValue := structValue.Field(i) - rowData[columnName] = getUnderlyingValue(colValue) - } - - newRow := table.NewRow(rowData) - - rows = append(rows, newRow) + switch m.currentState { + case stateTable: + return m.tableModel.view() + case stateVerbose: + return m.verboseModel.view() } - return &rows -} - -// maxColWidth finds the maximum width element in the given column -func maxColWidth(columnsData []*wideColumnsData, columnHeader string) int { - // default max width is the width of the header itself with padding - maxWidth := len(columnHeader) + headerPadding - - for _, data := range columnsData { - // get data at given column - structType := reflect.TypeOf(*data) - structValue := reflect.ValueOf(*data) - var underlyingValue interface{} - for i := 0; i < structType.NumField(); i++ { - currField := structType.Field(i) - columnName := currField.Tag.Get(columnTag) - if columnName == columnHeader { - colValue := structValue.Field(i) - underlyingValue = getUnderlyingValue(colValue) - break - } - } - - // see if the width of the current column element exceeds - // the previous max width - currWidth := len(fmt.Sprintf("%v", underlyingValue)) - if currWidth > maxWidth { - maxWidth = currWidth - } - } - - return maxWidth -} - -// createColumns creates columns based on the tags in the wideColumnsData -// struct -func createColumns(columnsData []*wideColumnsData) *[]table.Column { - columns := []table.Column{} - - // iterate through wideColumnsData struct and create a new column for each field tag - columnDataStruct := wideColumnsData{} - 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)) - - columns = append(columns, newCol) - } - - return &columns -} - -// createKeyMap creates a KeyMap with the controls for the table -func createKeyMap() *table.KeyMap { - keys := table.KeyMap{ - RowDown: key.NewBinding( - key.WithKeys("down"), - ), - RowUp: key.NewBinding( - key.WithKeys("up"), - ), - ScrollLeft: key.NewBinding( - key.WithKeys("left"), - ), - ScrollRight: key.NewBinding( - key.WithKeys("right"), - ), - PageDown: key.NewBinding( - key.WithKeys("shift+right"), - ), - PageUp: key.NewBinding( - key.WithKeys("shift+left"), - ), - } - - return &keys -} - -// createTable creates an intractable table which contains information about all of -// the given instance types -func createTable(instanceTypes []*instancetypes.Details) table.Model { - // can't get terminal size yet, so set temporary value - initialDimensionVal := 30 - - // calculate and fetch all column data from instance types - columnsData := getWideColumnsData(instanceTypes) - - newTable := table.New(*createColumns(columnsData)). - WithRows(*createRows(columnsData)). - WithKeyMap(*createKeyMap()). - WithPageSize(initialDimensionVal). - Focused(true). - Border(customBorder). - WithMaxTotalWidth(initialDimensionVal). - WithHorizontalFreezeColumnCount(1). - WithBaseStyle( - lipgloss.NewStyle(). - Align((lipgloss.Left)), - ). - HeaderStyle(lipgloss.NewStyle().Align(lipgloss.Center).Bold(true)) - - return newTable + return "" } diff --git a/pkg/selector/outputs/bubbletea_test.go b/pkg/selector/outputs/bubbletea_internal_test.go similarity index 84% rename from pkg/selector/outputs/bubbletea_test.go rename to pkg/selector/outputs/bubbletea_internal_test.go index 4d2880f..7384995 100644 --- a/pkg/selector/outputs/bubbletea_test.go +++ b/pkg/selector/outputs/bubbletea_internal_test.go @@ -11,7 +11,7 @@ // express or implied. See the License for the specific language governing // permissions and limitations under the License. -package outputs_test +package outputs import ( "encoding/json" @@ -21,11 +21,14 @@ import ( "testing" "github.com/aws/amazon-ec2-instance-selector/v2/pkg/instancetypes" - "github.com/aws/amazon-ec2-instance-selector/v2/pkg/selector/outputs" h "github.com/aws/amazon-ec2-instance-selector/v2/pkg/test" "github.com/evertras/bubble-table/table" ) +const ( + mockFilesPath = "../../../test/static" +) + // helpers // getInstanceTypeDetails unmarshalls the json file in the given testing folder @@ -59,8 +62,8 @@ func TestNewBubbleTeaModel_Hypervisor(t *testing.T) { instanceTypes := getInstanceTypeDetails(t, "g3_16xlarge.json") // test non nil Hypervisor - model := outputs.NewBubbleTeaModel(instanceTypes) - rows := model.TableModel.GetVisibleRows() + model := NewBubbleTeaModel(instanceTypes) + rows := model.tableModel.table.GetVisibleRows() expectedHypervisor := "xen" actualHypervisor := rows[0].Data["Hypervisor"] @@ -68,8 +71,8 @@ func TestNewBubbleTeaModel_Hypervisor(t *testing.T) { // test nil Hypervisor instanceTypes[0].Hypervisor = nil - model = outputs.NewBubbleTeaModel(instanceTypes) - rows = model.TableModel.GetVisibleRows() + model = NewBubbleTeaModel(instanceTypes) + rows = model.tableModel.table.GetVisibleRows() expectedHypervisor = "none" actualHypervisor = rows[0].Data["Hypervisor"] @@ -78,8 +81,8 @@ func TestNewBubbleTeaModel_Hypervisor(t *testing.T) { func TestNewBubbleTeaModel_CPUArchitectures(t *testing.T) { instanceTypes := getInstanceTypeDetails(t, "g3_16xlarge.json") - model := outputs.NewBubbleTeaModel(instanceTypes) - rows := model.TableModel.GetVisibleRows() + model := NewBubbleTeaModel(instanceTypes) + rows := model.tableModel.table.GetVisibleRows() actualGPUArchitectures := "x86_64" expectedGPUArchitectures := rows[0].Data["CPU Arch"] @@ -89,8 +92,8 @@ func TestNewBubbleTeaModel_CPUArchitectures(t *testing.T) { func TestNewBubbleTeaModel_GPU(t *testing.T) { instanceTypes := getInstanceTypeDetails(t, "g3_16xlarge.json") - model := outputs.NewBubbleTeaModel(instanceTypes) - rows := model.TableModel.GetVisibleRows() + model := NewBubbleTeaModel(instanceTypes) + rows := model.tableModel.table.GetVisibleRows() // test GPU count expectedGPUCount := "4" @@ -115,8 +118,8 @@ func TestNewBubbleTeaModel_ODPricing(t *testing.T) { instanceTypes := getInstanceTypeDetails(t, "g3_16xlarge.json") // test non nil OD price - model := outputs.NewBubbleTeaModel(instanceTypes) - rows := model.TableModel.GetVisibleRows() + model := NewBubbleTeaModel(instanceTypes) + rows := model.tableModel.table.GetVisibleRows() expectedODPrice := "$4.56" actualODPrice := fmt.Sprintf("%v", rows[0].Data["On-Demand Price/Hr"]) @@ -124,8 +127,8 @@ func TestNewBubbleTeaModel_ODPricing(t *testing.T) { // test nil OD price instanceTypes[0].OndemandPricePerHour = nil - model = outputs.NewBubbleTeaModel(instanceTypes) - rows = model.TableModel.GetVisibleRows() + model = NewBubbleTeaModel(instanceTypes) + rows = model.tableModel.table.GetVisibleRows() expectedODPrice = "-Not Fetched-" actualODPrice = fmt.Sprintf("%v", rows[0].Data["On-Demand Price/Hr"]) @@ -136,8 +139,8 @@ func TestNewBubbleTeaModel_SpotPricing(t *testing.T) { instanceTypes := getInstanceTypeDetails(t, "g3_16xlarge.json") // test non nil spot price - model := outputs.NewBubbleTeaModel(instanceTypes) - rows := model.TableModel.GetVisibleRows() + model := NewBubbleTeaModel(instanceTypes) + rows := model.tableModel.table.GetVisibleRows() expectedODPrice := "$1.368" actualODPrice := fmt.Sprintf("%v", rows[0].Data["Spot Price/Hr (30d avg)"]) @@ -145,8 +148,8 @@ func TestNewBubbleTeaModel_SpotPricing(t *testing.T) { // test nil spot price instanceTypes[0].SpotPrice = nil - model = outputs.NewBubbleTeaModel(instanceTypes) - rows = model.TableModel.GetVisibleRows() + model = NewBubbleTeaModel(instanceTypes) + rows = model.tableModel.table.GetVisibleRows() expectedODPrice = "-Not Fetched-" actualODPrice = fmt.Sprintf("%v", rows[0].Data["Spot Price/Hr (30d avg)"]) @@ -155,8 +158,8 @@ func TestNewBubbleTeaModel_SpotPricing(t *testing.T) { func TestNewBubbleTeaModel_Rows(t *testing.T) { instanceTypes := getInstanceTypeDetails(t, "3_instances.json") - model := outputs.NewBubbleTeaModel(instanceTypes) - rows := model.TableModel.GetVisibleRows() + model := NewBubbleTeaModel(instanceTypes) + rows := model.tableModel.table.GetVisibleRows() h.Assert(t, len(rows) == len(instanceTypes), "Number of rows should be %d, but is actually %d", len(instanceTypes), len(rows)) @@ -165,6 +168,6 @@ func TestNewBubbleTeaModel_Rows(t *testing.T) { currInstanceName := instanceTypes[i].InstanceType currRowName := rows[i].Data["Instance Type"] - h.Assert(t, *currInstanceName == currRowName, "Rows should be in following order: %s. Actual order: [%s]", outputs.OneLineOutput(instanceTypes), getRowsInstances(rows)) + h.Assert(t, *currInstanceName == currRowName, "Rows should be in following order: %s. Actual order: [%s]", OneLineOutput(instanceTypes), getRowsInstances(rows)) } } diff --git a/pkg/selector/outputs/outputs.go b/pkg/selector/outputs/outputs.go index a4c600f..468073d 100644 --- a/pkg/selector/outputs/outputs.go +++ b/pkg/selector/outputs/outputs.go @@ -221,6 +221,8 @@ func getWideColumnsData(instanceTypes []*instancetypes.Details) []*wideColumnsDa gpus = gpus + *gpuInfo.Count gpuType = append(gpuType, *gpuInfo.Manufacturer+" "+*gpuInfo.Name) } + } else { + gpuType = append(gpuType, none) } onDemandPricePerHourStr := "-Not Fetched-" diff --git a/pkg/selector/outputs/tableView.go b/pkg/selector/outputs/tableView.go new file mode 100644 index 0000000..30de42d --- /dev/null +++ b/pkg/selector/outputs/tableView.go @@ -0,0 +1,256 @@ +// 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" + "reflect" + + "github.com/aws/amazon-ec2-instance-selector/v2/pkg/instancetypes" + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/evertras/bubble-table/table" +) + +const ( + // table formatting + headerAndFooterPadding = 7 + headerPadding = 2 + + // controls + tableControls = "Controls: ↑/↓ - up/down • ←/→ - left/right • shift + ←/→ - pg up/down • enter - expand • q - quit" + ellipses = "..." +) + +type tableModel struct { + // the model for the table output + table table.Model + + tableWidth int +} + +var ( + customBorder = table.Border{ + Top: "─", + Left: "│", + Right: "│", + Bottom: "─", + + TopRight: "╮", + TopLeft: "╭", + BottomRight: "╯", + BottomLeft: "╰", + + TopJunction: "┬", + LeftJunction: "├", + RightJunction: "┤", + BottomJunction: "┴", + InnerJunction: "┼", + + InnerDivider: "│", + } +) + +// initTableModel initializes and returns a new tableModel based on the given +// instance type details +func initTableModel(instanceTypes []*instancetypes.Details) *tableModel { + return &tableModel{ + table: createTable(instanceTypes), + tableWidth: initialDimensionVal, + } +} + +// createRows creates a row for each instance type in the passed in list +func createRows(columnsData []*wideColumnsData) *[]table.Row { + rows := []table.Row{} + + // create a row for each instance type + for _, data := range columnsData { + rowData := table.RowData{} + + // create a new row by iterating through the column data + // struct and using struct tags as column keys + structType := reflect.TypeOf(*data) + structValue := reflect.ValueOf(*data) + for i := 0; i < structType.NumField(); i++ { + currField := structType.Field(i) + columnName := currField.Tag.Get(columnTag) + colValue := structValue.Field(i) + rowData[columnName] = getUnderlyingValue(colValue) + } + + newRow := table.NewRow(rowData) + + rows = append(rows, newRow) + } + + return &rows +} + +// maxColWidth finds the maximum width element in the given column +func maxColWidth(columnsData []*wideColumnsData, columnHeader string) int { + // default max width is the width of the header itself with padding + maxWidth := len(columnHeader) + headerPadding + + for _, data := range columnsData { + // get data at given column + structType := reflect.TypeOf(*data) + structValue := reflect.ValueOf(*data) + var underlyingValue interface{} + for i := 0; i < structType.NumField(); i++ { + currField := structType.Field(i) + columnName := currField.Tag.Get(columnTag) + if columnName == columnHeader { + colValue := structValue.Field(i) + underlyingValue = getUnderlyingValue(colValue) + break + } + } + + // see if the width of the current column element exceeds + // the previous max width + currWidth := len(fmt.Sprintf("%v", underlyingValue)) + if currWidth > maxWidth { + maxWidth = currWidth + } + } + + return maxWidth +} + +// createColumns creates columns based on the tags in the wideColumnsData +// struct +func createColumns(columnsData []*wideColumnsData) *[]table.Column { + columns := []table.Column{} + + // iterate through wideColumnsData struct and create a new column for each field tag + columnDataStruct := wideColumnsData{} + 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)) + + columns = append(columns, newCol) + } + + return &columns +} + +// createKeyMap creates a KeyMap with the controls for the table +func createKeyMap() *table.KeyMap { + keys := table.KeyMap{ + RowDown: key.NewBinding( + key.WithKeys("down"), + ), + RowUp: key.NewBinding( + key.WithKeys("up"), + ), + ScrollLeft: key.NewBinding( + key.WithKeys("left"), + ), + ScrollRight: key.NewBinding( + key.WithKeys("right"), + ), + PageDown: key.NewBinding( + key.WithKeys("shift+right"), + ), + PageUp: key.NewBinding( + key.WithKeys("shift+left"), + ), + } + + return &keys +} + +// createTable creates an intractable table which contains information about all of +// the given instance types +func createTable(instanceTypes []*instancetypes.Details) table.Model { + // calculate and fetch all column data from instance types + columnsData := getWideColumnsData(instanceTypes) + + newTable := table.New(*createColumns(columnsData)). + WithRows(*createRows(columnsData)). + WithKeyMap(*createKeyMap()). + WithPageSize(initialDimensionVal). + Focused(true). + Border(customBorder). + WithMaxTotalWidth(initialDimensionVal). + WithHorizontalFreezeColumnCount(1). + WithBaseStyle( + lipgloss.NewStyle(). + Align((lipgloss.Left)), + ). + HeaderStyle(lipgloss.NewStyle().Align(lipgloss.Center).Bold(true)) + + return newTable +} + +// resizeView will change the dimensions of the table in order to accommodate +// the new window dimensions represented by the given tea.WindowSizeMsg +func (m tableModel) resizeView(msg tea.WindowSizeMsg) tableModel { + // handle width changes + m.table = m.table.WithMaxTotalWidth(msg.Width) + m.tableWidth = msg.Width + + // handle height changes + if headerAndFooterPadding >= msg.Height { + // height too short to fit rows + m.table = m.table.WithPageSize(0) + } else { + newRowsPerPage := msg.Height - headerAndFooterPadding + m.table = m.table.WithPageSize(newRowsPerPage) + } + + return m +} + +// updateFooter updates the page and controls string in the table footer +func (m tableModel) updateFooter() tableModel { + controlsStr := tableControls + + // prevent controls text from wrapping to avoid table misprints + pageStr := fmt.Sprintf("Page: %d/%d | ", m.table.CurrentPage(), m.table.MaxPages()) + if m.tableWidth < len(pageStr)+len(controlsStr) { + controlsWidth := m.tableWidth - len(ellipses) - len(pageStr) - 2 + if controlsWidth < 0 { + controlsWidth = 0 + } else if controlsWidth > len(tableControls) { + controlsWidth = len(tableControls) + } + controlsStr = tableControls[0:controlsWidth] + ellipses + } + + renderedControls := lipgloss.NewStyle().Faint(true).Render(controlsStr) + footerStr := fmt.Sprintf("%s%s", pageStr, renderedControls) + m.table = m.table.WithStaticFooter(footerStr) + + return m +} + +// update updates the state of the tableModel +func (m tableModel) update(msg tea.Msg) (tableModel, tea.Cmd) { + var cmd tea.Cmd + m.table, cmd = m.table.Update(msg) + + // update footer + m = m.updateFooter() + + return m, cmd +} + +// view returns a string representing the table view +func (m tableModel) view() string { + return m.table.View() + "\n" +} diff --git a/pkg/selector/outputs/verboseView.go b/pkg/selector/outputs/verboseView.go new file mode 100644 index 0000000..01f23c7 --- /dev/null +++ b/pkg/selector/outputs/verboseView.go @@ -0,0 +1,121 @@ +// 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" + "math" + "strings" + + "github.com/aws/amazon-ec2-instance-selector/v2/pkg/instancetypes" + "github.com/charmbracelet/bubbles/viewport" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +const ( + // verbose view formatting + outlinePadding = 8 + + // controls + verboseControls = "Controls: ↑/↓ - up/down • enter - return to table • q - quit" +) + +// verboseModel represents the current state of the verbose view +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 +} + +// styling for viewport +var ( + titleStyle = func() lipgloss.Style { + b := lipgloss.RoundedBorder() + b.Right = "├" + return lipgloss.NewStyle().BorderStyle(b).Padding(0, 1) + }() + + infoStyle = func() lipgloss.Style { + b := lipgloss.RoundedBorder() + b.Left = "┤" + return titleStyle.Copy().BorderStyle(b) + }() +) + +// initVerboseModel initializes and returns a new verboseModel based on the given +// instance type details +func initVerboseModel(instanceTypes []*instancetypes.Details) *verboseModel { + viewportModel := viewport.New(initialDimensionVal, initialDimensionVal) + viewportModel.MouseWheelEnabled = true + + return &verboseModel{ + viewport: viewportModel, + instanceTypes: instanceTypes, + } +} + +// resizeView will change the dimensions of the verbose viewport in order to accommodate +// the new window dimensions represented by the given tea.WindowSizeMsg +func (m verboseModel) resizeView(msg tea.WindowSizeMsg) verboseModel { + // handle width changes + m.viewport.Width = msg.Width + + // handle height changes + if outlinePadding >= msg.Height { + // height too short to fit viewport + m.viewport.Height = 0 + } else { + newHeight := msg.Height - outlinePadding + m.viewport.Height = newHeight + } + + return m +} + +// update updates the state of the verboseModel +func (m verboseModel) update(msg tea.Msg) (verboseModel, tea.Cmd) { + var cmd tea.Cmd + m.viewport, cmd = m.viewport.Update(msg) + return m, cmd +} + +func (m verboseModel) view() string { + outputStr := strings.Builder{} + + // format header for viewport + instanceName := titleStyle.Render(*m.focusedInstanceName) + line := strings.Repeat("─", int(math.Max(0, float64(m.viewport.Width-lipgloss.Width(instanceName))))) + outputStr.WriteString(lipgloss.JoinHorizontal(lipgloss.Center, instanceName, line)) + outputStr.WriteString("\n") + + outputStr.WriteString(m.viewport.View()) + outputStr.WriteString("\n") + + // format footer for viewport + pagePercentage := infoStyle.Render(fmt.Sprintf("%3.f%%", m.viewport.ScrollPercent()*100)) + line = strings.Repeat("─", int(math.Max(0, float64(m.viewport.Width-lipgloss.Width(pagePercentage))))) + outputStr.WriteString(lipgloss.JoinHorizontal(lipgloss.Center, line, pagePercentage)) + outputStr.WriteString("\n") + + // controls + outputStr.WriteString(lipgloss.NewStyle().Faint(true).Render(verboseControls)) + outputStr.WriteString("\n") + + return outputStr.String() +}