mirror of https://github.com/fluxcd/cli-utils.git
Merge pull request #122 from mortent/TablePrinterLib
Break out functionality for table printing into library
This commit is contained in:
commit
c813e58c26
|
|
@ -0,0 +1,11 @@
|
|||
// Copyright 2020 The Kubernetes Authors.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package common
|
||||
|
||||
const (
|
||||
// RESET is the escape sequence for unsetting any previous commands.
|
||||
RESET = 0
|
||||
// ESC is the escape sequence used to send ANSI commands in the terminal.
|
||||
ESC = 27
|
||||
)
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
// Copyright 2020 The Kubernetes Authors.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"sigs.k8s.io/cli-utils/pkg/kstatus/status"
|
||||
)
|
||||
|
||||
// color is a type that captures the ANSI code for colors on the
|
||||
// terminal.
|
||||
type Color int
|
||||
|
||||
var (
|
||||
RED Color = 31
|
||||
GREEN Color = 32
|
||||
YELLOW Color = 33
|
||||
)
|
||||
|
||||
// SprintfWithColor formats according to the provided pattern and returns
|
||||
// the result as a string with the necessary ansii escape codes for
|
||||
// color
|
||||
func SprintfWithColor(color Color, format string, a ...interface{}) string {
|
||||
return fmt.Sprintf("%c[%dm", ESC, color) +
|
||||
fmt.Sprintf(format, a...) +
|
||||
fmt.Sprintf("%c[%dm", ESC, RESET)
|
||||
}
|
||||
|
||||
// ColorForStatus returns the appropriate Color, which represents
|
||||
// the ansii escape code, for different status values.
|
||||
func ColorForStatus(s status.Status) (color Color, setColor bool) {
|
||||
switch s {
|
||||
case status.CurrentStatus:
|
||||
color = GREEN
|
||||
setColor = true
|
||||
case status.InProgressStatus:
|
||||
color = YELLOW
|
||||
setColor = true
|
||||
case status.FailedStatus:
|
||||
color = RED
|
||||
setColor = true
|
||||
}
|
||||
return
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
// Copyright 2020 The Kubernetes Authors.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package common
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gotest.tools/assert"
|
||||
"sigs.k8s.io/cli-utils/pkg/kstatus/status"
|
||||
)
|
||||
|
||||
func TestSprintfWithColor(t *testing.T) {
|
||||
testCases := map[string]struct {
|
||||
color Color
|
||||
format string
|
||||
args []interface{}
|
||||
expectedResult string
|
||||
}{
|
||||
"no args with color": {
|
||||
color: GREEN,
|
||||
format: "This is a test",
|
||||
args: []interface{}{},
|
||||
expectedResult: "\x1b[32mThis is a test\x1b[0m",
|
||||
},
|
||||
"with args and color": {
|
||||
color: YELLOW,
|
||||
format: "%s %s",
|
||||
args: []interface{}{"sonic", "youth"},
|
||||
expectedResult: "\x1b[33msonic youth\x1b[0m",
|
||||
},
|
||||
}
|
||||
|
||||
for tn, tc := range testCases {
|
||||
t.Run(tn, func(t *testing.T) {
|
||||
result := SprintfWithColor(tc.color, tc.format, tc.args...)
|
||||
if want, got := tc.expectedResult, result; want != got {
|
||||
t.Errorf("expected %q, but got %q", want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestColorForStatus(t *testing.T) {
|
||||
testCases := map[string]struct {
|
||||
status status.Status
|
||||
expectedSetColor bool
|
||||
expectedColor Color
|
||||
}{
|
||||
"status with color": {
|
||||
status: status.CurrentStatus,
|
||||
expectedSetColor: true,
|
||||
expectedColor: GREEN,
|
||||
},
|
||||
"status without color": {
|
||||
status: status.NotFoundStatus,
|
||||
expectedSetColor: false,
|
||||
},
|
||||
}
|
||||
|
||||
for tn, tc := range testCases {
|
||||
t.Run(tn, func(t *testing.T) {
|
||||
color, setColor := ColorForStatus(tc.status)
|
||||
assert.Equal(t, setColor, tc.expectedSetColor)
|
||||
if tc.expectedSetColor {
|
||||
assert.Equal(t, color, tc.expectedColor)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
// Copyright 2020 The Kubernetes Authors.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package table
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"k8s.io/cli-runtime/pkg/genericclioptions"
|
||||
pe "sigs.k8s.io/cli-utils/pkg/kstatus/polling/event"
|
||||
"sigs.k8s.io/cli-utils/pkg/object"
|
||||
"sigs.k8s.io/cli-utils/pkg/print/common"
|
||||
)
|
||||
|
||||
// ColumnDefinition defines the columns that should be printed.
|
||||
type ColumnDefinition interface {
|
||||
Name() string
|
||||
Header() string
|
||||
Width() int
|
||||
PrintResource(w io.Writer, width int, r Resource) (int, error)
|
||||
}
|
||||
|
||||
// ResourceStates defines the interface that must be implemented
|
||||
// by the object that provides information about the resources
|
||||
// that should be printed.
|
||||
type ResourceStates interface {
|
||||
Resources() []Resource
|
||||
}
|
||||
|
||||
// Resource defines the interface that each of the Resource
|
||||
// objects must implement.
|
||||
type Resource interface {
|
||||
Identifier() object.ObjMetadata
|
||||
ResourceStatus() *pe.ResourceStatus
|
||||
SubResources() []Resource
|
||||
}
|
||||
|
||||
// BaseTablePrinter provides functionality for printing information
|
||||
// about a set of resources into a table format.
|
||||
// The printer will print to the Out stream defined in IOStreams,
|
||||
// and will print into the format defined by the Column definitions.
|
||||
type BaseTablePrinter struct {
|
||||
IOStreams genericclioptions.IOStreams
|
||||
Columns []ColumnDefinition
|
||||
}
|
||||
|
||||
// PrintTable prints the resources defined in ResourceStates. It will
|
||||
// print subresources if they exist.
|
||||
// moveUpCount defines how many lines the printer should move up
|
||||
// before starting printing. The return value is how many lines
|
||||
// were printed.
|
||||
func (t *BaseTablePrinter) PrintTable(rs ResourceStates,
|
||||
moveUpCount int) int {
|
||||
for i := 0; i < moveUpCount; i++ {
|
||||
t.moveUp()
|
||||
t.eraseCurrentLine()
|
||||
}
|
||||
linePrintCount := 0
|
||||
|
||||
for i, column := range t.Columns {
|
||||
format := fmt.Sprintf("%%-%ds", column.Width())
|
||||
t.printOrDie(format, column.Header())
|
||||
if i == len(t.Columns)-1 {
|
||||
t.printOrDie("\n")
|
||||
linePrintCount++
|
||||
} else {
|
||||
t.printOrDie(" ")
|
||||
}
|
||||
}
|
||||
|
||||
for _, resource := range rs.Resources() {
|
||||
for i, column := range t.Columns {
|
||||
written, err := column.PrintResource(t.IOStreams.Out, column.Width(), resource)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
remainingSpace := column.Width() - written
|
||||
t.printOrDie(strings.Repeat(" ", remainingSpace))
|
||||
if i == len(t.Columns)-1 {
|
||||
t.printOrDie("\n")
|
||||
linePrintCount++
|
||||
} else {
|
||||
t.printOrDie(" ")
|
||||
}
|
||||
}
|
||||
|
||||
linePrintCount += t.printSubTable(resource.SubResources(), "")
|
||||
}
|
||||
|
||||
return linePrintCount
|
||||
}
|
||||
|
||||
// printSubTable prints out any subresources that belong to the
|
||||
// top-level resources. This function takes care of printing the correct tree
|
||||
// structure and indentation.
|
||||
func (t *BaseTablePrinter) printSubTable(resources []Resource,
|
||||
prefix string) int {
|
||||
linePrintCount := 0
|
||||
for j, resource := range resources {
|
||||
for i, column := range t.Columns {
|
||||
availableWidth := column.Width()
|
||||
if column.Name() == "resource" {
|
||||
if j < len(resources)-1 {
|
||||
t.printOrDie(prefix + `├─ `)
|
||||
} else {
|
||||
t.printOrDie(prefix + `└─ `)
|
||||
}
|
||||
availableWidth -= utf8.RuneCountInString(prefix) + 3
|
||||
}
|
||||
written, err := column.PrintResource(t.IOStreams.Out,
|
||||
availableWidth, resource)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
remainingSpace := availableWidth - written
|
||||
t.printOrDie(strings.Repeat(" ", remainingSpace))
|
||||
if i == len(t.Columns)-1 {
|
||||
t.printOrDie("\n")
|
||||
linePrintCount++
|
||||
} else {
|
||||
t.printOrDie(" ")
|
||||
}
|
||||
}
|
||||
|
||||
var prefix string
|
||||
if j < len(resources)-1 {
|
||||
prefix = `│ `
|
||||
} else {
|
||||
prefix = " "
|
||||
}
|
||||
linePrintCount += t.printSubTable(resource.SubResources(), prefix)
|
||||
}
|
||||
return linePrintCount
|
||||
}
|
||||
|
||||
func (t *BaseTablePrinter) printOrDie(format string, a ...interface{}) {
|
||||
_, err := fmt.Fprintf(t.IOStreams.Out, format, a...)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *BaseTablePrinter) moveUp() {
|
||||
t.printOrDie("%c[%dA", common.ESC, 1)
|
||||
}
|
||||
|
||||
func (t *BaseTablePrinter) eraseCurrentLine() {
|
||||
t.printOrDie("%c[2K\r", common.ESC)
|
||||
}
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
// Copyright 2020 The Kubernetes Authors.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package table
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gotest.tools/assert"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"k8s.io/cli-runtime/pkg/genericclioptions"
|
||||
pe "sigs.k8s.io/cli-utils/pkg/kstatus/polling/event"
|
||||
"sigs.k8s.io/cli-utils/pkg/object"
|
||||
)
|
||||
|
||||
var (
|
||||
endColumnDef = ColumnDef{
|
||||
ColumnName: "end",
|
||||
ColumnHeader: "END",
|
||||
ColumnWidth: 3,
|
||||
PrintResourceFunc: func(w io.Writer, width int, r Resource) (i int,
|
||||
err error) {
|
||||
return fmt.Fprint(w, "end")
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
func TestBaseTablePrinter_PrintTable(t *testing.T) {
|
||||
testCases := map[string]struct {
|
||||
columnDefinitions []ColumnDefinition
|
||||
resources []Resource
|
||||
expectedOutput string
|
||||
}{
|
||||
"no resources": {
|
||||
columnDefinitions: []ColumnDefinition{
|
||||
MustColumn("resource"),
|
||||
endColumnDef,
|
||||
},
|
||||
resources: []Resource{},
|
||||
expectedOutput: `
|
||||
RESOURCE END
|
||||
`,
|
||||
},
|
||||
"with resource": {
|
||||
columnDefinitions: []ColumnDefinition{
|
||||
MustColumn("resource"),
|
||||
endColumnDef,
|
||||
},
|
||||
resources: []Resource{
|
||||
&fakeResource{
|
||||
resourceStatus: &pe.ResourceStatus{
|
||||
Identifier: object.ObjMetadata{
|
||||
Namespace: "default",
|
||||
Name: "Foo",
|
||||
GroupKind: schema.GroupKind{
|
||||
Group: "apps",
|
||||
Kind: "Deployment",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedOutput: `
|
||||
RESOURCE END
|
||||
Deployment/Foo end
|
||||
`,
|
||||
},
|
||||
"sub resources": {
|
||||
columnDefinitions: []ColumnDefinition{
|
||||
MustColumn("resource"),
|
||||
endColumnDef,
|
||||
},
|
||||
resources: []Resource{
|
||||
&fakeResource{
|
||||
resourceStatus: &pe.ResourceStatus{
|
||||
Identifier: object.ObjMetadata{
|
||||
Namespace: "default",
|
||||
Name: "Foo",
|
||||
GroupKind: schema.GroupKind{
|
||||
Group: "apps",
|
||||
Kind: "Deployment",
|
||||
},
|
||||
},
|
||||
GeneratedResources: []*pe.ResourceStatus{
|
||||
{
|
||||
Identifier: object.ObjMetadata{
|
||||
Namespace: "default",
|
||||
Name: "Bar",
|
||||
GroupKind: schema.GroupKind{
|
||||
Group: "apps",
|
||||
Kind: "ReplicaSet",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedOutput: `
|
||||
RESOURCE END
|
||||
Deployment/Foo end
|
||||
└─ ReplicaSet/Bar end
|
||||
`,
|
||||
},
|
||||
"trim long content": {
|
||||
columnDefinitions: []ColumnDefinition{
|
||||
MustColumn("resource"),
|
||||
endColumnDef,
|
||||
},
|
||||
resources: []Resource{
|
||||
&fakeResource{
|
||||
resourceStatus: &pe.ResourceStatus{
|
||||
Identifier: object.ObjMetadata{
|
||||
Namespace: "default",
|
||||
Name: "VeryLongNameThatShouldBeTrimmed",
|
||||
GroupKind: schema.GroupKind{
|
||||
Group: "apps",
|
||||
Kind: "Deployment",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedOutput: `
|
||||
RESOURCE END
|
||||
Deployment/VeryLongNameThatShouldBeTrimm end
|
||||
`,
|
||||
},
|
||||
}
|
||||
|
||||
for tn, tc := range testCases {
|
||||
t.Run(tn, func(t *testing.T) {
|
||||
ioStreams, _, outBuffer, _ := genericclioptions.NewTestIOStreams()
|
||||
|
||||
printer := &BaseTablePrinter{
|
||||
IOStreams: ioStreams,
|
||||
Columns: tc.columnDefinitions,
|
||||
}
|
||||
|
||||
resourceStates := &fakeResourceStates{
|
||||
resources: tc.resources,
|
||||
}
|
||||
|
||||
printer.PrintTable(resourceStates, 0)
|
||||
|
||||
assert.Equal(t,
|
||||
strings.TrimSpace(tc.expectedOutput),
|
||||
strings.TrimSpace(outBuffer.String()))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type fakeResourceStates struct {
|
||||
resources []Resource
|
||||
}
|
||||
|
||||
func (r *fakeResourceStates) Resources() []Resource {
|
||||
return r.resources
|
||||
}
|
||||
|
||||
type fakeResource struct {
|
||||
resourceStatus *pe.ResourceStatus
|
||||
}
|
||||
|
||||
func (r *fakeResource) Identifier() object.ObjMetadata {
|
||||
return r.resourceStatus.Identifier
|
||||
}
|
||||
|
||||
func (r *fakeResource) ResourceStatus() *pe.ResourceStatus {
|
||||
return r.resourceStatus
|
||||
}
|
||||
|
||||
func (r *fakeResource) SubResources() []Resource {
|
||||
var resources []Resource
|
||||
for _, res := range r.resourceStatus.GeneratedResources {
|
||||
resources = append(resources, &fakeResource{
|
||||
resourceStatus: res,
|
||||
})
|
||||
}
|
||||
return resources
|
||||
}
|
||||
|
|
@ -0,0 +1,243 @@
|
|||
// Copyright 2020 The Kubernetes Authors.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package table
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/utils/integer"
|
||||
"sigs.k8s.io/cli-utils/pkg/print/common"
|
||||
)
|
||||
|
||||
// ColumnDef is an implementation of the ColumnDefinition interface.
|
||||
// It can be used to define simple columns that doesn't need additional
|
||||
// knowledge about the actual type of the provided Resource besides the
|
||||
// information available through the interface.
|
||||
type ColumnDef struct {
|
||||
ColumnName string
|
||||
ColumnHeader string
|
||||
ColumnWidth int
|
||||
PrintResourceFunc func(w io.Writer, width int, r Resource) (int, error)
|
||||
}
|
||||
|
||||
// Name returns the name of the column.
|
||||
func (c ColumnDef) Name() string {
|
||||
return c.ColumnName
|
||||
}
|
||||
|
||||
// Header returns the header that should be printed for
|
||||
// the column.
|
||||
func (c ColumnDef) Header() string {
|
||||
return c.ColumnHeader
|
||||
}
|
||||
|
||||
// Width returns the width of the column.
|
||||
func (c ColumnDef) Width() int {
|
||||
return c.ColumnWidth
|
||||
}
|
||||
|
||||
// PrintResource is called by the BaseTablePrinter to output the
|
||||
// content of a particular column. This implementation just delegates
|
||||
// to the provided PrintResourceFunc.
|
||||
func (c ColumnDef) PrintResource(w io.Writer, width int, r Resource) (int, error) {
|
||||
return c.PrintResourceFunc(w, width, r)
|
||||
}
|
||||
|
||||
// MustColumn returns the pre-defined column definition with the
|
||||
// provided name. If the name doesn't exist, it will panic.
|
||||
func MustColumn(name string) ColumnDef {
|
||||
c, found := columnDefinitions[name]
|
||||
if !found {
|
||||
panic(fmt.Errorf("unknown column name %q", name))
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
var (
|
||||
columnDefinitions = map[string]ColumnDef{
|
||||
// namespace defines a column that output the namespace of the
|
||||
// resource, or nothing in the case of clusterscoped resources.
|
||||
"namespace": {
|
||||
ColumnName: "namespace",
|
||||
ColumnHeader: "NAMESPACE",
|
||||
ColumnWidth: 10,
|
||||
PrintResourceFunc: func(w io.Writer, width int, r Resource) (int,
|
||||
error) {
|
||||
namespace := r.Identifier().Namespace
|
||||
if len(namespace) > width {
|
||||
namespace = namespace[:width]
|
||||
}
|
||||
_, err := fmt.Fprint(w, namespace)
|
||||
return len(namespace), err
|
||||
},
|
||||
},
|
||||
// resource defines a column that outputs the kind and name of a
|
||||
// resource.
|
||||
"resource": {
|
||||
ColumnName: "resource",
|
||||
ColumnHeader: "RESOURCE",
|
||||
ColumnWidth: 40,
|
||||
PrintResourceFunc: func(w io.Writer, width int, r Resource) (int,
|
||||
error) {
|
||||
text := fmt.Sprintf("%s/%s", r.Identifier().GroupKind.Kind,
|
||||
r.Identifier().Name)
|
||||
if len(text) > width {
|
||||
text = text[:width]
|
||||
}
|
||||
_, err := fmt.Fprint(w, text)
|
||||
return len(text), err
|
||||
},
|
||||
},
|
||||
// status defines a column that outputs the status of a resource. It
|
||||
// will use ansii escape codes to color the output.
|
||||
"status": {
|
||||
ColumnName: "status",
|
||||
ColumnHeader: "STATUS",
|
||||
ColumnWidth: 10,
|
||||
PrintResourceFunc: func(w io.Writer, width int, r Resource) (int,
|
||||
error) {
|
||||
rs := r.ResourceStatus()
|
||||
if rs == nil {
|
||||
return 0, nil
|
||||
}
|
||||
s := rs.Status.String()
|
||||
if len(s) > width {
|
||||
s = s[:width]
|
||||
}
|
||||
color, setColor := common.ColorForStatus(rs.Status)
|
||||
var outputStatus string
|
||||
if setColor {
|
||||
outputStatus = common.SprintfWithColor(color, s)
|
||||
} else {
|
||||
outputStatus = s
|
||||
}
|
||||
_, err := fmt.Fprint(w, outputStatus)
|
||||
return len(s), err
|
||||
},
|
||||
},
|
||||
// conditions defines a column that outputs the conditions for
|
||||
// a resource. The output will be in colors.
|
||||
"conditions": {
|
||||
ColumnName: "conditions",
|
||||
ColumnHeader: "CONDITIONS",
|
||||
ColumnWidth: 40,
|
||||
PrintResourceFunc: func(w io.Writer, width int, r Resource) (int,
|
||||
error) {
|
||||
rs := r.ResourceStatus()
|
||||
if rs == nil {
|
||||
return 0, nil
|
||||
}
|
||||
u := rs.Resource
|
||||
if u == nil {
|
||||
return fmt.Fprintf(w, "-")
|
||||
}
|
||||
|
||||
conditions, found, err := unstructured.NestedSlice(u.Object,
|
||||
"status", "conditions")
|
||||
if !found || err != nil || len(conditions) == 0 {
|
||||
return fmt.Fprintf(w, "<None>")
|
||||
}
|
||||
|
||||
realLength := 0
|
||||
for i, cond := range conditions {
|
||||
condition := cond.(map[string]interface{})
|
||||
conditionType := condition["type"].(string)
|
||||
conditionStatus := condition["status"].(string)
|
||||
var color common.Color
|
||||
switch conditionStatus {
|
||||
case "True":
|
||||
color = common.GREEN
|
||||
case "False":
|
||||
color = common.RED
|
||||
default:
|
||||
color = common.YELLOW
|
||||
}
|
||||
remainingWidth := width - realLength
|
||||
if len(conditionType) > remainingWidth {
|
||||
conditionType = conditionType[:remainingWidth]
|
||||
}
|
||||
_, err := fmt.Fprint(w, common.SprintfWithColor(color, conditionType))
|
||||
if err != nil {
|
||||
return realLength, err
|
||||
}
|
||||
realLength += len(conditionType)
|
||||
if i < len(conditions)-1 && width-realLength > 2 {
|
||||
_, err = fmt.Fprintf(w, ",")
|
||||
if err != nil {
|
||||
return realLength, err
|
||||
}
|
||||
realLength += 1
|
||||
}
|
||||
}
|
||||
return realLength, nil
|
||||
},
|
||||
},
|
||||
// age defines a column that outputs the age of a resource computed
|
||||
// by looking at the creationTimestamp field.
|
||||
"age": {
|
||||
ColumnName: "age",
|
||||
ColumnHeader: "AGE",
|
||||
ColumnWidth: 6,
|
||||
PrintResourceFunc: func(w io.Writer, width int, r Resource) (i int, err error) {
|
||||
rs := r.ResourceStatus()
|
||||
if rs == nil {
|
||||
return 0, nil
|
||||
}
|
||||
u := rs.Resource
|
||||
if u == nil {
|
||||
return fmt.Fprint(w, "-")
|
||||
}
|
||||
|
||||
timestamp, found, err := unstructured.NestedString(u.Object,
|
||||
"metadata", "creationTimestamp")
|
||||
if !found || err != nil || timestamp == "" {
|
||||
return fmt.Fprint(w, "-")
|
||||
}
|
||||
parsedTime, err := time.Parse(time.RFC3339, timestamp)
|
||||
if err != nil {
|
||||
return fmt.Fprint(w, "-")
|
||||
}
|
||||
age := time.Since(parsedTime)
|
||||
switch {
|
||||
case age.Seconds() <= 90:
|
||||
return fmt.Fprintf(w, "%ds",
|
||||
integer.RoundToInt32(age.Round(time.Second).Seconds()))
|
||||
case age.Minutes() <= 90:
|
||||
return fmt.Fprintf(w, "%dm",
|
||||
integer.RoundToInt32(age.Round(time.Minute).Minutes()))
|
||||
default:
|
||||
return fmt.Fprintf(w, "%dh",
|
||||
integer.RoundToInt32(age.Round(time.Hour).Hours()))
|
||||
}
|
||||
},
|
||||
},
|
||||
// message defines a column that outputs the message from a
|
||||
// ResourceStatus, or if there is a non-nil error, output the text
|
||||
// from the error instead.
|
||||
"message": {
|
||||
ColumnName: "message",
|
||||
ColumnHeader: "MESSAGE",
|
||||
ColumnWidth: 40,
|
||||
PrintResourceFunc: func(w io.Writer, width int, r Resource) (i int, err error) {
|
||||
rs := r.ResourceStatus()
|
||||
if rs == nil {
|
||||
return 0, nil
|
||||
}
|
||||
var message string
|
||||
if rs.Error != nil {
|
||||
message = rs.Error.Error()
|
||||
} else {
|
||||
message = rs.Message
|
||||
}
|
||||
if len(message) > width {
|
||||
message = message[:width]
|
||||
}
|
||||
return fmt.Fprint(w, message)
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
|
@ -0,0 +1,239 @@
|
|||
// Copyright 2020 The Kubernetes Authors.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package table
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
v1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
pe "sigs.k8s.io/cli-utils/pkg/kstatus/polling/event"
|
||||
"sigs.k8s.io/cli-utils/pkg/kstatus/status"
|
||||
"sigs.k8s.io/cli-utils/pkg/object"
|
||||
)
|
||||
|
||||
func TestColumnDefs(t *testing.T) {
|
||||
testCases := map[string]struct {
|
||||
columnName string
|
||||
resource Resource
|
||||
columnWidth int
|
||||
expectedOutput string
|
||||
}{
|
||||
"namespace": {
|
||||
columnName: "namespace",
|
||||
resource: &fakeResource{
|
||||
resourceStatus: &pe.ResourceStatus{
|
||||
Identifier: object.ObjMetadata{
|
||||
Namespace: "Foo",
|
||||
},
|
||||
},
|
||||
},
|
||||
columnWidth: 10,
|
||||
expectedOutput: "Foo",
|
||||
},
|
||||
"namespace trimmed": {
|
||||
columnName: "namespace",
|
||||
resource: &fakeResource{
|
||||
resourceStatus: &pe.ResourceStatus{
|
||||
Identifier: object.ObjMetadata{
|
||||
Namespace: "ICanHearTheHeartBeatingAsOne",
|
||||
},
|
||||
},
|
||||
},
|
||||
columnWidth: 10,
|
||||
expectedOutput: "ICanHearTh",
|
||||
},
|
||||
"resource": {
|
||||
columnName: "resource",
|
||||
resource: &fakeResource{
|
||||
resourceStatus: &pe.ResourceStatus{
|
||||
Identifier: object.ObjMetadata{
|
||||
Name: "YoLaTengo",
|
||||
GroupKind: schema.GroupKind{
|
||||
Kind: "RoleBinding",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
columnWidth: 40,
|
||||
expectedOutput: "RoleBinding/YoLaTengo",
|
||||
},
|
||||
"resource trimmed": {
|
||||
columnName: "resource",
|
||||
resource: &fakeResource{
|
||||
resourceStatus: &pe.ResourceStatus{
|
||||
Identifier: object.ObjMetadata{
|
||||
Name: "SlantedAndEnchanted",
|
||||
GroupKind: schema.GroupKind{
|
||||
Kind: "Pavement",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
columnWidth: 25,
|
||||
expectedOutput: "Pavement/SlantedAndEnchan",
|
||||
},
|
||||
"status with color": {
|
||||
columnName: "status",
|
||||
resource: &fakeResource{
|
||||
resourceStatus: &pe.ResourceStatus{
|
||||
Status: status.CurrentStatus,
|
||||
},
|
||||
},
|
||||
columnWidth: 10,
|
||||
expectedOutput: "\x1b[32mCurrent\x1b[0m",
|
||||
},
|
||||
"status trimmed": {
|
||||
columnName: "status",
|
||||
resource: &fakeResource{
|
||||
resourceStatus: &pe.ResourceStatus{
|
||||
Status: status.NotFoundStatus,
|
||||
},
|
||||
},
|
||||
columnWidth: 5,
|
||||
expectedOutput: "NotFo",
|
||||
},
|
||||
"conditions with color": {
|
||||
columnName: "conditions",
|
||||
resource: &fakeResource{
|
||||
resourceStatus: &pe.ResourceStatus{
|
||||
Resource: mustResourceWithConditions([]condition{
|
||||
{
|
||||
Type: "Ready",
|
||||
Status: v1.ConditionUnknown,
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
columnWidth: 10,
|
||||
expectedOutput: "\x1b[33mReady\x1b[0m",
|
||||
},
|
||||
"conditions trimmed": {
|
||||
columnName: "conditions",
|
||||
resource: &fakeResource{
|
||||
resourceStatus: &pe.ResourceStatus{
|
||||
Resource: mustResourceWithConditions([]condition{
|
||||
{
|
||||
Type: "Ready",
|
||||
Status: v1.ConditionTrue,
|
||||
},
|
||||
{
|
||||
Type: "Reconciling",
|
||||
Status: v1.ConditionFalse,
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
columnWidth: 10,
|
||||
expectedOutput: "\x1b[32mReady\x1b[0m,\x1b[31mReco\x1b[0m",
|
||||
},
|
||||
"age not found": {
|
||||
columnName: "age",
|
||||
resource: &fakeResource{
|
||||
resourceStatus: &pe.ResourceStatus{
|
||||
Resource: &unstructured.Unstructured{},
|
||||
},
|
||||
},
|
||||
columnWidth: 10,
|
||||
expectedOutput: "-",
|
||||
},
|
||||
"age": {
|
||||
columnName: "age",
|
||||
resource: &fakeResource{
|
||||
resourceStatus: &pe.ResourceStatus{
|
||||
Resource: mustResourceWithCreationTimestamp(45 * time.Minute),
|
||||
},
|
||||
},
|
||||
columnWidth: 10,
|
||||
expectedOutput: "45m",
|
||||
},
|
||||
"message without error": {
|
||||
columnName: "message",
|
||||
resource: &fakeResource{
|
||||
resourceStatus: &pe.ResourceStatus{
|
||||
Message: "this is a test",
|
||||
},
|
||||
},
|
||||
columnWidth: 30,
|
||||
expectedOutput: "this is a test",
|
||||
},
|
||||
"message from error": {
|
||||
columnName: "message",
|
||||
resource: &fakeResource{
|
||||
resourceStatus: &pe.ResourceStatus{
|
||||
Message: "this is a test",
|
||||
Error: fmt.Errorf("something went wrong somewhere"),
|
||||
},
|
||||
},
|
||||
columnWidth: 50,
|
||||
expectedOutput: "something went wrong somewhere",
|
||||
},
|
||||
"message trimmed": {
|
||||
columnName: "message",
|
||||
resource: &fakeResource{
|
||||
resourceStatus: &pe.ResourceStatus{
|
||||
Message: "this is a test",
|
||||
},
|
||||
},
|
||||
columnWidth: 6,
|
||||
expectedOutput: "this i",
|
||||
},
|
||||
}
|
||||
|
||||
for tn, tc := range testCases {
|
||||
t.Run(tn, func(t *testing.T) {
|
||||
columnDef := MustColumn(tc.columnName)
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, err := columnDef.PrintResource(&buf, tc.columnWidth, tc.resource)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
if want, got := tc.expectedOutput, buf.String(); want != got {
|
||||
t.Errorf("expected %q, but got %q", want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type condition struct {
|
||||
Type string
|
||||
Status v1.ConditionStatus
|
||||
}
|
||||
|
||||
func mustResourceWithConditions(conditions []condition) *unstructured.Unstructured {
|
||||
u := &unstructured.Unstructured{
|
||||
Object: make(map[string]interface{}),
|
||||
}
|
||||
var conditionsSlice []interface{}
|
||||
for _, c := range conditions {
|
||||
cond := make(map[string]interface{})
|
||||
cond["type"] = c.Type
|
||||
cond["status"] = string(c.Status)
|
||||
conditionsSlice = append(conditionsSlice, cond)
|
||||
}
|
||||
err := unstructured.SetNestedSlice(u.Object, conditionsSlice,
|
||||
"status", "conditions")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func mustResourceWithCreationTimestamp(age time.Duration) *unstructured.Unstructured {
|
||||
u := &unstructured.Unstructured{
|
||||
Object: make(map[string]interface{}),
|
||||
}
|
||||
creationTime := time.Now().Add(-age)
|
||||
u.SetCreationTimestamp(metav1.Time{
|
||||
Time: creationTime,
|
||||
})
|
||||
return u
|
||||
}
|
||||
Loading…
Reference in New Issue