From 02564289e44b0a34a9aa365bcdfb0a214b8e382a Mon Sep 17 00:00:00 2001 From: "Rodrigo O.C. Pereira" <68402662+digocorbellini@users.noreply.github.com> Date: Tue, 26 Jul 2022 15:40:52 -0500 Subject: [PATCH 1/9] CLI Instance Sorting (#146) * created sorting system based on json pathing * incorporated sorting system into CLI * added shorthand sorting fields * Revert "Handle pricing api change in aws-sdk-go 1.44.46 (#145) * Revert "Handle pricing api change in aws-sdk-go 1.44.46 (#140)" This reverts commit cec208c8d60d8348fbba31f791068b65269d459a. * Update aws-sdk-go to v1.44.59 * added sorting example and new help printout to readme * added jsonpath library license to third party licenses * changed sorting to use sort.Slice() * removed need for $ in json path * shortened sort-by flag description and updated readme * early sort return for empty and 1 element lists * added sorter tests * fixed typos * reduced scope of if statement in main Co-authored-by: Brandon Wagner * simplified call to sort instances * modified tests for new sorting interface * fixed comment typo * refactored sorting keys map Co-authored-by: Rodrigo Okamoto Co-authored-by: Ciprian Hacman Co-authored-by: Brandon Wagner --- README.md | 156 +++++- THIRD_PARTY_LICENSES | 29 +- cmd/main.go | 177 ++++++- go.mod | 1 + go.sum | 2 + pkg/sorter/sorter.go | 336 +++++++++++++ pkg/sorter/sorter_test.go | 323 ++++++++++++ test/static/FilterVerbose/1_instance.json | 89 ++++ test/static/FilterVerbose/3_instances.json | 263 ++++++++++ .../static/FilterVerbose/4_special_cases.json | 460 ++++++++++++++++++ 10 files changed, 1799 insertions(+), 37 deletions(-) create mode 100644 pkg/sorter/sorter.go create mode 100644 pkg/sorter/sorter_test.go create mode 100644 test/static/FilterVerbose/1_instance.json create mode 100644 test/static/FilterVerbose/3_instances.json create mode 100644 test/static/FilterVerbose/4_special_cases.json diff --git a/README.md b/README.md index af62940..aea2309 100644 --- a/README.md +++ b/README.md @@ -146,6 +146,140 @@ t3.medium 2 4 nitro true true t3a.medium 2 4 nitro true true x86_64 Up to 5 Gigabit 3 0 0 $0.0376 $0.01431 ``` +**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 +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 +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 + +**Sort by memory in descending order using JSON path** +``` +$ 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 +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). + +**Example output of instance type object using Verbose output** +``` +$ ec2-instance-selector --max-results 1 -v +[ + { + "AutoRecoverySupported": true, + "BareMetal": false, + "BurstablePerformanceSupported": false, + "CurrentGeneration": false, + "DedicatedHostsSupported": true, + "EbsInfo": { + "EbsOptimizedInfo": { + "BaselineBandwidthInMbps": 1750, + "BaselineIops": 10000, + "BaselineThroughputInMBps": 218.75, + "MaximumBandwidthInMbps": 3500, + "MaximumIops": 20000, + "MaximumThroughputInMBps": 437.5 + }, + "EbsOptimizedSupport": "default", + "EncryptionSupport": "supported", + "NvmeSupport": "required" + }, + "FpgaInfo": null, + "FreeTierEligible": false, + "GpuInfo": null, + "HibernationSupported": false, + "Hypervisor": "nitro", + "InferenceAcceleratorInfo": null, + "InstanceStorageInfo": null, + "InstanceStorageSupported": false, + "InstanceType": "a1.2xlarge", + "MemoryInfo": { + "SizeInMiB": 16384 + }, + "NetworkInfo": { + "DefaultNetworkCardIndex": 0, + "EfaInfo": null, + "EfaSupported": false, + "EnaSupport": "required", + "EncryptionInTransitSupported": false, + "Ipv4AddressesPerInterface": 15, + "Ipv6AddressesPerInterface": 15, + "Ipv6Supported": true, + "MaximumNetworkCards": 1, + "MaximumNetworkInterfaces": 4, + "NetworkCards": [ + { + "MaximumNetworkInterfaces": 4, + "NetworkCardIndex": 0, + "NetworkPerformance": "Up to 10 Gigabit" + } + ], + "NetworkPerformance": "Up to 10 Gigabit" + }, + "PlacementGroupInfo": { + "SupportedStrategies": [ + "cluster", + "partition", + "spread" + ] + }, + "ProcessorInfo": { + "SupportedArchitectures": [ + "arm64" + ], + "SustainedClockSpeedInGhz": 2.3 + }, + "SupportedBootModes": [ + "uefi" + ], + "SupportedRootDeviceTypes": [ + "ebs" + ], + "SupportedUsageClasses": [ + "on-demand", + "spot" + ], + "SupportedVirtualizationTypes": [ + "hvm" + ], + "VCpuInfo": { + "DefaultCores": 8, + "DefaultThreadsPerCore": 1, + "DefaultVCpus": 8, + "ValidCores": null, + "ValidThreadsPerCore": null + }, + "OndemandPricePerHour": 0.204, + "SpotPrice": 0.03939999999999999 + } +] +NOTE: 497 entries were truncated, increase --max-results to see more +``` +NOTE: Use this JSON format as reference when finding JSON paths for sorting + **All CLI Options** ``` @@ -153,7 +287,7 @@ $ ec2-instance-selector --help ``` ```bash#help -ec2-instance-selector is a CLI tool to filter EC2 instance types based on resource criteria. +ec2-instance-selector is a CLI tool to filter EC2 instance types based on resource criteria. Filtering allows you to select all the instance types that match your application requirements. Full docs can be found at github.com/aws/amazon-ec2-instance-selector @@ -241,15 +375,17 @@ Suite Flags: Global Flags: - --cache-dir string Directory to save the pricing and instance type caches (default "~/.ec2-instance-selector/") - --cache-ttl int Cache TTLs in hours for pricing and instance type caches. Setting the cache to 0 will turn off caching and cleanup any on-disk caches. (default 168) - -h, --help Help - --max-results int The maximum number of instance types that match your criteria to return (default 20) - -o, --output string Specify the output format (table, table-wide, one-line) - --profile string AWS CLI profile to use for credentials and config - -r, --region string AWS Region to use for API requests (NOTE: if not passed in, uses AWS SDK default precedence) - -v, --verbose Verbose - will print out full instance specs - --version Prints CLI version + --cache-dir string Directory to save the pricing and instance type caches (default "~/.ec2-instance-selector/") + --cache-ttl int Cache TTLs in hours for pricing and instance type caches. Setting the cache to 0 will turn off caching and cleanup any on-disk caches. (default 168) + -h, --help Help + --max-results int The maximum number of instance types that match your criteria to return (default 20) + -o, --output string Specify the output format (table, table-wide, one-line) + --profile string AWS CLI profile to use for credentials and config + -r, --region string AWS Region to use for API requests (NOTE: if not passed in, uses AWS SDK default precedence) + --sort-by string 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. (default ".InstanceType") + --sort-direction string Specify the direction to sort in (ascending, asc, descending, desc) (default "ascending") + -v, --verbose Verbose - will print out full instance specs + --version Prints CLI version ``` diff --git a/THIRD_PARTY_LICENSES b/THIRD_PARTY_LICENSES index 8fcb97b..2e69ed9 100644 --- a/THIRD_PARTY_LICENSES +++ b/THIRD_PARTY_LICENSES @@ -853,4 +853,31 @@ 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. \ No newline at end of file +THE SOFTWARE. + +------ + +** github.com/oliveagle/jsonpath; version v0.0.0-20180606110733-2e52cf6e6852 -- +https://github.com/oliveagle/jsonpath + +The MIT License (MIT) + +Copyright (c) 2015 oliver + +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 +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. \ No newline at end of file diff --git a/cmd/main.go b/cmd/main.go index f57e56d..e304a7f 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -25,8 +25,10 @@ import ( commandline "github.com/aws/amazon-ec2-instance-selector/v2/pkg/cli" "github.com/aws/amazon-ec2-instance-selector/v2/pkg/env" + "github.com/aws/amazon-ec2-instance-selector/v2/pkg/instancetypes" "github.com/aws/amazon-ec2-instance-selector/v2/pkg/selector" "github.com/aws/amazon-ec2-instance-selector/v2/pkg/selector/outputs" + "github.com/aws/amazon-ec2-instance-selector/v2/pkg/sorter" "github.com/aws/aws-sdk-go/aws/session" homedir "github.com/mitchellh/go-homedir" "github.com/spf13/cobra" @@ -103,15 +105,44 @@ const ( // Configuration Flag Constants const ( - maxResults = "max-results" - profile = "profile" - help = "help" - verbose = "verbose" - version = "version" - region = "region" - output = "output" - cacheTTL = "cache-ttl" - cacheDir = "cache-dir" + maxResults = "max-results" + profile = "profile" + help = "help" + verbose = "verbose" + version = "version" + region = "region" + output = "output" + cacheTTL = "cache-ttl" + cacheDir = "cache-dir" + sortDirection = "sort-direction" + 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 ( @@ -142,6 +173,29 @@ 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, + } + // Registers flags with specific input types from the cli pkg // Filter Flags - These will be grouped at the top of the help flags @@ -206,6 +260,8 @@ 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.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 flags, err := cli.ParseAndValidateFlags() @@ -237,6 +293,9 @@ Full docs can be found at github.com/aws/amazon-` + binName } } registerShutdown(shutdown) + + sortField := cli.StringMe(flags[sortBy]) + lowercaseSortField := strings.ToLower(*sortField) outputFlag := cli.StringMe(flags[output]) if outputFlag != nil && *outputFlag == tableWideOutput { // If output type is `table-wide`, simply print both prices for better comparison, @@ -245,18 +304,37 @@ Full docs can be found at github.com/aws/amazon-` + binName if err := hydrateCaches(*instanceSelector); err != nil { log.Printf("%v", err) } - } else if flags[pricePerHour] != nil { + } else { // Else, if price filters are applied, only hydrate the respective cache as we don't have to print the prices - if flags[usageClass] == nil || *cli.StringMe(flags[usageClass]) == "on-demand" { - if instanceSelector.EC2Pricing.OnDemandCacheCount() == 0 { - if err := instanceSelector.EC2Pricing.RefreshOnDemandCache(); err != nil { - log.Printf("There was a problem refreshing the on-demand pricing cache: %v", err) + if flags[pricePerHour] != nil { + if flags[usageClass] == nil || *cli.StringMe(flags[usageClass]) == "on-demand" { + if instanceSelector.EC2Pricing.OnDemandCacheCount() == 0 { + if err := instanceSelector.EC2Pricing.RefreshOnDemandCache(); err != nil { + log.Printf("There was a problem refreshing the on-demand pricing cache: %v", err) + } + } + } else { + if instanceSelector.EC2Pricing.SpotCacheCount() == 0 { + if err := instanceSelector.EC2Pricing.RefreshSpotCache(spotPricingDaysBack); err != nil { + log.Printf("There was a problem refreshing the spot pricing cache: %v", err) + } } } - } else { - if instanceSelector.EC2Pricing.SpotCacheCount() == 0 { - if err := instanceSelector.EC2Pricing.RefreshSpotCache(spotPricingDaysBack); err != nil { - log.Printf("There was a problem refreshing the spot pricing cache: %v", err) + } + + // refresh appropriate caches if sorting by either spot or on demand pricing + if strings.Contains(lowercaseSortField, "price") { + if strings.Contains(lowercaseSortField, "spot") { + if instanceSelector.EC2Pricing.SpotCacheCount() == 0 { + if err := instanceSelector.EC2Pricing.RefreshSpotCache(spotPricingDaysBack); err != nil { + log.Printf("There was a problem refreshing the spot pricing cache: %v", err) + } + } + } else { + if instanceSelector.EC2Pricing.OnDemandCacheCount() == 0 { + if err := instanceSelector.EC2Pricing.RefreshOnDemandCache(); err != nil { + log.Printf("There was a problem refreshing the on-demand pricing cache: %v", err) + } } } } @@ -338,16 +416,52 @@ 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 + } + outputFn := getOutputFn(outputFlag, selector.InstanceTypesOutputFn(resultsOutputFn)) + var instanceTypes []string + var itemsTruncated int - instanceTypes, itemsTruncated, err := instanceSelector.FilterWithOutput(filters, outputFn) - if err != nil { - fmt.Printf("An error occurred when filtering instance types: %v", err) - os.Exit(1) - } - if len(instanceTypes) == 0 { - log.Println("The criteria was too narrow and returned no valid instance types. Consider broadening your criteria so that more instance types are returned.") - os.Exit(1) + sortDirection := cli.StringMe(flags[sortDirection]) + if *sortField == instanceNamePath && (*sortDirection == sortAscending || *sortDirection == sortAsc) { + // filter already sorts in ascending order by name + instanceTypes, itemsTruncated, err = instanceSelector.FilterWithOutput(filters, outputFn) + if err != nil { + fmt.Printf("An error occurred when filtering instance types: %v", err) + os.Exit(1) + } + if len(instanceTypes) == 0 { + log.Println("The criteria was too narrow and returned no valid instance types. Consider broadening your criteria so that more instance types are returned.") + os.Exit(1) + } + } else { + // fetch instance types without truncating results + prevMaxResults := filters.MaxResults + filters.MaxResults = nil + instanceTypeDetails, err := instanceSelector.FilterVerbose(filters) + if err != nil { + fmt.Printf("An error occurred when filtering instance types: %v", err) + os.Exit(1) + } + + instanceTypeDetails, err = sorter.Sort(instanceTypeDetails, *sortField, *sortDirection) + if err != nil { + fmt.Printf("Sorting error: %v", err) + os.Exit(1) + } + + // truncate instance types based on user passed in maxResults + instanceTypeDetails, itemsTruncated = truncateResults(prevMaxResults, instanceTypeDetails) + if len(instanceTypeDetails) == 0 { + log.Println("The criteria was too narrow and returned no valid instance types. Consider broadening your criteria so that more instance types are returned.") + os.Exit(1) + } + + // format instance types for output + instanceTypes = outputFn(instanceTypeDetails) } for _, instanceType := range instanceTypes { @@ -487,3 +601,14 @@ func registerShutdown(shutdown func()) { shutdown() }() } + +func truncateResults(maxResults *int, instanceTypeInfoSlice []*instancetypes.Details) ([]*instancetypes.Details, int) { + if maxResults == nil { + return instanceTypeInfoSlice, 0 + } + upperIndex := *maxResults + if *maxResults > len(instanceTypeInfoSlice) { + upperIndex = len(instanceTypeInfoSlice) + } + return instanceTypeInfoSlice[0:upperIndex], len(instanceTypeInfoSlice) - upperIndex +} diff --git a/go.mod b/go.mod index 9175af9..36ec5bd 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( require ( github.com/inconshreveable/mousetrap v1.0.0 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect + github.com/oliveagle/jsonpath v0.0.0-20180606110733-2e52cf6e6852 // indirect github.com/smartystreets/goconvey v1.6.4 // indirect go.uber.org/atomic v1.4.0 // indirect ) diff --git a/go.sum b/go.sum index c9d5e83..3e0d1bb 100644 --- a/go.sum +++ b/go.sum @@ -71,6 +71,8 @@ github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrk github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/oliveagle/jsonpath v0.0.0-20180606110733-2e52cf6e6852 h1:Yl0tPBa8QPjGmesFh1D0rDy+q1Twx6FyU7VWHi8wZbI= +github.com/oliveagle/jsonpath v0.0.0-20180606110733-2e52cf6e6852/go.mod h1:eqOVx5Vwu4gd2mmMZvVZsgIqNSaW3xxRThUJ0k/TPk4= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= diff --git a/pkg/sorter/sorter.go b/pkg/sorter/sorter.go new file mode 100644 index 0000000..62e067e --- /dev/null +++ b/pkg/sorter/sorter.go @@ -0,0 +1,336 @@ +// 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 sorter + +import ( + "encoding/json" + "fmt" + "reflect" + "sort" + "strings" + + "github.com/aws/amazon-ec2-instance-selector/v2/pkg/instancetypes" + "github.com/aws/aws-sdk-go/aws" + "github.com/oliveagle/jsonpath" +) + +const ( + // Sort direction + + 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" +) + +// sorterNode represents a sortable instance type which holds the value +// to sort by instance sort +type sorterNode struct { + instanceType *instancetypes.Details + fieldValue reflect.Value +} + +// sorter is used to sort instance types based on a sorting field +// and direction +type sorter struct { + sorters []*sorterNode + sortField string + isDescending bool +} + +// 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"). +// +// 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) { + sorter, err := newSorter(instanceTypes, sortField, sortDirection) + if err != nil { + return nil, fmt.Errorf("an error occurred when preparing to sort instance types: %v", err) + } + + if err := sorter.sort(); err != nil { + return nil, fmt.Errorf("an error occurred when sorting instance types: %v", err) + } + + return sorter.instanceTypes(), nil +} + +// newSorter creates a new Sorter object to be used to sort the given instance types +// based on the sorting field and 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"). +// +// sortDirection represents the direction to sort in. Valid options: "ascending", "asc", "descending", "desc". +func newSorter(instanceTypes []*instancetypes.Details, sortField string, sortDirection string) (*sorter, error) { + var isDescending bool + switch sortDirection { + case sortDescending, sortDesc: + isDescending = true + 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) + } + + sortField = formatSortField(sortField) + + // Create sorterNode objects for each instance type + sorters := []*sorterNode{} + for _, instanceType := range instanceTypes { + newSorter, err := newSorterNode(instanceType, sortField) + if err != nil { + return nil, fmt.Errorf("error creating sorting node: %v", err) + } + + sorters = append(sorters, newSorter) + } + + return &sorter{ + sorters: sorters, + sortField: sortField, + isDescending: isDescending, + }, nil +} + +// formatSortField reformats sortField to match the expected json path format +// of the json lookup library. Format is unchanged if the sorting field +// 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 { + return sortField + } + + return "$" + sortField +} + +// newSorterNode creates a new sorterNode object which represents the given instance type +// and can be used in sorting of instance types based on the given sortField +func newSorterNode(instanceType *instancetypes.Details, sortField string) (*sorterNode, error) { + // 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: + gpuCount := getTotalGpusCount(instanceType) + return &sorterNode{ + instanceType: instanceType, + fieldValue: reflect.ValueOf(gpuCount), + }, nil + case inferenceAcceleratorsField: + acceleratorsCount := getTotalAcceleratorsCount(instanceType) + return &sorterNode{ + instanceType: instanceType, + fieldValue: reflect.ValueOf(acceleratorsCount), + }, nil + } + + // convert instance type into json + jsonInstanceType, err := json.Marshal(instanceType) + if err != nil { + return nil, err + } + + // unmarshal json instance types in order to get proper format + // for json path parsing + var jsonData interface{} + err = json.Unmarshal(jsonInstanceType, &jsonData) + if err != nil { + return nil, err + } + + // get the desired field from the json data based on the passed in + // json path + result, err := jsonpath.JsonPathLookup(jsonData, sortField) + if err != nil { + // handle case where parent objects in path are null + // by setting result to nil + if err.Error() == "get attribute from null object" { + result = nil + } else { + return nil, fmt.Errorf("error during json path lookup: %v", err) + } + } + + return &sorterNode{ + instanceType: instanceType, + fieldValue: reflect.ValueOf(result), + }, nil +} + +// sort the instance types in the Sorter based on the Sorter's sort field and +// direction +func (s *sorter) sort() error { + if len(s.sorters) <= 1 { + return nil + } + + var sortErr error = nil + + sort.Slice(s.sorters, func(i int, j int) bool { + valI := s.sorters[i].fieldValue + valJ := s.sorters[j].fieldValue + + less, err := isLess(valI, valJ, s.isDescending) + if err != nil { + sortErr = err + } + + return less + }) + + return sortErr +} + +// isLess determines whether the first value (valI) is less than the +// second value (valJ) or not +func isLess(valI, valJ reflect.Value, isDescending bool) (bool, error) { + switch valI.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + // if valJ is not an int (can occur if the other value is nil) + // then valI is less. This will bubble invalid values to the end + vaJKind := valJ.Kind() + if vaJKind != reflect.Int && vaJKind != reflect.Int8 && vaJKind != reflect.Int16 && vaJKind != reflect.Int32 && vaJKind != reflect.Int64 { + return true, nil + } + + if isDescending { + return valI.Int() > valJ.Int(), nil + } else { + return valI.Int() <= valJ.Int(), nil + } + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + // if valJ is not a uint (can occur if the other value is nil) + // then valI is less. This will bubble invalid values to the end + vaJKind := valJ.Kind() + if vaJKind != reflect.Uint && vaJKind != reflect.Uint8 && vaJKind != reflect.Uint16 && vaJKind != reflect.Uint32 && vaJKind != reflect.Uint64 { + return true, nil + } + + if isDescending { + return valI.Uint() > valJ.Uint(), nil + } else { + return valI.Uint() <= valJ.Uint(), nil + } + case reflect.Float32, reflect.Float64: + // if valJ is not a float (can occur if the other value is nil) + // then valI is less. This will bubble invalid values to the end + vaJKind := valJ.Kind() + if vaJKind != reflect.Float32 && vaJKind != reflect.Float64 { + return true, nil + } + + if isDescending { + return valI.Float() > valJ.Float(), nil + } else { + return valI.Float() <= valJ.Float(), nil + } + case reflect.String: + // if valJ is not a string (can occur if the other value is nil) + // then valI is less. This will bubble invalid values to the end + if valJ.Kind() != reflect.String { + return true, nil + } + + if isDescending { + return strings.Compare(valI.String(), valJ.String()) > 0, nil + } else { + return strings.Compare(valI.String(), valJ.String()) <= 0, nil + } + case reflect.Pointer: + // Handle nil values by making non nil values always less than the nil values. That way the + // nil values can be bubbled up to the end of the list. + if valI.IsNil() { + return false, nil + } else if valJ.Kind() != reflect.Pointer || valJ.IsNil() { + return true, nil + } + + return isLess(valI.Elem(), valJ.Elem(), isDescending) + case reflect.Bool: + // if valJ is not a bool (can occur if the other value is nil) + // then valI is less. This will bubble invalid values to the end + if valJ.Kind() != reflect.Bool { + return true, nil + } + + if isDescending { + return !valI.Bool(), nil + } else { + return valI.Bool(), nil + } + case reflect.Invalid: + // handle invalid values (like nil values) by making valid values + // always less than the invalid values. That way the invalid values + // always bubble up to the end of the list + return false, nil + default: + // unsortable value + return false, fmt.Errorf("unsortable value") + } +} + +// instanceTypes returns the list of instance types held in the Sorter +func (s *sorter) instanceTypes() []*instancetypes.Details { + instanceTypes := []*instancetypes.Details{} + + for _, node := range s.sorters { + instanceTypes = append(instanceTypes, node.instanceType) + } + + return instanceTypes +} + +// helper functions for special sorting fields + +// getTotalGpusCount calculates the number of gpus in the given instance type +func getTotalGpusCount(instanceType *instancetypes.Details) *int64 { + gpusInfo := instanceType.GpuInfo + + if gpusInfo == nil { + return nil + } + + total := aws.Int64(0) + for _, gpu := range gpusInfo.Gpus { + total = aws.Int64(*total + *gpu.Count) + } + + return total +} + +// getTotalAcceleratorsCount calculates the total number of inference accelerators +// in the given instance type +func getTotalAcceleratorsCount(instanceType *instancetypes.Details) *int64 { + acceleratorInfo := instanceType.InferenceAcceleratorInfo + + if acceleratorInfo == nil { + return nil + } + + total := aws.Int64(0) + for _, accel := range acceleratorInfo.Accelerators { + total = aws.Int64(*total + *accel.Count) + } + + return total +} diff --git a/pkg/sorter/sorter_test.go b/pkg/sorter/sorter_test.go new file mode 100644 index 0000000..776cf85 --- /dev/null +++ b/pkg/sorter/sorter_test.go @@ -0,0 +1,323 @@ +// 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 sorter_test + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "strings" + "testing" + + "github.com/aws/amazon-ec2-instance-selector/v2/pkg/instancetypes" + "github.com/aws/amazon-ec2-instance-selector/v2/pkg/selector/outputs" + "github.com/aws/amazon-ec2-instance-selector/v2/pkg/sorter" + h "github.com/aws/amazon-ec2-instance-selector/v2/pkg/test" +) + +const ( + mockFilesPath = "../../test/static" + describeInstanceTypesPages = "DescribeInstanceTypesPages" +) + +// Helpers + +// getInstanceTypeDetails unmarshalls the json file in the given testing folder +// and returns a list of instance type details +func getInstanceTypeDetails(t *testing.T, file string) []*instancetypes.Details { + folder := "FilterVerbose" + mockFilename := fmt.Sprintf("%s/%s/%s", mockFilesPath, folder, file) + mockFile, err := ioutil.ReadFile(mockFilename) + h.Assert(t, err == nil, "Error reading mock file "+string(mockFilename)) + + instanceTypes := []*instancetypes.Details{} + err = json.Unmarshal(mockFile, &instanceTypes) + h.Assert(t, err == nil, fmt.Sprintf("Error parsing mock json file contents %s. Error: %v", mockFilename, err)) + return instanceTypes +} + +// checkSortResults is a helper function for comparing the results of sorting tests. Returns true if +// the order of instance types in the instanceTypes list matches the the order of instance type names +// in the expectedResult list, and returns false otherwise. +func checkSortResults(instanceTypes []*instancetypes.Details, expectedResult []string) bool { + if len(instanceTypes) != len(expectedResult) { + return false + } + + for i := 0; i < len(instanceTypes); i++ { + actualName := instanceTypes[i].InstanceTypeInfo.InstanceType + expectedName := expectedResult[i] + + if actualName == nil || *actualName != expectedName { + return false + } + } + + return true +} + +// Tests + +func TestSort_JSONPath(t *testing.T) { + instanceTypes := getInstanceTypeDetails(t, "3_instances.json") + + sortField := ".EbsInfo.EbsOptimizedInfo.BaselineBandwidthInMbps" + sortDirection := "asc" + + sortedInstances, err := sorter.Sort(instanceTypes, sortField, sortDirection) + expectedResults := []string{ + "a1.large", + "a1.2xlarge", + "a1.4xlarge", + } + + h.Ok(t, err) + h.Assert(t, checkSortResults(sortedInstances, expectedResults), fmt.Sprintf("Expected ascending order: [%s], but actual order: %s", strings.Join(expectedResults, ","), outputs.OneLineOutput(sortedInstances))) +} + +func TestSort_SpecialCases(t *testing.T) { + instanceTypes := getInstanceTypeDetails(t, "4_special_cases.json") + + // test gpus flag + sortField := "gpus" + sortDirection := "asc" + + sortedInstances, err := sorter.Sort(instanceTypes, sortField, sortDirection) + expectedResults := []string{ + "g3.4xlarge", + "g3.16xlarge", + "inf1.24xlarge", + "inf1.2xlarge", + } + + h.Ok(t, err) + h.Assert(t, checkSortResults(sortedInstances, expectedResults), fmt.Sprintf("Expected gpus order: [%s], but actual order: %s", strings.Join(expectedResults, ","), outputs.OneLineOutput(sortedInstances))) + + // test inference accelerators flag + sortField = "inference-accelerators" + + sortedInstances, err = sorter.Sort(instanceTypes, sortField, sortDirection) + + expectedResults = []string{ + "inf1.2xlarge", + "inf1.24xlarge", + "g3.16xlarge", + "g3.4xlarge", + } + + h.Ok(t, err) + h.Assert(t, checkSortResults(sortedInstances, expectedResults), fmt.Sprintf("Expected inference accelerators order: [%s], but actual order: %s", strings.Join(expectedResults, ","), outputs.OneLineOutput(sortedInstances))) +} + +func TestSort_OneElement(t *testing.T) { + instanceTypes := getInstanceTypeDetails(t, "1_instance.json") + + sortField := ".MemoryInfo.SizeInMiB" + sortDirection := "asc" + + sortedInstances, err := sorter.Sort(instanceTypes, sortField, sortDirection) + expectedResults := []string{"a1.2xlarge"} + + h.Ok(t, err) + h.Assert(t, len(sortedInstances) == 1, fmt.Sprintf("Should only have 1 instance, but have: %d", len(sortedInstances))) + h.Assert(t, checkSortResults(sortedInstances, expectedResults), fmt.Sprintf("Expected order: [%s], but actual order: %s", strings.Join(expectedResults, ","), outputs.OneLineOutput(sortedInstances))) +} + +func TestSort_EmptyList(t *testing.T) { + instanceTypes := []*instancetypes.Details{} + + sortField := ".MemoryInfo.SizeInMiB" + sortDirection := "asc" + + sortedInstances, err := sorter.Sort(instanceTypes, sortField, sortDirection) + + h.Ok(t, err) + h.Assert(t, len(sortedInstances) == 0, fmt.Sprintf("Sorted instance types list should be empty but actually has %d elements", len(sortedInstances))) +} + +func TestSort_InvalidSortField(t *testing.T) { + instanceTypes := getInstanceTypeDetails(t, "3_instances.json") + + sortField := "fdsafdsafdjskalfjlsf #@" + sortDirection := "asc" + + sortedInstances, err := sorter.Sort(instanceTypes, sortField, sortDirection) + + h.Assert(t, err != nil, "An error should be returned") + h.Assert(t, sortedInstances == nil, "Returned sorter should be nil") +} + +func TestSort_InvalidDirection(t *testing.T) { + instanceTypes := getInstanceTypeDetails(t, "3_instances.json") + + sortField := ".MemoryInfo.SizeInMiB" + sortDirection := "fdsa hfd j2 $#21" + + sortedInstances, err := sorter.Sort(instanceTypes, sortField, sortDirection) + + h.Assert(t, err != nil, "An error should be returned") + h.Assert(t, sortedInstances == nil, "Returned sorter should be nil") +} + +func TestSort_Number(t *testing.T) { + // All numbers (ints and floats) are evaluated as floats + // due to the way that json unmarshalling must be done + // in order to match json path library input format + + instanceTypes := getInstanceTypeDetails(t, "3_instances.json") + + // test ascending + sortField := ".MemoryInfo.SizeInMiB" + sortDirection := "asc" + + sortedInstances, err := sorter.Sort(instanceTypes, sortField, sortDirection) + expectedResults := []string{ + "a1.large", + "a1.2xlarge", + "a1.4xlarge", + } + + h.Ok(t, err) + h.Assert(t, checkSortResults(sortedInstances, expectedResults), fmt.Sprintf("Expected ascending order: [%s], but actual order: %s", strings.Join(expectedResults, ","), outputs.OneLineOutput(sortedInstances))) + + // test descending + sortDirection = "desc" + + sortedInstances, err = sorter.Sort(instanceTypes, sortField, sortDirection) + expectedResults = []string{ + "a1.4xlarge", + "a1.2xlarge", + "a1.large", + } + + h.Ok(t, err) + h.Assert(t, checkSortResults(sortedInstances, expectedResults), fmt.Sprintf("Expected descending order: [%s], but actual order: %s", strings.Join(expectedResults, ","), outputs.OneLineOutput(sortedInstances))) +} + +func TestSort_String(t *testing.T) { + instanceTypes := getInstanceTypeDetails(t, "3_instances.json") + + // test ascending + sortField := ".InstanceType" + sortDirection := "asc" + + sortedInstances, err := sorter.Sort(instanceTypes, sortField, sortDirection) + expectedResults := []string{ + "a1.2xlarge", + "a1.4xlarge", + "a1.large", + } + + h.Ok(t, err) + h.Assert(t, checkSortResults(sortedInstances, expectedResults), fmt.Sprintf("Expected ascending order: [%s], but actual order: %s", strings.Join(expectedResults, ","), outputs.OneLineOutput(sortedInstances))) + + // test descending + sortDirection = "desc" + + sortedInstances, err = sorter.Sort(instanceTypes, sortField, sortDirection) + expectedResults = []string{ + "a1.large", + "a1.4xlarge", + "a1.2xlarge", + } + + h.Ok(t, err) + h.Assert(t, checkSortResults(sortedInstances, expectedResults), fmt.Sprintf("Expected descending order: [%s], but actual order: %s", strings.Join(expectedResults, ","), outputs.OneLineOutput(sortedInstances))) +} + +func TestSort_Invalid(t *testing.T) { + instanceTypes := getInstanceTypeDetails(t, "3_instances.json") + + // test ascending + sortField := ".SpotPrice" + sortDirection := "asc" + + sortedInstances, err := sorter.Sort(instanceTypes, sortField, sortDirection) + expectedResults := []string{ + "a1.large", + "a1.2xlarge", + "a1.4xlarge", + } + + h.Ok(t, err) + h.Assert(t, checkSortResults(sortedInstances, expectedResults), fmt.Sprintf("Expected ascending order: [%s], but actual order: %s", strings.Join(expectedResults, ","), outputs.OneLineOutput(sortedInstances))) + + // test descending + sortDirection = "desc" + + sortedInstances, err = sorter.Sort(instanceTypes, sortField, sortDirection) + expectedResults = []string{ + "a1.2xlarge", + "a1.large", + "a1.4xlarge", + } + + h.Ok(t, err) + h.Assert(t, checkSortResults(sortedInstances, expectedResults), fmt.Sprintf("Expected descending order: [%s], but actual order: %s", strings.Join(expectedResults, ","), outputs.OneLineOutput(sortedInstances))) +} + +func TestSort_Unsortable(t *testing.T) { + instanceTypes := getInstanceTypeDetails(t, "3_instances.json") + + sortField := ".NetworkInfo" + sortDirection := "asc" + + sortedInstances, err := sorter.Sort(instanceTypes, sortField, sortDirection) + + h.Assert(t, err != nil, "An error should be returned") + h.Assert(t, sortedInstances == nil, "returned instances list should be nil") +} + +func TestSort_Pointer(t *testing.T) { + instanceTypes := getInstanceTypeDetails(t, "3_instances.json") + + sortField := ".EbsInfo" + sortDirection := "asc" + + sortedInstances, err := sorter.Sort(instanceTypes, sortField, sortDirection) + + h.Assert(t, err != nil, "An error should be returned") + h.Assert(t, sortedInstances == nil, "returned instances list should be nil") +} + +func TestSort_Bool(t *testing.T) { + instanceTypes := getInstanceTypeDetails(t, "3_instances.json") + + // test ascending + sortField := ".HibernationSupported" + sortDirection := "asc" + + sortedInstances, err := sorter.Sort(instanceTypes, sortField, sortDirection) + expectedResults := []string{ + "a1.4xlarge", + "a1.2xlarge", + "a1.large", + } + + h.Ok(t, err) + h.Assert(t, checkSortResults(sortedInstances, expectedResults), fmt.Sprintf("Expected ascending order: [%s], but actual order: %s", strings.Join(expectedResults, ","), outputs.OneLineOutput(sortedInstances))) + + // test descending + sortDirection = "desc" + + sortedInstances, err = sorter.Sort(instanceTypes, sortField, sortDirection) + expectedResults = []string{ + "a1.large", + "a1.2xlarge", + "a1.4xlarge", + } + + h.Ok(t, err) + h.Assert(t, checkSortResults(sortedInstances, expectedResults), fmt.Sprintf("Expected descending order: [%s], but actual order: %s", strings.Join(expectedResults, ","), outputs.OneLineOutput(sortedInstances))) +} diff --git a/test/static/FilterVerbose/1_instance.json b/test/static/FilterVerbose/1_instance.json new file mode 100644 index 0000000..86257fb --- /dev/null +++ b/test/static/FilterVerbose/1_instance.json @@ -0,0 +1,89 @@ +[ + { + "AutoRecoverySupported": true, + "BareMetal": false, + "BurstablePerformanceSupported": false, + "CurrentGeneration": false, + "DedicatedHostsSupported": true, + "EbsInfo": { + "EbsOptimizedInfo": { + "BaselineBandwidthInMbps": 1750, + "BaselineIops": 10000, + "BaselineThroughputInMBps": 218.75, + "MaximumBandwidthInMbps": 3500, + "MaximumIops": 20000, + "MaximumThroughputInMBps": 437.5 + }, + "EbsOptimizedSupport": "default", + "EncryptionSupport": "supported", + "NvmeSupport": "required" + }, + "FpgaInfo": null, + "FreeTierEligible": false, + "GpuInfo": null, + "HibernationSupported": false, + "Hypervisor": "nitro", + "InferenceAcceleratorInfo": null, + "InstanceStorageInfo": null, + "InstanceStorageSupported": false, + "InstanceType": "a1.2xlarge", + "MemoryInfo": { + "SizeInMiB": 16384 + }, + "NetworkInfo": { + "DefaultNetworkCardIndex": 0, + "EfaInfo": null, + "EfaSupported": false, + "EnaSupport": "required", + "EncryptionInTransitSupported": false, + "Ipv4AddressesPerInterface": 15, + "Ipv6AddressesPerInterface": 15, + "Ipv6Supported": true, + "MaximumNetworkCards": 1, + "MaximumNetworkInterfaces": 4, + "NetworkCards": [ + { + "MaximumNetworkInterfaces": 4, + "NetworkCardIndex": 0, + "NetworkPerformance": "Up to 10 Gigabit" + } + ], + "NetworkPerformance": "Up to 10 Gigabit" + }, + "PlacementGroupInfo": { + "SupportedStrategies": [ + "cluster", + "partition", + "spread" + ] + }, + "ProcessorInfo": { + "SupportedArchitectures": [ + "arm64" + ], + "SustainedClockSpeedInGhz": 2.3 + }, + "SupportedBootModes": [ + "uefi" + ], + "SupportedRootDeviceTypes": [ + "ebs" + ], + "SupportedUsageClasses": [ + "on-demand", + "spot" + ], + "SupportedVirtualizationTypes": [ + "hvm" + ], + "VCpuInfo": { + "DefaultCores": 8, + "DefaultThreadsPerCore": 1, + "DefaultVCpus": 8, + "ValidCores": null, + "ValidThreadsPerCore": null + }, + "OndemandPricePerHour": 0.204, + "SpotPrice": 0.03939999999999999 + } +] diff --git a/test/static/FilterVerbose/3_instances.json b/test/static/FilterVerbose/3_instances.json new file mode 100644 index 0000000..3d1ff15 --- /dev/null +++ b/test/static/FilterVerbose/3_instances.json @@ -0,0 +1,263 @@ +[ + { + "AutoRecoverySupported": true, + "BareMetal": false, + "BurstablePerformanceSupported": false, + "CurrentGeneration": false, + "DedicatedHostsSupported": true, + "EbsInfo": { + "EbsOptimizedInfo": { + "BaselineBandwidthInMbps": 1750, + "BaselineIops": 10000, + "BaselineThroughputInMBps": 218.75, + "MaximumBandwidthInMbps": 3500, + "MaximumIops": 20000, + "MaximumThroughputInMBps": 437.5 + }, + "EbsOptimizedSupport": "default", + "EncryptionSupport": "supported", + "NvmeSupport": "required" + }, + "FpgaInfo": null, + "FreeTierEligible": false, + "GpuInfo": null, + "HibernationSupported": false, + "Hypervisor": "nitro", + "InferenceAcceleratorInfo": null, + "InstanceStorageInfo": null, + "InstanceStorageSupported": false, + "InstanceType": "a1.2xlarge", + "MemoryInfo": { + "SizeInMiB": 16384 + }, + "NetworkInfo": { + "DefaultNetworkCardIndex": 0, + "EfaInfo": null, + "EfaSupported": false, + "EnaSupport": "required", + "EncryptionInTransitSupported": false, + "Ipv4AddressesPerInterface": 15, + "Ipv6AddressesPerInterface": 15, + "Ipv6Supported": true, + "MaximumNetworkCards": 1, + "MaximumNetworkInterfaces": 4, + "NetworkCards": [ + { + "MaximumNetworkInterfaces": 4, + "NetworkCardIndex": 0, + "NetworkPerformance": "Up to 10 Gigabit" + } + ], + "NetworkPerformance": "Up to 10 Gigabit" + }, + "PlacementGroupInfo": { + "SupportedStrategies": [ + "cluster", + "partition", + "spread" + ] + }, + "ProcessorInfo": { + "SupportedArchitectures": [ + "arm64" + ], + "SustainedClockSpeedInGhz": 2.3 + }, + "SupportedBootModes": [ + "uefi" + ], + "SupportedRootDeviceTypes": [ + "ebs" + ], + "SupportedUsageClasses": [ + "on-demand", + "spot" + ], + "SupportedVirtualizationTypes": [ + "hvm" + ], + "VCpuInfo": { + "DefaultCores": 8, + "DefaultThreadsPerCore": 1, + "DefaultVCpus": 8, + "ValidCores": null, + "ValidThreadsPerCore": null + }, + "OndemandPricePerHour": 0.204, + "SpotPrice": 0.03939999999999999 + }, + { + "AutoRecoverySupported": true, + "BareMetal": false, + "BurstablePerformanceSupported": false, + "CurrentGeneration": false, + "DedicatedHostsSupported": true, + "EbsInfo": { + "EbsOptimizedInfo": { + "BaselineBandwidthInMbps": 3500, + "BaselineIops": 20000, + "BaselineThroughputInMBps": 437.5, + "MaximumBandwidthInMbps": 3500, + "MaximumIops": 20000, + "MaximumThroughputInMBps": 437.5 + }, + "EbsOptimizedSupport": "default", + "EncryptionSupport": "supported", + "NvmeSupport": "required" + }, + "FpgaInfo": null, + "FreeTierEligible": false, + "GpuInfo": null, + "HibernationSupported": true, + "Hypervisor": "nitro", + "InferenceAcceleratorInfo": null, + "InstanceStorageInfo": null, + "InstanceStorageSupported": false, + "InstanceType": "a1.4xlarge", + "MemoryInfo": { + "SizeInMiB": 32768 + }, + "NetworkInfo": { + "DefaultNetworkCardIndex": 0, + "EfaInfo": null, + "EfaSupported": false, + "EnaSupport": "required", + "EncryptionInTransitSupported": false, + "Ipv4AddressesPerInterface": 30, + "Ipv6AddressesPerInterface": 30, + "Ipv6Supported": true, + "MaximumNetworkCards": 1, + "MaximumNetworkInterfaces": 8, + "NetworkCards": [ + { + "MaximumNetworkInterfaces": 8, + "NetworkCardIndex": 0, + "NetworkPerformance": "Up to 10 Gigabit" + } + ], + "NetworkPerformance": "Up to 10 Gigabit" + }, + "PlacementGroupInfo": { + "SupportedStrategies": [ + "cluster", + "partition", + "spread" + ] + }, + "ProcessorInfo": { + "SupportedArchitectures": [ + "arm64" + ], + "SustainedClockSpeedInGhz": 2.3 + }, + "SupportedBootModes": [ + "uefi" + ], + "SupportedRootDeviceTypes": [ + "ebs" + ], + "SupportedUsageClasses": [ + "on-demand", + "spot" + ], + "SupportedVirtualizationTypes": [ + "hvm" + ], + "VCpuInfo": { + "DefaultCores": 16, + "DefaultThreadsPerCore": 1, + "DefaultVCpus": 16, + "ValidCores": null, + "ValidThreadsPerCore": null + }, + "OndemandPricePerHour": 0.408, + "SpotPrice": null + }, + { + "AutoRecoverySupported": true, + "BareMetal": false, + "BurstablePerformanceSupported": false, + "CurrentGeneration": false, + "DedicatedHostsSupported": true, + "EbsInfo": { + "EbsOptimizedInfo": { + "BaselineBandwidthInMbps": 525, + "BaselineIops": 4000, + "BaselineThroughputInMBps": 65.625, + "MaximumBandwidthInMbps": 3500, + "MaximumIops": 20000, + "MaximumThroughputInMBps": 437.5 + }, + "EbsOptimizedSupport": "default", + "EncryptionSupport": "supported", + "NvmeSupport": "required" + }, + "FpgaInfo": null, + "FreeTierEligible": false, + "GpuInfo": null, + "HibernationSupported": false, + "Hypervisor": "nitro", + "InferenceAcceleratorInfo": null, + "InstanceStorageInfo": null, + "InstanceStorageSupported": false, + "InstanceType": "a1.large", + "MemoryInfo": { + "SizeInMiB": 4096 + }, + "NetworkInfo": { + "DefaultNetworkCardIndex": 0, + "EfaInfo": null, + "EfaSupported": false, + "EnaSupport": "required", + "EncryptionInTransitSupported": false, + "Ipv4AddressesPerInterface": 10, + "Ipv6AddressesPerInterface": 10, + "Ipv6Supported": true, + "MaximumNetworkCards": 1, + "MaximumNetworkInterfaces": 3, + "NetworkCards": [ + { + "MaximumNetworkInterfaces": 3, + "NetworkCardIndex": 0, + "NetworkPerformance": "Up to 10 Gigabit" + } + ], + "NetworkPerformance": "Up to 10 Gigabit" + }, + "PlacementGroupInfo": { + "SupportedStrategies": [ + "cluster", + "partition", + "spread" + ] + }, + "ProcessorInfo": { + "SupportedArchitectures": [ + "arm64" + ], + "SustainedClockSpeedInGhz": 2.3 + }, + "SupportedBootModes": [ + "uefi" + ], + "SupportedRootDeviceTypes": [ + "ebs" + ], + "SupportedUsageClasses": [ + "on-demand", + "spot" + ], + "SupportedVirtualizationTypes": [ + "hvm" + ], + "VCpuInfo": { + "DefaultCores": 2, + "DefaultThreadsPerCore": 1, + "DefaultVCpus": 2, + "ValidCores": null, + "ValidThreadsPerCore": null + }, + "OndemandPricePerHour": 0.051, + "SpotPrice": 0.009819123023512438 + } +] diff --git a/test/static/FilterVerbose/4_special_cases.json b/test/static/FilterVerbose/4_special_cases.json new file mode 100644 index 0000000..6e29f26 --- /dev/null +++ b/test/static/FilterVerbose/4_special_cases.json @@ -0,0 +1,460 @@ +[ + { + "AutoRecoverySupported": true, + "BareMetal": false, + "BurstablePerformanceSupported": false, + "CurrentGeneration": true, + "DedicatedHostsSupported": true, + "EbsInfo": { + "EbsOptimizedInfo": { + "BaselineBandwidthInMbps": 19000, + "BaselineIops": 80000, + "BaselineThroughputInMBps": 2375, + "MaximumBandwidthInMbps": 19000, + "MaximumIops": 80000, + "MaximumThroughputInMBps": 2375 + }, + "EbsOptimizedSupport": "default", + "EncryptionSupport": "supported", + "NvmeSupport": "required" + }, + "FpgaInfo": null, + "FreeTierEligible": false, + "GpuInfo": null, + "HibernationSupported": false, + "Hypervisor": "nitro", + "InferenceAcceleratorInfo": { + "Accelerators": [ + { + "Count": 16, + "Manufacturer": "AWS", + "Name": "Inferentia" + } + ] + }, + "InstanceStorageInfo": null, + "InstanceStorageSupported": false, + "InstanceType": "inf1.24xlarge", + "MemoryInfo": { + "SizeInMiB": 196608 + }, + "NetworkInfo": { + "DefaultNetworkCardIndex": 0, + "EfaInfo": { + "MaximumEfaInterfaces": 1 + }, + "EfaSupported": true, + "EnaSupport": "required", + "EncryptionInTransitSupported": true, + "Ipv4AddressesPerInterface": 30, + "Ipv6AddressesPerInterface": 30, + "Ipv6Supported": true, + "MaximumNetworkCards": 1, + "MaximumNetworkInterfaces": 11, + "NetworkCards": [ + { + "MaximumNetworkInterfaces": 11, + "NetworkCardIndex": 0, + "NetworkPerformance": "100 Gigabit" + } + ], + "NetworkPerformance": "100 Gigabit" + }, + "PlacementGroupInfo": { + "SupportedStrategies": [ + "cluster", + "partition", + "spread" + ] + }, + "ProcessorInfo": { + "SupportedArchitectures": [ + "x86_64" + ], + "SustainedClockSpeedInGhz": 2.5 + }, + "SupportedBootModes": [ + "legacy-bios", + "uefi" + ], + "SupportedRootDeviceTypes": [ + "ebs" + ], + "SupportedUsageClasses": [ + "on-demand", + "spot" + ], + "SupportedVirtualizationTypes": [ + "hvm" + ], + "VCpuInfo": { + "DefaultCores": 48, + "DefaultThreadsPerCore": 2, + "DefaultVCpus": 96, + "ValidCores": [ + 2, + 4, + 6, + 8, + 10, + 12, + 14, + 16, + 18, + 20, + 22, + 24, + 26, + 28, + 30, + 32, + 34, + 36, + 38, + 40, + 42, + 44, + 46, + 48 + ], + "ValidThreadsPerCore": [ + 1, + 2 + ] + }, + "OndemandPricePerHour": 4.721, + "SpotPrice": 1.4163 + }, + { + "AutoRecoverySupported": true, + "BareMetal": false, + "BurstablePerformanceSupported": false, + "CurrentGeneration": true, + "DedicatedHostsSupported": false, + "EbsInfo": { + "EbsOptimizedInfo": { + "BaselineBandwidthInMbps": 1190, + "BaselineIops": 6000, + "BaselineThroughputInMBps": 148.75, + "MaximumBandwidthInMbps": 4750, + "MaximumIops": 20000, + "MaximumThroughputInMBps": 593.75 + }, + "EbsOptimizedSupport": "default", + "EncryptionSupport": "supported", + "NvmeSupport": "required" + }, + "FpgaInfo": null, + "FreeTierEligible": false, + "GpuInfo": null, + "HibernationSupported": false, + "Hypervisor": "nitro", + "InferenceAcceleratorInfo": { + "Accelerators": [ + { + "Count": 1, + "Manufacturer": "AWS", + "Name": "Inferentia" + } + ] + }, + "InstanceStorageInfo": null, + "InstanceStorageSupported": false, + "InstanceType": "inf1.2xlarge", + "MemoryInfo": { + "SizeInMiB": 16384 + }, + "NetworkInfo": { + "DefaultNetworkCardIndex": 0, + "EfaInfo": null, + "EfaSupported": false, + "EnaSupport": "required", + "EncryptionInTransitSupported": true, + "Ipv4AddressesPerInterface": 10, + "Ipv6AddressesPerInterface": 10, + "Ipv6Supported": true, + "MaximumNetworkCards": 1, + "MaximumNetworkInterfaces": 4, + "NetworkCards": [ + { + "MaximumNetworkInterfaces": 4, + "NetworkCardIndex": 0, + "NetworkPerformance": "Up to 25 Gigabit" + } + ], + "NetworkPerformance": "Up to 25 Gigabit" + }, + "PlacementGroupInfo": { + "SupportedStrategies": [ + "cluster", + "partition", + "spread" + ] + }, + "ProcessorInfo": { + "SupportedArchitectures": [ + "x86_64" + ], + "SustainedClockSpeedInGhz": 2.5 + }, + "SupportedBootModes": [ + "legacy-bios", + "uefi" + ], + "SupportedRootDeviceTypes": [ + "ebs" + ], + "SupportedUsageClasses": [ + "on-demand", + "spot" + ], + "SupportedVirtualizationTypes": [ + "hvm" + ], + "VCpuInfo": { + "DefaultCores": 4, + "DefaultThreadsPerCore": 2, + "DefaultVCpus": 8, + "ValidCores": [ + 2, + 4 + ], + "ValidThreadsPerCore": [ + 1, + 2 + ] + }, + "OndemandPricePerHour": 0.362, + "SpotPrice": 0.10859999999999999 + }, + { + "AutoRecoverySupported": true, + "BareMetal": false, + "BurstablePerformanceSupported": false, + "CurrentGeneration": true, + "DedicatedHostsSupported": true, + "EbsInfo": { + "EbsOptimizedInfo": { + "BaselineBandwidthInMbps": 14000, + "BaselineIops": 80000, + "BaselineThroughputInMBps": 1750, + "MaximumBandwidthInMbps": 14000, + "MaximumIops": 80000, + "MaximumThroughputInMBps": 1750 + }, + "EbsOptimizedSupport": "default", + "EncryptionSupport": "supported", + "NvmeSupport": "unsupported" + }, + "FpgaInfo": null, + "FreeTierEligible": false, + "GpuInfo": { + "Gpus": [ + { + "Count": 4, + "Manufacturer": "NVIDIA", + "MemoryInfo": { + "SizeInMiB": 8192 + }, + "Name": "M60" + } + ], + "TotalGpuMemoryInMiB": 32768 + }, + "HibernationSupported": false, + "Hypervisor": "xen", + "InferenceAcceleratorInfo": null, + "InstanceStorageInfo": null, + "InstanceStorageSupported": false, + "InstanceType": "g3.16xlarge", + "MemoryInfo": { + "SizeInMiB": 499712 + }, + "NetworkInfo": { + "DefaultNetworkCardIndex": 0, + "EfaInfo": null, + "EfaSupported": false, + "EnaSupport": "supported", + "EncryptionInTransitSupported": false, + "Ipv4AddressesPerInterface": 50, + "Ipv6AddressesPerInterface": 50, + "Ipv6Supported": true, + "MaximumNetworkCards": 1, + "MaximumNetworkInterfaces": 15, + "NetworkCards": [ + { + "MaximumNetworkInterfaces": 15, + "NetworkCardIndex": 0, + "NetworkPerformance": "25 Gigabit" + } + ], + "NetworkPerformance": "25 Gigabit" + }, + "PlacementGroupInfo": { + "SupportedStrategies": [ + "cluster", + "partition", + "spread" + ] + }, + "ProcessorInfo": { + "SupportedArchitectures": [ + "x86_64" + ], + "SustainedClockSpeedInGhz": 2.3 + }, + "SupportedBootModes": [ + "legacy-bios" + ], + "SupportedRootDeviceTypes": [ + "ebs" + ], + "SupportedUsageClasses": [ + "on-demand", + "spot" + ], + "SupportedVirtualizationTypes": [ + "hvm" + ], + "VCpuInfo": { + "DefaultCores": 32, + "DefaultThreadsPerCore": 2, + "DefaultVCpus": 64, + "ValidCores": [ + 2, + 4, + 6, + 8, + 10, + 12, + 14, + 16, + 18, + 20, + 22, + 24, + 26, + 28, + 30, + 32 + ], + "ValidThreadsPerCore": [ + 1, + 2 + ] + }, + "OndemandPricePerHour": 4.56, + "SpotPrice": 1.368 + }, + { + "AutoRecoverySupported": true, + "BareMetal": false, + "BurstablePerformanceSupported": false, + "CurrentGeneration": true, + "DedicatedHostsSupported": true, + "EbsInfo": { + "EbsOptimizedInfo": { + "BaselineBandwidthInMbps": 3500, + "BaselineIops": 20000, + "BaselineThroughputInMBps": 437.5, + "MaximumBandwidthInMbps": 3500, + "MaximumIops": 20000, + "MaximumThroughputInMBps": 437.5 + }, + "EbsOptimizedSupport": "default", + "EncryptionSupport": "supported", + "NvmeSupport": "unsupported" + }, + "FpgaInfo": null, + "FreeTierEligible": false, + "GpuInfo": { + "Gpus": [ + { + "Count": 1, + "Manufacturer": "NVIDIA", + "MemoryInfo": { + "SizeInMiB": 8192 + }, + "Name": "M60" + } + ], + "TotalGpuMemoryInMiB": 8192 + }, + "HibernationSupported": false, + "Hypervisor": "xen", + "InferenceAcceleratorInfo": null, + "InstanceStorageInfo": null, + "InstanceStorageSupported": false, + "InstanceType": "g3.4xlarge", + "MemoryInfo": { + "SizeInMiB": 124928 + }, + "NetworkInfo": { + "DefaultNetworkCardIndex": 0, + "EfaInfo": null, + "EfaSupported": false, + "EnaSupport": "supported", + "EncryptionInTransitSupported": false, + "Ipv4AddressesPerInterface": 30, + "Ipv6AddressesPerInterface": 30, + "Ipv6Supported": true, + "MaximumNetworkCards": 1, + "MaximumNetworkInterfaces": 8, + "NetworkCards": [ + { + "MaximumNetworkInterfaces": 8, + "NetworkCardIndex": 0, + "NetworkPerformance": "Up to 10 Gigabit" + } + ], + "NetworkPerformance": "Up to 10 Gigabit" + }, + "PlacementGroupInfo": { + "SupportedStrategies": [ + "cluster", + "partition", + "spread" + ] + }, + "ProcessorInfo": { + "SupportedArchitectures": [ + "x86_64" + ], + "SustainedClockSpeedInGhz": 2.7 + }, + "SupportedBootModes": [ + "legacy-bios" + ], + "SupportedRootDeviceTypes": [ + "ebs" + ], + "SupportedUsageClasses": [ + "on-demand", + "spot" + ], + "SupportedVirtualizationTypes": [ + "hvm" + ], + "VCpuInfo": { + "DefaultCores": 8, + "DefaultThreadsPerCore": 2, + "DefaultVCpus": 16, + "ValidCores": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8 + ], + "ValidThreadsPerCore": [ + 1, + 2 + ] + }, + "OndemandPricePerHour": 1.14, + "SpotPrice": 0.34199999999999997 + } +] \ No newline at end of file From e4364b89ac6d25888e5ddb2dbb0432b9608bc4f2 Mon Sep 17 00:00:00 2001 From: "Rodrigo O.C. Pereira" <68402662+digocorbellini@users.noreply.github.com> Date: Mon, 1 Aug 2022 17:37:10 -0500 Subject: [PATCH 2/9] Interactive output format (#149) * imported bubble tea table * created bubbletea.go in cli directory * all bubble tea columns populated * added controls * improved controls printout in footer * created instance type view * cleaned up mod file * fixed sort bug in CLI usage * cleaned up table creation code + vertical scaling * updated third party licenses * updated --help printout in readme * rename headerPadding variable * added bubble tea tests * renamed column header constants * changed bubble tea flag to table-interactive * interactive table hydrates pricing caches * changed bubble tea flag to 'interactive' * sort in all cases * de-dupe logic in TableOutputWide and bubble tea row/column creation * updated bubble tea tests * added dynamic column widths * fixed extra () in main Co-authored-by: Rodrigo Okamoto --- README.md | 2 +- THIRD_PARTY_LICENSES | 571 +++++++++++++++++++++ cmd/main.go | 64 +-- go.mod | 18 +- go.sum | 45 +- pkg/selector/outputs/bubbletea.go | 259 ++++++++++ pkg/selector/outputs/bubbletea_test.go | 170 ++++++ pkg/selector/outputs/outputs.go | 196 ++++--- test/static/FilterVerbose/g3_16xlarge.json | 121 +++++ 9 files changed, 1352 insertions(+), 94 deletions(-) create mode 100644 pkg/selector/outputs/bubbletea.go create mode 100644 pkg/selector/outputs/bubbletea_test.go create mode 100644 test/static/FilterVerbose/g3_16xlarge.json diff --git a/README.md b/README.md index aea2309..c9d182d 100644 --- a/README.md +++ b/README.md @@ -379,7 +379,7 @@ Global Flags: --cache-ttl int Cache TTLs in hours for pricing and instance type caches. Setting the cache to 0 will turn off caching and cleanup any on-disk caches. (default 168) -h, --help Help --max-results int The maximum number of instance types that match your criteria to return (default 20) - -o, --output string Specify the output format (table, table-wide, one-line) + -o, --output string Specify the output format (table, table-wide, one-line, interactive) --profile string AWS CLI profile to use for credentials and config -r, --region string AWS Region to use for API requests (NOTE: if not passed in, uses AWS SDK default precedence) --sort-by string 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. (default ".InstanceType") diff --git a/THIRD_PARTY_LICENSES b/THIRD_PARTY_LICENSES index 2e69ed9..224d356 100644 --- a/THIRD_PARTY_LICENSES +++ b/THIRD_PARTY_LICENSES @@ -874,6 +874,577 @@ 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/muesli/reflow; version v0.3.0 -- +https://github.com/muesli/reflow + +MIT License + +Copyright (c) 2019 Christian Muehlhaeuser + +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 +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/charmbracelet/bubbletea; v0.22.0 -- +https://github.com/charmbracelet/bubbletea +Copyright (c) 2020 Charmbracelet, Inc +** github.com/charmbracelet/bubbles; v0.13.0 -- +https://github.com/charmbracelet/bubbles +Copyright (c) 2020 Charmbracelet, Inc + +MIT License + +Copyright (c) 2020 Charmbracelet, Inc + +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 +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/charmbracelet/lipgloss; v0.5.0 -- +https://github.com/charmbracelet/lipgloss + +MIT License + +Copyright (c) 2021 Charmbracelet, Inc + +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 +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/muesli/cancelreader; v0.2.2 -- +https://github.com/muesli/cancelreader + +MIT License + +Copyright (c) 2022 Erik Geiser and Christian Muehlhaeuser + +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 +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/evertras/bubble-table; v0.14.5 -- +https://github.com/evertras/bubble-table + +MIT License + +Copyright (c) 2022 Brandon Fulljames + +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 +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/mattn/go-isatty; v0.0.14 -- +https://github.com/mattn/go-isatty + +Copyright (c) Yasuhiro MATSUMOTO + +MIT License (Expat) + +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 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/muesli/termenv; v0.12.0 -- +https://github.com/muesli/termenv + +MIT License + +Copyright (c) 2019 Christian Muehlhaeuser + +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 +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. + +------ + +** golang.org/x/sys; v0.0.0-20220727055044-e65921a090b8 -- +https://cs.opensource.google/go/x/sys + +Copyright (c) 2009 The Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +------ + +** github.com/lucasb-eyer/go-colorful; v1.2.0 -- +https://github.com/lucasb-eyer/go-colorful + +Copyright (c) 2013 Lucas Beyer + +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 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/mattn/go-runewidth; v0.0.13 -- +https://github.com/mattn/go-runewidth + +The MIT License (MIT) + +Copyright (c) 2016 Yasuhiro Matsumoto + +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 +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. + +------ + +** golang.org/x/term; v0.0.0-20220722155259-a9ba230a4035 -- +https://cs.opensource.google/go/x/term + +Copyright (c) 2009 The Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +------ + +** github.com/muesli/ansi; v0.0.0-20211031195517-c9f0611b6c70 -- +https://github.com/muesli/ansi + +MIT License + +Copyright (c) 2021 Christian Muehlhaeuser + +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 +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/atotto/clipboard; v0.1.4 -- +https://github.com/atotto/clipboard + +Copyright (c) 2013 Ato Araki. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of @atotto. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +------ + +** github.com/containerd/console; v1.0.3 -- +https://github.com/containerd/console + + + Apache License + Version 2.0, January 2004 + https://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright The containerd Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License 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. + +------ + +** github.com/rivo/uniseg; v0.3.0 -- +https://github.com/rivo/uniseg + +MIT License + +Copyright (c) 2019 Oliver Kuederle + +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 e304a7f..9bb4c30 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -30,6 +30,7 @@ import ( "github.com/aws/amazon-ec2-instance-selector/v2/pkg/selector/outputs" "github.com/aws/amazon-ec2-instance-selector/v2/pkg/sorter" "github.com/aws/aws-sdk-go/aws/session" + tea "github.com/charmbracelet/bubbletea" homedir "github.com/mitchellh/go-homedir" "github.com/spf13/cobra" "go.uber.org/multierr" @@ -47,6 +48,7 @@ const ( tableOutput = "table" tableWideOutput = "table-wide" oneLine = "one-line" + bubbleTeaOutput = "interactive" ) // Filter Flag Constants @@ -170,6 +172,7 @@ Full docs can be found at github.com/aws/amazon-` + binName tableOutput, tableWideOutput, oneLine, + bubbleTeaOutput, } resultsOutputFn := outputs.SimpleInstanceTypeOutput @@ -297,7 +300,7 @@ Full docs can be found at github.com/aws/amazon-` + binName sortField := cli.StringMe(flags[sortBy]) lowercaseSortField := strings.ToLower(*sortField) outputFlag := cli.StringMe(flags[output]) - if outputFlag != nil && *outputFlag == tableWideOutput { + if outputFlag != nil && (*outputFlag == tableWideOutput || *outputFlag == bubbleTeaOutput) { // If output type is `table-wide`, simply print both prices for better comparison, // even if the actual filter is applied on any one of those based on usage class // Save time by hydrating all caches in parallel @@ -421,47 +424,48 @@ Full docs can be found at github.com/aws/amazon-` + binName sortField = &sortFieldShorthandPath } - outputFn := getOutputFn(outputFlag, selector.InstanceTypesOutputFn(resultsOutputFn)) - var instanceTypes []string - var itemsTruncated int + // fetch instance types without truncating results + prevMaxResults := filters.MaxResults + filters.MaxResults = nil + instanceTypesDetails, err := instanceSelector.FilterVerbose(filters) + if err != nil { + fmt.Printf("An error occurred when filtering instance types: %v", err) + os.Exit(1) + } + // sort instance types sortDirection := cli.StringMe(flags[sortDirection]) - if *sortField == instanceNamePath && (*sortDirection == sortAscending || *sortDirection == sortAsc) { - // filter already sorts in ascending order by name - instanceTypes, itemsTruncated, err = instanceSelector.FilterWithOutput(filters, outputFn) - if err != nil { - fmt.Printf("An error occurred when filtering instance types: %v", err) - os.Exit(1) - } - if len(instanceTypes) == 0 { - log.Println("The criteria was too narrow and returned no valid instance types. Consider broadening your criteria so that more instance types are returned.") - os.Exit(1) - } - } else { - // fetch instance types without truncating results - prevMaxResults := filters.MaxResults - filters.MaxResults = nil - instanceTypeDetails, err := instanceSelector.FilterVerbose(filters) - if err != nil { - fmt.Printf("An error occurred when filtering instance types: %v", err) - os.Exit(1) - } + instanceTypesDetails, err = sorter.Sort(instanceTypesDetails, *sortField, *sortDirection) + if err != nil { + fmt.Printf("Sorting error: %v", err) + os.Exit(1) + } - instanceTypeDetails, err = sorter.Sort(instanceTypeDetails, *sortField, *sortDirection) - if err != nil { - fmt.Printf("Sorting error: %v", err) + // handle output format + var itemsTruncated int + var instanceTypes []string + if outputFlag != nil && *outputFlag == bubbleTeaOutput { + p := tea.NewProgram(outputs.NewBubbleTeaModel(instanceTypesDetails)) + if err := p.Start(); err != nil { + fmt.Printf("An error occurred when starting bubble tea: %v", err) os.Exit(1) } + shutdown() + return + } else { + // handle regular output modes + // truncate instance types based on user passed in maxResults - instanceTypeDetails, itemsTruncated = truncateResults(prevMaxResults, instanceTypeDetails) - if len(instanceTypeDetails) == 0 { + instanceTypesDetails, itemsTruncated = truncateResults(prevMaxResults, instanceTypesDetails) + if len(instanceTypesDetails) == 0 { log.Println("The criteria was too narrow and returned no valid instance types. Consider broadening your criteria so that more instance types are returned.") os.Exit(1) } // format instance types for output - instanceTypes = outputFn(instanceTypeDetails) + outputFn := getOutputFn(outputFlag, selector.InstanceTypesOutputFn(resultsOutputFn)) + instanceTypes = outputFn(instanceTypesDetails) } for _, instanceType := range instanceTypes { diff --git a/go.mod b/go.mod index 36ec5bd..71bd1b3 100644 --- a/go.mod +++ b/go.mod @@ -5,8 +5,14 @@ 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/bubbletea v0.21.0 + github.com/charmbracelet/lipgloss v0.5.0 + github.com/evertras/bubble-table v0.14.4 github.com/imdario/mergo v0.3.11 github.com/mitchellh/go-homedir v1.1.0 + github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 + github.com/oliveagle/jsonpath v0.0.0-20180606110733-2e52cf6e6852 github.com/patrickmn/go-cache v2.1.0+incompatible github.com/spf13/cobra v0.0.7 github.com/spf13/pflag v1.0.3 @@ -15,9 +21,19 @@ require ( ) require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/containerd/console v1.0.3 // indirect github.com/inconshreveable/mousetrap v1.0.0 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect - github.com/oliveagle/jsonpath v0.0.0-20180606110733-2e52cf6e6852 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.14 // indirect + github.com/mattn/go-runewidth v0.0.13 // indirect + github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect + 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/smartystreets/goconvey v1.6.4 // indirect go.uber.org/atomic v1.4.0 // indirect + golang.org/x/sys v0.0.0-20220209214540-3681064d5158 // indirect + golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect ) diff --git a/go.sum b/go.sum index 3e0d1bb..fd1c56c 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAE github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aws/aws-sdk-go v1.44.59 h1:bkdnNsMvMhFmNLqKDAJ6rKR+S0hjOt/3AIJp2mxOK9o= github.com/aws/aws-sdk-go v1.44.59/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= @@ -11,7 +13,16 @@ github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+Ce github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= 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/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= +github.com/charmbracelet/lipgloss v0.5.0 h1:lulQHuVeodSgDez+3rGiuxlPVXSnhth442DATR2/8t8= +github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= +github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk= github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= @@ -23,6 +34,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= +github.com/evertras/bubble-table v0.14.4 h1:UHUiPfsJ+lqbPSHIM1n7O8Ie2tbK0r9ReicXFnLg44I= +github.com/evertras/bubble-table v0.14.4/go.mod h1:SPOZKbIpyYWPHBNki3fyNpiPBQkvkULAtOT7NTD5fKY= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= @@ -64,11 +77,30 @@ github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFB github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= +github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= +github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= +github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= +github.com/muesli/cancelreader v0.2.0 h1:SOpr+CfyVNce341kKqvbhhzQhBPyJRXQaCtn03Pae1Q= +github.com/muesli/cancelreader v0.2.0/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ= +github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= +github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= +github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= +github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739 h1:QANkGiGr39l1EESqrE0gZw0/AJNYzIvoGLhIoVYtluI= +github.com/muesli/termenv v0.11.1-0.20220212125758-44cd13922739/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= github.com/oliveagle/jsonpath v0.0.0-20180606110733-2e52cf6e6852 h1:Yl0tPBa8QPjGmesFh1D0rDy+q1Twx6FyU7VWHi8wZbI= @@ -89,8 +121,12 @@ github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y8 github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +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/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= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= @@ -109,8 +145,8 @@ github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnIn github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= @@ -140,8 +176,14 @@ golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220209214540-3681064d5158 h1:rm+CHSpPEEW2IsXUib1ThaHIjuBVZjxNgSKmBLFfD4c= +golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= @@ -167,4 +209,5 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/pkg/selector/outputs/bubbletea.go b/pkg/selector/outputs/bubbletea.go new file mode 100644 index 0000000..28bc4b8 --- /dev/null +++ b/pkg/selector/outputs/bubbletea.go @@ -0,0 +1,259 @@ +// 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" + "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" +) + +var ( + customBorder = table.Border{ + Top: "─", + Left: "│", + Right: "│", + Bottom: "─", + + TopRight: "╮", + TopLeft: "╭", + BottomRight: "╯", + BottomLeft: "╰", + + TopJunction: "┬", + LeftJunction: "├", + RightJunction: "┤", + BottomJunction: "┴", + InnerJunction: "┼", + + InnerDivider: "│", + } +) + +// BubbleTeaModel is used to hold the state of the bubble tea TUI +type BubbleTeaModel struct { + // the model for the table output + TableModel table.Model +} + +// 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), + } +} + +// Init is used by bubble tea to initialize a bubble tea table +func (m BubbleTeaModel) Init() tea.Cmd { + return nil +} + +// 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: + switch msg.String() { + case "ctrl+c", "esc", "q": + return m, tea.Quit + } + 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) + } + } + + // 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) + + return m, cmd +} + +// 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) + } + + 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 +} diff --git a/pkg/selector/outputs/bubbletea_test.go b/pkg/selector/outputs/bubbletea_test.go new file mode 100644 index 0000000..4d2880f --- /dev/null +++ b/pkg/selector/outputs/bubbletea_test.go @@ -0,0 +1,170 @@ +// 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_test + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "strings" + "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" +) + +// helpers + +// getInstanceTypeDetails unmarshalls the json file in the given testing folder +// and returns a list of instance type details +func getInstanceTypeDetails(t *testing.T, file string) []*instancetypes.Details { + folder := "FilterVerbose" + mockFilename := fmt.Sprintf("%s/%s/%s", mockFilesPath, folder, file) + mockFile, err := ioutil.ReadFile(mockFilename) + h.Assert(t, err == nil, "Error reading mock file "+string(mockFilename)) + + instanceTypes := []*instancetypes.Details{} + err = json.Unmarshal(mockFile, &instanceTypes) + h.Assert(t, err == nil, fmt.Sprintf("Error parsing mock json file contents %s. Error: %v", mockFilename, err)) + return instanceTypes +} + +// getRowsInstances reformats the given table rows into a list of instance type names +func getRowsInstances(rows []table.Row) string { + instances := []string{} + + for _, row := range rows { + instances = append(instances, fmt.Sprintf("%v", row.Data["Instance Type"])) + } + + return strings.Join(instances, ", ") +} + +// tests + +func TestNewBubbleTeaModel_Hypervisor(t *testing.T) { + instanceTypes := getInstanceTypeDetails(t, "g3_16xlarge.json") + + // test non nil Hypervisor + model := outputs.NewBubbleTeaModel(instanceTypes) + rows := model.TableModel.GetVisibleRows() + expectedHypervisor := "xen" + actualHypervisor := rows[0].Data["Hypervisor"] + + h.Assert(t, actualHypervisor == expectedHypervisor, fmt.Sprintf("Hypervisor should be %s but instead is %s", expectedHypervisor, actualHypervisor)) + + // test nil Hypervisor + instanceTypes[0].Hypervisor = nil + model = outputs.NewBubbleTeaModel(instanceTypes) + rows = model.TableModel.GetVisibleRows() + expectedHypervisor = "none" + actualHypervisor = rows[0].Data["Hypervisor"] + + h.Assert(t, actualHypervisor == expectedHypervisor, fmt.Sprintf("Hypervisor should be %s but instead is %s", expectedHypervisor, actualHypervisor)) +} + +func TestNewBubbleTeaModel_CPUArchitectures(t *testing.T) { + instanceTypes := getInstanceTypeDetails(t, "g3_16xlarge.json") + model := outputs.NewBubbleTeaModel(instanceTypes) + rows := model.TableModel.GetVisibleRows() + + actualGPUArchitectures := "x86_64" + expectedGPUArchitectures := rows[0].Data["CPU Arch"] + + h.Assert(t, actualGPUArchitectures == expectedGPUArchitectures, "CPU architecture should be (%s), but actually (%s)", expectedGPUArchitectures, actualGPUArchitectures) +} + +func TestNewBubbleTeaModel_GPU(t *testing.T) { + instanceTypes := getInstanceTypeDetails(t, "g3_16xlarge.json") + model := outputs.NewBubbleTeaModel(instanceTypes) + rows := model.TableModel.GetVisibleRows() + + // test GPU count + expectedGPUCount := "4" + actualGPUCount := fmt.Sprintf("%v", rows[0].Data["GPUs"]) + + h.Assert(t, expectedGPUCount == actualGPUCount, "GPU count should be %s, but is actually %s", expectedGPUCount, actualGPUCount) + + // test GPU memory + expectedGPUMemory := "32" + actualGPUMemory := rows[0].Data["GPU Mem (GiB)"] + + h.Assert(t, expectedGPUMemory == actualGPUMemory, "GPU memory should be %s, but is actually %s", expectedGPUMemory, actualGPUMemory) + + // test GPU info + expectedGPUInfo := "NVIDIA M60" + actualGPUInfo := rows[0].Data["GPU Info"] + + h.Assert(t, expectedGPUInfo == actualGPUInfo, "GPU info should be (%s), but is actually (%s)", expectedGPUInfo, actualGPUInfo) +} + +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() + expectedODPrice := "$4.56" + actualODPrice := fmt.Sprintf("%v", rows[0].Data["On-Demand Price/Hr"]) + + h.Assert(t, actualODPrice == expectedODPrice, "Actual OD price should be %s, but is actually %s", expectedODPrice, actualODPrice) + + // test nil OD price + instanceTypes[0].OndemandPricePerHour = nil + model = outputs.NewBubbleTeaModel(instanceTypes) + rows = model.TableModel.GetVisibleRows() + expectedODPrice = "-Not Fetched-" + actualODPrice = fmt.Sprintf("%v", rows[0].Data["On-Demand Price/Hr"]) + + h.Assert(t, actualODPrice == expectedODPrice, "Actual OD price should be %s, but is actually %s", expectedODPrice, actualODPrice) +} + +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() + expectedODPrice := "$1.368" + actualODPrice := fmt.Sprintf("%v", rows[0].Data["Spot Price/Hr (30d avg)"]) + + h.Assert(t, actualODPrice == expectedODPrice, "Actual spot price should be %s, but is actually %s", expectedODPrice, actualODPrice) + + // test nil spot price + instanceTypes[0].SpotPrice = nil + model = outputs.NewBubbleTeaModel(instanceTypes) + rows = model.TableModel.GetVisibleRows() + expectedODPrice = "-Not Fetched-" + actualODPrice = fmt.Sprintf("%v", rows[0].Data["Spot Price/Hr (30d avg)"]) + + h.Assert(t, actualODPrice == expectedODPrice, "Actual spot price should be %s, but is actually %s", expectedODPrice, actualODPrice) +} + +func TestNewBubbleTeaModel_Rows(t *testing.T) { + instanceTypes := getInstanceTypeDetails(t, "3_instances.json") + model := outputs.NewBubbleTeaModel(instanceTypes) + rows := model.TableModel.GetVisibleRows() + + h.Assert(t, len(rows) == len(instanceTypes), "Number of rows should be %d, but is actually %d", len(instanceTypes), len(rows)) + + // test that order of instance types is retained + for i := range instanceTypes { + 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)) + } +} diff --git a/pkg/selector/outputs/outputs.go b/pkg/selector/outputs/outputs.go index 318cf8e..a4c600f 100644 --- a/pkg/selector/outputs/outputs.go +++ b/pkg/selector/outputs/outputs.go @@ -19,6 +19,7 @@ import ( "encoding/json" "fmt" "log" + "reflect" "strconv" "strings" "text/tabwriter" @@ -26,6 +27,27 @@ import ( "github.com/aws/amazon-ec2-instance-selector/v2/pkg/instancetypes" ) +const columnTag = "column" + +// wideColumnsData stores the data that should be displayed on each column +// of a wide output row +type wideColumnsData struct { + instanceName string `column:"Instance Type"` + vcpu int64 `column:"VCPUs"` + memory string `column:"Mem (GiB)"` + hypervisor string `column:"Hypervisor"` + currentGen bool `column:"Current Gen"` + hibernationSupport bool `column:"Hibernation Support"` + cpuArch string `column:"CPU Arch"` + networkPerformance string `column:"Network Performance"` + eni int64 `column:"ENIs"` + gpu int64 `column:"GPUs"` + gpuMemory string `column:"GPU Mem (GiB)"` + gpuInfo string `column:"GPU Info"` + odPrice string `column:"On-Demand Price/Hr"` + spotPrice string `column:"Spot Price/Hr (30d avg)"` +} + // SimpleInstanceTypeOutput is an OutputFn which outputs a slice of instance type names func SimpleInstanceTypeOutput(instanceTypeInfoSlice []*instancetypes.Details) []string { instanceTypeStrings := []string{} @@ -91,28 +113,15 @@ func TableOutputWide(instanceTypeInfoSlice []*instancetypes.Details) []string { } w := new(tabwriter.Writer) buf := new(bytes.Buffer) - none := "none" w.Init(buf, 8, 8, 2, ' ', 0) defer w.Flush() - onDemandPricePerHourHeader := "On-Demand Price/Hr" - spotPricePerHourHeader := "Spot Price/Hr (30d avg)" - - headers := []interface{}{ - "Instance Type", - "VCPUs", - "Mem (GiB)", - "Hypervisor", - "Current Gen", - "Hibernation Support", - "CPU Arch", - "Network Performance", - "ENIs", - "GPUs", - "GPU Mem (GiB)", - "GPU Info", - onDemandPricePerHourHeader, - spotPricePerHourHeader, + columnDataStruct := wideColumnsData{} + headers := []interface{}{} + structType := reflect.TypeOf(columnDataStruct) + for i := 0; i < structType.NumField(); i++ { + columnHeader := structType.Field(i).Tag.Get(columnTag) + headers = append(headers, columnHeader) } separators := make([]interface{}, 0) @@ -124,50 +133,24 @@ func TableOutputWide(instanceTypeInfoSlice []*instancetypes.Details) []string { fmt.Fprintf(w, headerFormat, headers...) fmt.Fprintf(w, "\n"+headerFormat, separators...) - for _, instanceTypeInfo := range instanceTypeInfoSlice { - hypervisor := instanceTypeInfo.Hypervisor - if hypervisor == nil { - hypervisor = &none - } - cpuArchitectures := []string{} - for _, cpuArch := range instanceTypeInfo.ProcessorInfo.SupportedArchitectures { - cpuArchitectures = append(cpuArchitectures, *cpuArch) - } - gpus := int64(0) - gpuMemory := int64(0) - gpuType := []string{} - if instanceTypeInfo.GpuInfo != nil { - gpuMemory = *instanceTypeInfo.GpuInfo.TotalGpuMemoryInMiB - for _, gpuInfo := range instanceTypeInfo.GpuInfo.Gpus { - gpus = gpus + *gpuInfo.Count - gpuType = append(gpuType, *gpuInfo.Manufacturer+" "+*gpuInfo.Name) - } - } - - onDemandPricePerHourStr := "-Not Fetched-" - spotPricePerHourStr := "-Not Fetched-" - if instanceTypeInfo.OndemandPricePerHour != nil { - onDemandPricePerHourStr = fmt.Sprintf("$%s", formatFloat(*instanceTypeInfo.OndemandPricePerHour)) - } - if instanceTypeInfo.SpotPrice != nil { - spotPricePerHourStr = fmt.Sprintf("$%s", formatFloat(*instanceTypeInfo.SpotPrice)) - } + columnsData := getWideColumnsData(instanceTypeInfoSlice) + for _, data := range columnsData { fmt.Fprintf(w, "\n%s\t%d\t%s\t%s\t%t\t%t\t%s\t%s\t%d\t%d\t%s\t%s\t%s\t%s\t", - *instanceTypeInfo.InstanceType, - *instanceTypeInfo.VCpuInfo.DefaultVCpus, - formatFloat(float64(*instanceTypeInfo.MemoryInfo.SizeInMiB)/1024.0), - *hypervisor, - *instanceTypeInfo.CurrentGeneration, - *instanceTypeInfo.HibernationSupported, - strings.Join(cpuArchitectures, ", "), - *instanceTypeInfo.NetworkInfo.NetworkPerformance, - *instanceTypeInfo.NetworkInfo.MaximumNetworkInterfaces, - gpus, - formatFloat(float64(gpuMemory)/1024.0), - strings.Join(gpuType, ", "), - onDemandPricePerHourStr, - spotPricePerHourStr, + data.instanceName, + data.vcpu, + data.memory, + data.hypervisor, + data.currentGen, + data.hibernationSupport, + data.cpuArch, + data.networkPerformance, + data.eni, + data.gpu, + data.gpuMemory, + data.gpuInfo, + data.odPrice, + data.spotPrice, ) } w.Flush() @@ -211,3 +194,94 @@ func reverse(s string) string { } return string(runes) } + +// getWideColumnsData returns the column data necessary for a wide output for each of +// the given instance types +func getWideColumnsData(instanceTypes []*instancetypes.Details) []*wideColumnsData { + columnsData := []*wideColumnsData{} + + for _, instanceType := range instanceTypes { + none := "none" + hyperisor := instanceType.Hypervisor + if hyperisor == nil { + hyperisor = &none + } + + cpuArchitectures := []string{} + for _, cpuArch := range instanceType.ProcessorInfo.SupportedArchitectures { + cpuArchitectures = append(cpuArchitectures, *cpuArch) + } + + gpus := int64(0) + gpuMemory := int64(0) + gpuType := []string{} + if instanceType.GpuInfo != nil { + gpuMemory = *instanceType.GpuInfo.TotalGpuMemoryInMiB + for _, gpuInfo := range instanceType.GpuInfo.Gpus { + gpus = gpus + *gpuInfo.Count + gpuType = append(gpuType, *gpuInfo.Manufacturer+" "+*gpuInfo.Name) + } + } + + onDemandPricePerHourStr := "-Not Fetched-" + spotPricePerHourStr := "-Not Fetched-" + if instanceType.OndemandPricePerHour != nil { + onDemandPricePerHourStr = "$" + formatFloat(*instanceType.OndemandPricePerHour) + } + if instanceType.SpotPrice != nil { + spotPricePerHourStr = "$" + formatFloat(*instanceType.SpotPrice) + } + + newColumn := wideColumnsData{ + instanceName: *instanceType.InstanceType, + vcpu: *instanceType.VCpuInfo.DefaultVCpus, + memory: formatFloat(float64(*instanceType.MemoryInfo.SizeInMiB) / 1024.0), + hypervisor: *hyperisor, + currentGen: *instanceType.CurrentGeneration, + hibernationSupport: *instanceType.HibernationSupported, + cpuArch: strings.Join(cpuArchitectures, ", "), + networkPerformance: *instanceType.NetworkInfo.NetworkPerformance, + eni: *instanceType.NetworkInfo.MaximumNetworkInterfaces, + gpu: gpus, + gpuMemory: formatFloat(float64(gpuMemory) / 1024.0), + gpuInfo: strings.Join(gpuType, ", "), + odPrice: onDemandPricePerHourStr, + spotPrice: spotPricePerHourStr, + } + + columnsData = append(columnsData, &newColumn) + } + + return columnsData +} + +// getUnderlyingValue returns the underlying value of the given +// reflect.Value type +func getUnderlyingValue(value reflect.Value) interface{} { + var val interface{} + + switch value.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + val = value.Int() + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + val = value.Uint() + case reflect.Float32, reflect.Float64: + val = value.Float() + case reflect.String: + val = value.String() + case reflect.Pointer: + val = value.Pointer() + case reflect.Bool: + val = value.Bool() + case reflect.Complex128, reflect.Complex64: + val = value.Complex() + case reflect.Interface: + val = value.Interface() + case reflect.UnsafePointer: + val = value.UnsafePointer() + default: + val = nil + } + + return val +} diff --git a/test/static/FilterVerbose/g3_16xlarge.json b/test/static/FilterVerbose/g3_16xlarge.json new file mode 100644 index 0000000..9855779 --- /dev/null +++ b/test/static/FilterVerbose/g3_16xlarge.json @@ -0,0 +1,121 @@ +[ + { + "AutoRecoverySupported": true, + "BareMetal": false, + "BurstablePerformanceSupported": false, + "CurrentGeneration": true, + "DedicatedHostsSupported": true, + "EbsInfo": { + "EbsOptimizedInfo": { + "BaselineBandwidthInMbps": 14000, + "BaselineIops": 80000, + "BaselineThroughputInMBps": 1750, + "MaximumBandwidthInMbps": 14000, + "MaximumIops": 80000, + "MaximumThroughputInMBps": 1750 + }, + "EbsOptimizedSupport": "default", + "EncryptionSupport": "supported", + "NvmeSupport": "unsupported" + }, + "FpgaInfo": null, + "FreeTierEligible": false, + "GpuInfo": { + "Gpus": [ + { + "Count": 4, + "Manufacturer": "NVIDIA", + "MemoryInfo": { + "SizeInMiB": 8192 + }, + "Name": "M60" + } + ], + "TotalGpuMemoryInMiB": 32768 + }, + "HibernationSupported": false, + "Hypervisor": "xen", + "InferenceAcceleratorInfo": null, + "InstanceStorageInfo": null, + "InstanceStorageSupported": false, + "InstanceType": "g3.16xlarge", + "MemoryInfo": { + "SizeInMiB": 499712 + }, + "NetworkInfo": { + "DefaultNetworkCardIndex": 0, + "EfaInfo": null, + "EfaSupported": false, + "EnaSupport": "supported", + "EncryptionInTransitSupported": false, + "Ipv4AddressesPerInterface": 50, + "Ipv6AddressesPerInterface": 50, + "Ipv6Supported": true, + "MaximumNetworkCards": 1, + "MaximumNetworkInterfaces": 15, + "NetworkCards": [ + { + "MaximumNetworkInterfaces": 15, + "NetworkCardIndex": 0, + "NetworkPerformance": "25 Gigabit" + } + ], + "NetworkPerformance": "25 Gigabit" + }, + "PlacementGroupInfo": { + "SupportedStrategies": [ + "cluster", + "partition", + "spread" + ] + }, + "ProcessorInfo": { + "SupportedArchitectures": [ + "x86_64" + ], + "SustainedClockSpeedInGhz": 2.3 + }, + "SupportedBootModes": [ + "legacy-bios" + ], + "SupportedRootDeviceTypes": [ + "ebs" + ], + "SupportedUsageClasses": [ + "on-demand", + "spot" + ], + "SupportedVirtualizationTypes": [ + "hvm" + ], + "VCpuInfo": { + "DefaultCores": 32, + "DefaultThreadsPerCore": 2, + "DefaultVCpus": 64, + "ValidCores": [ + 2, + 4, + 6, + 8, + 10, + 12, + 14, + 16, + 18, + 20, + 22, + 24, + 26, + 28, + 30, + 32 + ], + "ValidThreadsPerCore": [ + 1, + 2 + ] + }, + "OndemandPricePerHour": 4.56, + "SpotPrice": 1.368 + } +] \ No newline at end of file From a07cb5a18e8295d0753ce643a2f6d7f115282aa4 Mon Sep 17 00:00:00 2001 From: Brandon Wagner Date: Wed, 3 Aug 2022 13:37:22 -0500 Subject: [PATCH 3/9] fix pricing sync issue (#150) * fix pricing sync with mutex on refresh * expire cache items on load --- pkg/ec2pricing/odpricing.go | 12 +++++++++++- pkg/ec2pricing/spotpricing.go | 12 +++++++++++- pkg/selector/selector.go | 2 +- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/pkg/ec2pricing/odpricing.go b/pkg/ec2pricing/odpricing.go index f4ea64b..1cc99a6 100644 --- a/pkg/ec2pricing/odpricing.go +++ b/pkg/ec2pricing/odpricing.go @@ -23,6 +23,7 @@ import ( "path/filepath" "strconv" "strings" + "sync" "time" "github.com/aws/aws-sdk-go/aws" @@ -44,6 +45,7 @@ type OnDemandPricing struct { DirectoryPath string cache *cache.Cache pricingClient pricingiface.PricingAPI + sync.RWMutex } func LoadODCacheOrNew(pricingClient pricingiface.PricingAPI, region string, fullRefreshTTL time.Duration, directoryPath string) *OnDemandPricing { @@ -91,7 +93,9 @@ func loadODCacheFrom(itemTTL time.Duration, region string, expandedDirPath strin if err := json.Unmarshal(cacheBytes, odCache); err != nil { return nil, err } - return cache.NewFrom(itemTTL, itemTTL, *odCache), nil + c := cache.NewFrom(itemTTL, itemTTL, *odCache) + c.DeleteExpired() + return c, nil } func getODCacheFilePath(region string, directoryPath string) string { @@ -111,6 +115,8 @@ func odCacheRefreshJob(odPricing *OnDemandPricing) { } func (c *OnDemandPricing) Refresh() error { + c.Lock() + defer c.Unlock() odInstanceTypeCosts, err := c.fetchOnDemandPricing("") if err != nil { return fmt.Errorf("there was a problem refreshing the on-demand instance type pricing cache: %v", err) @@ -128,6 +134,8 @@ func (c *OnDemandPricing) Get(instanceType string) (float64, error) { if cost, ok := c.cache.Get(instanceType); ok { return cost.(float64), nil } + c.RLock() + defer c.RUnlock() costs, err := c.fetchOnDemandPricing(instanceType) if err != nil { return 0, fmt.Errorf("there was a problem fetching on-demand instance type pricing for %s: %v", instanceType, err) @@ -154,6 +162,8 @@ func (c *OnDemandPricing) Save() error { } func (c *OnDemandPricing) Clear() error { + c.Lock() + defer c.Unlock() c.cache.Flush() return os.Remove(getODCacheFilePath(c.Region, c.DirectoryPath)) } diff --git a/pkg/ec2pricing/spotpricing.go b/pkg/ec2pricing/spotpricing.go index 9c09764..6f4bc6d 100644 --- a/pkg/ec2pricing/spotpricing.go +++ b/pkg/ec2pricing/spotpricing.go @@ -23,6 +23,7 @@ import ( "path/filepath" "sort" "strconv" + "sync" "time" "github.com/aws/aws-sdk-go/aws" @@ -43,6 +44,7 @@ type SpotPricing struct { DirectoryPath string cache *cache.Cache ec2Client ec2iface.EC2API + sync.RWMutex } type spotPricingEntry struct { @@ -98,7 +100,9 @@ func loadSpotCacheFrom(itemTTL time.Duration, region string, expandedDirPath str if err := decoder.Decode(spotTimeSeries); err != nil { return nil, err } - return cache.NewFrom(itemTTL, itemTTL, *spotTimeSeries), nil + c := cache.NewFrom(itemTTL, itemTTL, *spotTimeSeries) + c.DeleteExpired() + return c, nil } func getSpotCacheFilePath(region string, directoryPath string) string { @@ -118,6 +122,8 @@ func spotCacheRefreshJob(spotPricing *SpotPricing, days int) { } func (c *SpotPricing) Refresh(days int) error { + c.Lock() + defer c.Unlock() spotInstanceTypeCosts, err := c.fetchSpotPricingTimeSeries("", days) if err != nil { return fmt.Errorf("there was a problem refreshing the spot instance type pricing cache: %v", err) @@ -139,6 +145,8 @@ func (c *SpotPricing) Get(instanceType string, zone string, days int) (float64, } } if !ok { + c.RLock() + defer c.RUnlock() zonalSpotPricing, err := c.fetchSpotPricingTimeSeries(instanceType, days) if err != nil { return -1, fmt.Errorf("there was a problem fetching spot instance type pricing for %s: %v", instanceType, err) @@ -224,6 +232,8 @@ func (c *SpotPricing) Save() error { } func (c *SpotPricing) Clear() error { + c.Lock() + defer c.Unlock() c.cache.Flush() return os.Remove(getSpotCacheFilePath(c.Region, c.DirectoryPath)) } diff --git a/pkg/selector/selector.go b/pkg/selector/selector.go index 4088d2d..953932c 100644 --- a/pkg/selector/selector.go +++ b/pkg/selector/selector.go @@ -261,7 +261,7 @@ func (itf Selector) prepareFilter(filters Filters, instanceTypeInfo instancetype if itf.EC2Pricing.OnDemandCacheCount() > 0 { price, err := itf.EC2Pricing.GetOnDemandInstanceTypeCost(instanceTypeName) if err != nil { - log.Printf("Could not retrieve instantaneous hourly on-demand price for instance type %s\n", instanceTypeName) + log.Printf("Could not retrieve instantaneous hourly on-demand price for instance type %s - %s\n", instanceTypeName, err) } else { instanceTypeHourlyPriceOnDemand = &price instanceTypeInfo.OndemandPricePerHour = instanceTypeHourlyPriceOnDemand From 218d13ff13b2ce78fbe7c0d024375dcbf8c703de Mon Sep 17 00:00:00 2001 From: "Rodrigo O.C. Pereira" <68402662+digocorbellini@users.noreply.github.com> Date: Thu, 4 Aug 2022 16:53:43 -0500 Subject: [PATCH 4/9] Verbose View for Interactive Output (#151) * added verbose view to interactive output * removed useless statement * updated verbose view controls * added 'none' to missing gpuinfo * updated example table-wide printouts in readme * fixed width resizing bug with text wrapping * moved views into own files * fixed bubble tea tests Co-authored-by: Rodrigo Okamoto --- README.md | 63 ++--- cmd/main.go | 2 +- pkg/selector/outputs/bubbletea.go | 256 +++++------------- ...tea_test.go => bubbletea_internal_test.go} | 45 +-- pkg/selector/outputs/outputs.go | 2 + pkg/selector/outputs/tableView.go | 256 ++++++++++++++++++ pkg/selector/outputs/verboseView.go | 121 +++++++++ 7 files changed, 498 insertions(+), 247 deletions(-) rename pkg/selector/outputs/{bubbletea_test.go => bubbletea_internal_test.go} (84%) create mode 100644 pkg/selector/outputs/tableView.go create mode 100644 pkg/selector/outputs/verboseView.go 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() +} From 98c355563038bb52d6e43addc90578496eb6da38 Mon Sep 17 00:00:00 2001 From: "Rodrigo O.C. Pereira" <68402662+digocorbellini@users.noreply.github.com> Date: Wed, 10 Aug 2022 21:32:43 -0500 Subject: [PATCH 5/9] fixed eks testing bug (#153) Co-authored-by: Rodrigo Okamoto --- pkg/selector/eks_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/selector/eks_test.go b/pkg/selector/eks_test.go index fc7c5cd..3da4380 100644 --- a/pkg/selector/eks_test.go +++ b/pkg/selector/eks_test.go @@ -109,8 +109,8 @@ func eksGithubReleaseHTTPServer(failLatestRelease bool, failExactRelease bool) * w.WriteHeader(404) return } - w.WriteHeader(302) w.Header().Add("location", "/releases/tag/v"+githubReleaseVersion) + w.WriteHeader(302) return } if r.URL.Path == "/archive/v"+githubReleaseVersion+".zip" { From adc1627c331e74bd52f80b23dfb31e9e6354a29f Mon Sep 17 00:00:00 2001 From: "Rodrigo O.C. Pereira" <68402662+digocorbellini@users.noreply.github.com> Date: Thu, 11 Aug 2022 14:51:24 -0500 Subject: [PATCH 6/9] Interactive table sorting, trimming, and filtering (#152) * implemented filtering * styled filter text input * fixed bug with filtering and then going to verbose view * fixed bug with expanding with no rows selected * working trim feature * fixed trim while filtering bug * moved sorting shorthands to sorter.go * created sorting view (Non fuctional) * fixed row selection toggle * added sorting by json path * added sorting by shorthand values * added sort direction toggle * updated third party licenses * moved all sorting constants to sorter.go * removed unecessary print in sortingView view() * renamed initSortingView to initSortingModel * use 'esc' instead of 'e' to exit verbose view * Added interactive output demo to readme Co-authored-by: Rodrigo Okamoto --- README.md | 6 + THIRD_PARTY_LICENSES | 27 +++ cmd/main.go | 61 +------ go.mod | 3 +- go.sum | 3 + pkg/selector/outputs/bubbletea.go | 109 ++++++++++-- pkg/selector/outputs/sortingView.go | 257 ++++++++++++++++++++++++++++ pkg/selector/outputs/tableView.go | 242 ++++++++++++++++++++++++-- pkg/selector/outputs/verboseView.go | 9 +- pkg/sorter/sorter.go | 72 ++++++-- 10 files changed, 683 insertions(+), 106 deletions(-) create mode 100644 pkg/selector/outputs/sortingView.go 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, From 6e75d52941093029aa01ccdbab3669cd26395d04 Mon Sep 17 00:00:00 2001 From: "Rodrigo O.C. Pereira" <68402662+digocorbellini@users.noreply.github.com> Date: Fri, 12 Aug 2022 14:45:35 -0500 Subject: [PATCH 7/9] fixed interactive table resizing bug (#154) Co-authored-by: Rodrigo Okamoto --- pkg/selector/outputs/tableView.go | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/pkg/selector/outputs/tableView.go b/pkg/selector/outputs/tableView.go index 2d9213b..07e52e8 100644 --- a/pkg/selector/outputs/tableView.go +++ b/pkg/selector/outputs/tableView.go @@ -249,12 +249,17 @@ func (m tableModel) resizeView(msg tea.WindowSizeMsg) tableModel { // handle height changes if headerAndFooterPadding >= msg.Height { - // height too short to fit rows - m.table = m.table.WithPageSize(0) - m.tableRowsPerPage = 0 + // height too short to fit footer and header + // so only display 1 row + m.table = m.table.WithPageSize(1) + m.table = m.table.WithFooterVisibility(false) + m.table = m.table.WithHeaderVisibility(false) + m.tableRowsPerPage = 1 } else { newRowsPerPage := msg.Height - headerAndFooterPadding m.table = m.table.WithPageSize(newRowsPerPage) + m.table = m.table.WithFooterVisibility(true) + m.table = m.table.WithHeaderVisibility(true) m.tableRowsPerPage = newRowsPerPage } From dcdd98906df2ad5d8ff42453c4e8b7d9d664f0f5 Mon Sep 17 00:00:00 2001 From: Brandon Wagner Date: Fri, 12 Aug 2022 15:34:37 -0500 Subject: [PATCH 8/9] use-go-proxy (#156) --- scripts/build-docker-images | 1 - test/license-test/run-license-test.sh | 2 +- test/readme-test/run-readme-codeblocks | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/scripts/build-docker-images b/scripts/build-docker-images index b80d3ff..c159c46 100755 --- a/scripts/build-docker-images +++ b/scripts/build-docker-images @@ -55,7 +55,6 @@ for os_arch in "${PLATFORMS[@]}"; do docker build \ --build-arg GOOS=${os} \ --build-arg GOARCH=${arch} \ - --build-arg GOPROXY=${GOPROXY} \ -t ${img_tag} \ ${REPO_ROOT_PATH} done \ No newline at end of file diff --git a/test/license-test/run-license-test.sh b/test/license-test/run-license-test.sh index 6c13dee..4a8f9d9 100755 --- a/test/license-test/run-license-test.sh +++ b/test/license-test/run-license-test.sh @@ -10,6 +10,6 @@ LICENSE_TEST_TAG="aeis-license-test" LICENSE_REPORT_FILE="$BUILD_PATH/license-report" SUPPORTED_PLATFORMS_LINUX="linux/amd64" make -s -f $SCRIPTPATH/../../Makefile build-binaries -docker buildx build --load --build-arg=GOPROXY=direct -t $LICENSE_TEST_TAG $SCRIPTPATH/ +docker buildx build --load -t $LICENSE_TEST_TAG $SCRIPTPATH/ docker run -i -e GITHUB_TOKEN --rm -v $SCRIPTPATH/:/test -v $BUILD_BIN/:/aeis-bin $LICENSE_TEST_TAG golicense /test/license-config.hcl /aeis-bin/$BINARY_NAME | tee $LICENSE_REPORT_FILE $SCRIPTPATH/check-licenses.sh $LICENSE_REPORT_FILE diff --git a/test/readme-test/run-readme-codeblocks b/test/readme-test/run-readme-codeblocks index cc81fd6..b58bb52 100755 --- a/test/readme-test/run-readme-codeblocks +++ b/test/readme-test/run-readme-codeblocks @@ -10,7 +10,7 @@ function exit_and_fail() { exit 1 } trap exit_and_fail INT ERR -docker build --target=builder --build-arg="GOPROXY=direct" -t codeblocks -f $SCRIPTPATH/../../Dockerfile $SCRIPTPATH/../../ +docker build --target=builder -t codeblocks -f $SCRIPTPATH/../../Dockerfile $SCRIPTPATH/../../ docker build -t rundoc -f $SCRIPTPATH/rundoc-Dockerfile $SCRIPTPATH/ function rd() { docker run -i --rm -v $SCRIPTPATH/../../:/aeis rundoc rundoc "$@" From 2e422d0689553c40d41290ed0f6f0dc202c90045 Mon Sep 17 00:00:00 2001 From: Austin Siu Date: Fri, 12 Aug 2022 15:40:32 -0500 Subject: [PATCH 9/9] =?UTF-8?q?=F0=9F=A5=91=F0=9F=A4=96=20v2.4.0=20release?= =?UTF-8?q?=20prep=20=F0=9F=A4=96=F0=9F=A5=91=20(#155)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5c1a475..cd9574b 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ brew install ec2-instance-selector #### Install w/ Curl for Linux/Mac ``` -curl -Lo ec2-instance-selector https://github.com/aws/amazon-ec2-instance-selector/releases/download/v2.3.3/ec2-instance-selector-`uname | tr '[:upper:]' '[:lower:]'`-amd64 && chmod +x ec2-instance-selector +curl -Lo ec2-instance-selector https://github.com/aws/amazon-ec2-instance-selector/releases/download/v2.4.0/ec2-instance-selector-`uname | tr '[:upper:]' '[:lower:]'`-amd64 && chmod +x ec2-instance-selector ``` To execute the CLI, you will need AWS credentials configured. Take a look at the [AWS CLI configuration documentation](https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-configure.html#config-settings-and-precedence) for details on the various ways to configure credentials. An easy way to try out the ec2-instance-selector CLI is to populate the following environment variables with your AWS API credentials.