mirror of https://github.com/docker/cli.git
Sort swarm stacks and nodes using natural sorting
This commit changes the order stacks and nodes are displayed. For example, running "docker stack ls" is expected to display the following list: NAME SERVICES service-1 1 service-2 1 service-10 1 However, currently this is what is printed: NAME SERVICES service-1 1 service-10 1 service-2 1 To fix this, "docker stack ls" and "docker node ls" are using natural sorting to make it more human readable. Signed-off-by: Boaz Shuster <ripcurld.github@gmail.com>
This commit is contained in:
parent
e3746d388e
commit
1333b49194
|
@ -1,15 +1,27 @@
|
||||||
package node
|
package node
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"sort"
|
||||||
|
|
||||||
"github.com/docker/cli/cli"
|
"github.com/docker/cli/cli"
|
||||||
"github.com/docker/cli/cli/command"
|
"github.com/docker/cli/cli/command"
|
||||||
"github.com/docker/cli/cli/command/formatter"
|
"github.com/docker/cli/cli/command/formatter"
|
||||||
"github.com/docker/cli/opts"
|
"github.com/docker/cli/opts"
|
||||||
"github.com/docker/docker/api/types"
|
"github.com/docker/docker/api/types"
|
||||||
|
"github.com/docker/docker/api/types/swarm"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"golang.org/x/net/context"
|
"golang.org/x/net/context"
|
||||||
|
"vbom.ml/util/sortorder"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type byHostname []swarm.Node
|
||||||
|
|
||||||
|
func (n byHostname) Len() int { return len(n) }
|
||||||
|
func (n byHostname) Swap(i, j int) { n[i], n[j] = n[j], n[i] }
|
||||||
|
func (n byHostname) Less(i, j int) bool {
|
||||||
|
return sortorder.NaturalLess(n[i].Description.Hostname, n[j].Description.Hostname)
|
||||||
|
}
|
||||||
|
|
||||||
type listOptions struct {
|
type listOptions struct {
|
||||||
quiet bool
|
quiet bool
|
||||||
format string
|
format string
|
||||||
|
@ -68,5 +80,6 @@ func runList(dockerCli command.Cli, options listOptions) error {
|
||||||
Output: dockerCli.Out(),
|
Output: dockerCli.Out(),
|
||||||
Format: formatter.NewNodeFormat(format, options.quiet),
|
Format: formatter.NewNodeFormat(format, options.quiet),
|
||||||
}
|
}
|
||||||
|
sort.Sort(byHostname(nodes))
|
||||||
return formatter.NodeWrite(nodesCtx, nodes, info)
|
return formatter.NodeWrite(nodesCtx, nodes, info)
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,6 +9,8 @@ import (
|
||||||
"github.com/docker/cli/cli/internal/test"
|
"github.com/docker/cli/cli/internal/test"
|
||||||
"github.com/docker/docker/api/types"
|
"github.com/docker/docker/api/types"
|
||||||
"github.com/docker/docker/api/types/swarm"
|
"github.com/docker/docker/api/types/swarm"
|
||||||
|
"github.com/docker/docker/pkg/testutil"
|
||||||
|
"github.com/docker/docker/pkg/testutil/golden"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
// Import builders to get the builder function as package function
|
// Import builders to get the builder function as package function
|
||||||
. "github.com/docker/cli/cli/internal/test/builders"
|
. "github.com/docker/cli/cli/internal/test/builders"
|
||||||
|
@ -156,3 +158,22 @@ func TestNodeListFormat(t *testing.T) {
|
||||||
assert.Contains(t, buf.String(), `nodeHostname1: Leader`)
|
assert.Contains(t, buf.String(), `nodeHostname1: Leader`)
|
||||||
assert.Contains(t, buf.String(), `nodeHostname2: Reachable`)
|
assert.Contains(t, buf.String(), `nodeHostname2: Reachable`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNodeListOrder(t *testing.T) {
|
||||||
|
cli := test.NewFakeCli(&fakeClient{
|
||||||
|
nodeListFunc: func() ([]swarm.Node, error) {
|
||||||
|
return []swarm.Node{
|
||||||
|
*Node(Hostname("node-2-foo"), Manager(Leader())),
|
||||||
|
*Node(Hostname("node-10-foo"), Manager()),
|
||||||
|
*Node(Hostname("node-1-foo")),
|
||||||
|
}, nil
|
||||||
|
|
||||||
|
},
|
||||||
|
})
|
||||||
|
cmd := newListCommand(cli)
|
||||||
|
cmd.Flags().Set("format", "{{.Hostname}}: {{.ManagerStatus}}")
|
||||||
|
assert.NoError(t, cmd.Execute())
|
||||||
|
actual := cli.OutBuffer().String()
|
||||||
|
expected := golden.Get(t, []byte(actual), "node-list-sort.golden")
|
||||||
|
testutil.EqualNormalizedString(t, testutil.RemoveSpace, actual, string(expected))
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
node-1-foo:
|
||||||
|
node-2-foo: Leader
|
||||||
|
node-10-foo: Reachable
|
|
@ -12,6 +12,7 @@ import (
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"golang.org/x/net/context"
|
"golang.org/x/net/context"
|
||||||
|
"vbom.ml/util/sortorder"
|
||||||
)
|
)
|
||||||
|
|
||||||
type listOptions struct {
|
type listOptions struct {
|
||||||
|
@ -60,7 +61,7 @@ type byName []*formatter.Stack
|
||||||
|
|
||||||
func (n byName) Len() int { return len(n) }
|
func (n byName) Len() int { return len(n) }
|
||||||
func (n byName) Swap(i, j int) { n[i], n[j] = n[j], n[i] }
|
func (n byName) Swap(i, j int) { n[i], n[j] = n[j], n[i] }
|
||||||
func (n byName) Less(i, j int) bool { return n[i].Name < n[j].Name }
|
func (n byName) Less(i, j int) bool { return sortorder.NaturalLess(n[i].Name, n[j].Name) }
|
||||||
|
|
||||||
func getStacks(ctx context.Context, apiclient client.APIClient) ([]*formatter.Stack, error) {
|
func getStacks(ctx context.Context, apiclient client.APIClient) ([]*formatter.Stack, error) {
|
||||||
services, err := apiclient.ServiceList(
|
services, err := apiclient.ServiceList(
|
||||||
|
|
|
@ -98,10 +98,13 @@ func TestListWithoutFormat(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestListOrder(t *testing.T) {
|
func TestListOrder(t *testing.T) {
|
||||||
buf := new(bytes.Buffer)
|
usecases := []struct {
|
||||||
cmd := newListCommand(test.NewFakeCliWithOutput(&fakeClient{
|
golden string
|
||||||
serviceListFunc: func(options types.ServiceListOptions) ([]swarm.Service, error) {
|
swarmServices []swarm.Service
|
||||||
return []swarm.Service{
|
}{
|
||||||
|
{
|
||||||
|
golden: "stack-list-sort.golden",
|
||||||
|
swarmServices: []swarm.Service{
|
||||||
*Service(
|
*Service(
|
||||||
ServiceLabels(map[string]string{
|
ServiceLabels(map[string]string{
|
||||||
"com.docker.stack.namespace": "service-name-foo",
|
"com.docker.stack.namespace": "service-name-foo",
|
||||||
|
@ -112,11 +115,40 @@ func TestListOrder(t *testing.T) {
|
||||||
"com.docker.stack.namespace": "service-name-bar",
|
"com.docker.stack.namespace": "service-name-bar",
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
}, nil
|
},
|
||||||
},
|
},
|
||||||
}, buf))
|
{
|
||||||
assert.NoError(t, cmd.Execute())
|
golden: "stack-list-sort-natural.golden",
|
||||||
actual := buf.String()
|
swarmServices: []swarm.Service{
|
||||||
expected := golden.Get(t, []byte(actual), "stack-list-sort.golden")
|
*Service(
|
||||||
testutil.EqualNormalizedString(t, testutil.RemoveSpace, actual, string(expected))
|
ServiceLabels(map[string]string{
|
||||||
|
"com.docker.stack.namespace": "service-name-1-foo",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
*Service(
|
||||||
|
ServiceLabels(map[string]string{
|
||||||
|
"com.docker.stack.namespace": "service-name-10-foo",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
*Service(
|
||||||
|
ServiceLabels(map[string]string{
|
||||||
|
"com.docker.stack.namespace": "service-name-2-foo",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, uc := range usecases {
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
cmd := newListCommand(test.NewFakeCliWithOutput(&fakeClient{
|
||||||
|
serviceListFunc: func(options types.ServiceListOptions) ([]swarm.Service, error) {
|
||||||
|
return uc.swarmServices, nil
|
||||||
|
},
|
||||||
|
}, buf))
|
||||||
|
assert.NoError(t, cmd.Execute())
|
||||||
|
actual := buf.String()
|
||||||
|
expected := golden.Get(t, []byte(actual), uc.golden)
|
||||||
|
testutil.EqualNormalizedString(t, testutil.RemoveSpace, actual, string(expected))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,4 @@
|
||||||
|
NAME SERVICES
|
||||||
|
service-name-1-foo 1
|
||||||
|
service-name-2-foo 1
|
||||||
|
service-name-10-foo 1
|
|
@ -52,3 +52,4 @@ gopkg.in/yaml.v2 4c78c975fe7c825c6d1466c42be594d1d6f3aba6
|
||||||
github.com/tonistiigi/fsutil 0ac4c11b053b9c5c7c47558f81f96c7100ce50fb
|
github.com/tonistiigi/fsutil 0ac4c11b053b9c5c7c47558f81f96c7100ce50fb
|
||||||
github.com/stevvooe/continuity cd7a8e21e2b6f84799f5dd4b65faf49c8d3ee02d
|
github.com/stevvooe/continuity cd7a8e21e2b6f84799f5dd4b65faf49c8d3ee02d
|
||||||
golang.org/x/sync de49d9dcd27d4f764488181bea099dfe6179bcf0
|
golang.org/x/sync de49d9dcd27d4f764488181bea099dfe6179bcf0
|
||||||
|
vbom.ml/util 928aaa586d7718c70f4090ddf83f2b34c16fdc8d
|
||||||
|
|
|
@ -0,0 +1,17 @@
|
||||||
|
The MIT License (MIT)
|
||||||
|
Copyright (c) 2015 Frits van Bommel
|
||||||
|
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.
|
|
@ -0,0 +1,5 @@
|
||||||
|
## util [![GoDoc](https://godoc.org/vbom.ml/util?status.svg)](https://godoc.org/vbom.ml/util)
|
||||||
|
|
||||||
|
import "vbom.ml/util"
|
||||||
|
|
||||||
|
Go utility packages.
|
|
@ -0,0 +1,5 @@
|
||||||
|
## sortorder [![GoDoc](https://godoc.org/vbom.ml/util/sortorder?status.svg)](https://godoc.org/vbom.ml/util/sortorder)
|
||||||
|
|
||||||
|
import "vbom.ml/util/sortorder"
|
||||||
|
|
||||||
|
Sort orders and comparison functions.
|
|
@ -0,0 +1,5 @@
|
||||||
|
// Package sortorder implements sort orders and comparison functions.
|
||||||
|
//
|
||||||
|
// Currently, it only implements so-called "natural order", where integers
|
||||||
|
// embedded in strings are compared by value.
|
||||||
|
package sortorder // import "vbom.ml/util/sortorder"
|
|
@ -0,0 +1,76 @@
|
||||||
|
package sortorder
|
||||||
|
|
||||||
|
// Natural implements sort.Interface to sort strings in natural order. This
|
||||||
|
// means that e.g. "abc2" < "abc12".
|
||||||
|
//
|
||||||
|
// Non-digit sequences and numbers are compared separately. The former are
|
||||||
|
// compared bytewise, while the latter are compared numerically (except that
|
||||||
|
// the number of leading zeros is used as a tie-breaker, so e.g. "2" < "02")
|
||||||
|
//
|
||||||
|
// Limitation: only ASCII digits (0-9) are considered.
|
||||||
|
type Natural []string
|
||||||
|
|
||||||
|
func (n Natural) Len() int { return len(n) }
|
||||||
|
func (n Natural) Swap(i, j int) { n[i], n[j] = n[j], n[i] }
|
||||||
|
func (n Natural) Less(i, j int) bool { return NaturalLess(n[i], n[j]) }
|
||||||
|
|
||||||
|
func isdigit(b byte) bool { return '0' <= b && b <= '9' }
|
||||||
|
|
||||||
|
// NaturalLess compares two strings using natural ordering. This means that e.g.
|
||||||
|
// "abc2" < "abc12".
|
||||||
|
//
|
||||||
|
// Non-digit sequences and numbers are compared separately. The former are
|
||||||
|
// compared bytewise, while the latter are compared numerically (except that
|
||||||
|
// the number of leading zeros is used as a tie-breaker, so e.g. "2" < "02")
|
||||||
|
//
|
||||||
|
// Limitation: only ASCII digits (0-9) are considered.
|
||||||
|
func NaturalLess(str1, str2 string) bool {
|
||||||
|
idx1, idx2 := 0, 0
|
||||||
|
for idx1 < len(str1) && idx2 < len(str2) {
|
||||||
|
c1, c2 := str1[idx1], str2[idx2]
|
||||||
|
dig1, dig2 := isdigit(c1), isdigit(c2)
|
||||||
|
switch {
|
||||||
|
case dig1 != dig2: // Digits before other characters.
|
||||||
|
return dig1 // True if LHS is a digit, false if the RHS is one.
|
||||||
|
case !dig1: // && !dig2, because dig1 == dig2
|
||||||
|
// UTF-8 compares bytewise-lexicographically, no need to decode
|
||||||
|
// codepoints.
|
||||||
|
if c1 != c2 {
|
||||||
|
return c1 < c2
|
||||||
|
}
|
||||||
|
idx1++
|
||||||
|
idx2++
|
||||||
|
default: // Digits
|
||||||
|
// Eat zeros.
|
||||||
|
for ; idx1 < len(str1) && str1[idx1] == '0'; idx1++ {
|
||||||
|
}
|
||||||
|
for ; idx2 < len(str2) && str2[idx2] == '0'; idx2++ {
|
||||||
|
}
|
||||||
|
// Eat all digits.
|
||||||
|
nonZero1, nonZero2 := idx1, idx2
|
||||||
|
for ; idx1 < len(str1) && isdigit(str1[idx1]); idx1++ {
|
||||||
|
}
|
||||||
|
for ; idx2 < len(str2) && isdigit(str2[idx2]); idx2++ {
|
||||||
|
}
|
||||||
|
// If lengths of numbers with non-zero prefix differ, the shorter
|
||||||
|
// one is less.
|
||||||
|
if len1, len2 := idx1-nonZero1, idx2-nonZero2; len1 != len2 {
|
||||||
|
return len1 < len2
|
||||||
|
}
|
||||||
|
// If they're not equal, string comparison is correct.
|
||||||
|
if nr1, nr2 := str1[nonZero1:idx1], str2[nonZero2:idx2]; nr1 != nr2 {
|
||||||
|
return nr1 < nr2
|
||||||
|
}
|
||||||
|
// Otherwise, the one with less zeros is less.
|
||||||
|
// Because everything up to the number is equal, comparing the index
|
||||||
|
// after the zeros is sufficient.
|
||||||
|
if nonZero1 != nonZero2 {
|
||||||
|
return nonZero1 < nonZero2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// They're identical so far, so continue comparing.
|
||||||
|
}
|
||||||
|
// So far they are identical. At least one is ended. If the other continues,
|
||||||
|
// it sorts last.
|
||||||
|
return len(str1) < len(str2)
|
||||||
|
}
|
Loading…
Reference in New Issue