瀏覽代碼

cscli: new tables, --color yes|no|auto option (#1763)

mmetc 2 年之前
父節點
當前提交
ddd75eae9a

+ 10 - 79
cmd/crowdsec-cli/alerts.go

@@ -7,21 +7,20 @@ import (
 	"fmt"
 	"net/url"
 	"os"
-	"sort"
 	"strconv"
 	"strings"
-	"time"
 
-	"github.com/crowdsecurity/crowdsec/pkg/apiclient"
-	"github.com/crowdsecurity/crowdsec/pkg/cwversion"
-	"github.com/crowdsecurity/crowdsec/pkg/database"
-	"github.com/crowdsecurity/crowdsec/pkg/models"
 	"github.com/go-openapi/strfmt"
-	"github.com/olekukonko/tablewriter"
+	colorable "github.com/mattn/go-colorable"
 	"github.com/pkg/errors"
 	log "github.com/sirupsen/logrus"
 	"github.com/spf13/cobra"
 	"gopkg.in/yaml.v2"
+
+	"github.com/crowdsecurity/crowdsec/pkg/apiclient"
+	"github.com/crowdsecurity/crowdsec/pkg/cwversion"
+	"github.com/crowdsecurity/crowdsec/pkg/database"
+	"github.com/crowdsecurity/crowdsec/pkg/models"
 )
 
 var printMachine bool
@@ -83,39 +82,11 @@ func AlertsToTable(alerts *models.GetAlertsResponse, printMachine bool) error {
 		x, _ := json.MarshalIndent(alerts, "", " ")
 		fmt.Printf("%s", string(x))
 	} else if csConfig.Cscli.Output == "human" {
-
-		table := tablewriter.NewWriter(os.Stdout)
-		header := []string{"ID", "value", "reason", "country", "as", "decisions", "created_at"}
-		if printMachine {
-			header = append(header, "machine")
-		}
-		table.SetHeader(header)
-
 		if len(*alerts) == 0 {
 			fmt.Println("No active alerts")
 			return nil
 		}
-		for _, alertItem := range *alerts {
-
-			displayVal := *alertItem.Source.Scope
-			if *alertItem.Source.Value != "" {
-				displayVal += ":" + *alertItem.Source.Value
-			}
-			row := []string{
-				strconv.Itoa(int(alertItem.ID)),
-				displayVal,
-				*alertItem.Scenario,
-				alertItem.Source.Cn,
-				alertItem.Source.AsNumber + " " + alertItem.Source.AsName,
-				DecisionsFromAlert(alertItem),
-				*alertItem.StartAt,
-			}
-			if printMachine {
-				row = append(row, alertItem.MachineID)
-			}
-			table.Append(row)
-		}
-		table.Render() // Send output
+		alertsTable(colorable.NewColorableStdout(), alerts, printMachine)
 	}
 	return nil
 }
@@ -138,53 +109,13 @@ func DisplayOneAlert(alert *models.Alert, withDetail bool) error {
 		fmt.Printf(" - AS         : %s\n", alert.Source.AsName)
 		fmt.Printf(" - Begin      : %s\n", *alert.StartAt)
 		fmt.Printf(" - End        : %s\n\n", *alert.StopAt)
-		foundActive := false
-		table := tablewriter.NewWriter(os.Stdout)
-		table.SetHeader([]string{"ID", "scope:value", "action", "expiration", "created_at"})
-		for _, decision := range alert.Decisions {
-			parsedDuration, err := time.ParseDuration(*decision.Duration)
-			if err != nil {
-				log.Errorf(err.Error())
-			}
-			expire := time.Now().UTC().Add(parsedDuration)
-			if time.Now().UTC().After(expire) {
-				continue
-			}
-			foundActive = true
-			scopeAndValue := *decision.Scope
-			if *decision.Value != "" {
-				scopeAndValue += ":" + *decision.Value
-			}
-			table.Append([]string{
-				strconv.Itoa(int(decision.ID)),
-				scopeAndValue,
-				*decision.Type,
-				*decision.Duration,
-				alert.CreatedAt,
-			})
-		}
-		if foundActive {
-			fmt.Printf(" - Active Decisions  :\n")
-			table.Render() // Send output
-		}
+
+		alertDecisionsTable(colorable.NewColorableStdout(), alert)
 
 		if withDetail {
 			fmt.Printf("\n - Events  :\n")
 			for _, event := range alert.Events {
-				fmt.Printf("\n- Date: %s\n", *event.Timestamp)
-				table = tablewriter.NewWriter(os.Stdout)
-				table.SetHeader([]string{"Key", "Value"})
-				sort.Slice(event.Meta, func(i, j int) bool {
-					return event.Meta[i].Key < event.Meta[j].Key
-				})
-				for _, meta := range event.Meta {
-					table.Append([]string{
-						meta.Key,
-						meta.Value,
-					})
-				}
-
-				table.Render() // Send output
+				alertEventTable(colorable.NewColorableStdout(), event)
 			}
 		}
 	}

+ 100 - 0
cmd/crowdsec-cli/alerts_table.go

@@ -0,0 +1,100 @@
+package main
+
+import (
+	"fmt"
+	"io"
+	"sort"
+	"strconv"
+	"time"
+
+	log "github.com/sirupsen/logrus"
+
+	"github.com/crowdsecurity/crowdsec/pkg/models"
+)
+
+func alertsTable(out io.Writer, alerts *models.GetAlertsResponse, printMachine bool) {
+	t := newTable(out)
+	t.SetRowLines(false)
+	header := []string{"ID", "value", "reason", "country", "as", "decisions", "created_at"}
+	if printMachine {
+		header = append(header, "machine")
+	}
+	t.SetHeaders(header...)
+
+	for _, alertItem := range *alerts {
+		displayVal := *alertItem.Source.Scope
+		if *alertItem.Source.Value != "" {
+			displayVal += ":" + *alertItem.Source.Value
+		}
+
+		row := []string{
+			strconv.Itoa(int(alertItem.ID)),
+			displayVal,
+			*alertItem.Scenario,
+			alertItem.Source.Cn,
+			alertItem.Source.AsNumber + " " + alertItem.Source.AsName,
+			DecisionsFromAlert(alertItem),
+			*alertItem.StartAt,
+		}
+
+		if printMachine {
+			row = append(row, alertItem.MachineID)
+		}
+
+		t.AddRow(row...)
+	}
+
+	t.Render()
+}
+
+func alertDecisionsTable(out io.Writer, alert *models.Alert) {
+	foundActive := false
+	t := newTable(out)
+	t.SetRowLines(false)
+	t.SetHeaders("ID", "scope:value", "action", "expiration", "created_at")
+	for _, decision := range alert.Decisions {
+		parsedDuration, err := time.ParseDuration(*decision.Duration)
+		if err != nil {
+			log.Errorf(err.Error())
+		}
+		expire := time.Now().UTC().Add(parsedDuration)
+		if time.Now().UTC().After(expire) {
+			continue
+		}
+		foundActive = true
+		scopeAndValue := *decision.Scope
+		if *decision.Value != "" {
+			scopeAndValue += ":" + *decision.Value
+		}
+		t.AddRow(
+			strconv.Itoa(int(decision.ID)),
+			scopeAndValue,
+			*decision.Type,
+			*decision.Duration,
+			alert.CreatedAt,
+		)
+	}
+	if foundActive {
+		fmt.Printf(" - Active Decisions  :\n")
+		t.Render() // Send output
+	}
+}
+
+func alertEventTable(out io.Writer, event *models.Event) {
+	fmt.Fprintf(out, "\n- Date: %s\n", *event.Timestamp)
+
+	t := newTable(out)
+	t.SetHeaders("Key", "Value")
+	sort.Slice(event.Meta, func(i, j int) bool {
+		return event.Meta[i].Key < event.Meta[j].Key
+	})
+
+	for _, meta := range event.Meta {
+		t.AddRow(
+			meta.Key,
+			meta.Value,
+		)
+	}
+
+	t.Render() // Send output
+}

+ 19 - 37
cmd/crowdsec-cli/bouncers.go

@@ -1,62 +1,45 @@
 package main
 
 import (
-	"bytes"
 	"encoding/csv"
 	"encoding/json"
 	"fmt"
+	"io"
 	"time"
 
-	middlewares "github.com/crowdsecurity/crowdsec/pkg/apiserver/middlewares/v1"
-	"github.com/crowdsecurity/crowdsec/pkg/database"
-	"github.com/crowdsecurity/crowdsec/pkg/types"
-	"github.com/enescakir/emoji"
-	"github.com/olekukonko/tablewriter"
+	colorable "github.com/mattn/go-colorable"
 	"github.com/pkg/errors"
 	log "github.com/sirupsen/logrus"
 	"github.com/spf13/cobra"
+
+	middlewares "github.com/crowdsecurity/crowdsec/pkg/apiserver/middlewares/v1"
+	"github.com/crowdsecurity/crowdsec/pkg/database"
+	"github.com/crowdsecurity/crowdsec/pkg/types"
 )
 
 var keyIP string
 var keyLength int
 var key string
 
-func getBouncers(dbClient *database.Client) ([]byte, error) {
+func getBouncers(out io.Writer, dbClient *database.Client) error {
 	bouncers, err := dbClient.ListBouncers()
-	w := bytes.NewBuffer(nil)
 	if err != nil {
-		return nil, fmt.Errorf("unable to list bouncers: %s", err)
+		return fmt.Errorf("unable to list bouncers: %s", err)
 	}
 	if csConfig.Cscli.Output == "human" {
-
-		table := tablewriter.NewWriter(w)
-		table.SetCenterSeparator("")
-		table.SetColumnSeparator("")
-
-		table.SetHeaderAlignment(tablewriter.ALIGN_LEFT)
-		table.SetAlignment(tablewriter.ALIGN_LEFT)
-		table.SetHeader([]string{"Name", "IP Address", "Valid", "Last API pull", "Type", "Version", "Auth Type"})
-		for _, b := range bouncers {
-			var revoked string
-			if !b.Revoked {
-				revoked = emoji.CheckMark.String()
-			} else {
-				revoked = emoji.Prohibited.String()
-			}
-			table.Append([]string{b.Name, b.IPAddress, revoked, b.LastPull.Format(time.RFC3339), b.Type, b.Version, b.AuthType})
-		}
-		table.Render()
+		getBouncersTable(out, bouncers)
 	} else if csConfig.Cscli.Output == "json" {
-		x, err := json.MarshalIndent(bouncers, "", " ")
-		if err != nil {
-			return nil, errors.Wrap(err, "failed to unmarshal")
+		enc := json.NewEncoder(out)
+		enc.SetIndent("", "  ")
+		if err := enc.Encode(bouncers); err != nil {
+			return errors.Wrap(err, "failed to unmarshal")
 		}
-		return x, nil
+		return nil
 	} else if csConfig.Cscli.Output == "raw" {
-		csvwriter := csv.NewWriter(w)
+		csvwriter := csv.NewWriter(out)
 		err := csvwriter.Write([]string{"name", "ip", "revoked", "last_pull", "type", "version", "auth_type"})
 		if err != nil {
-			return nil, errors.Wrap(err, "failed to write raw header")
+			return errors.Wrap(err, "failed to write raw header")
 		}
 		for _, b := range bouncers {
 			var revoked string
@@ -67,12 +50,12 @@ func getBouncers(dbClient *database.Client) ([]byte, error) {
 			}
 			err := csvwriter.Write([]string{b.Name, b.IPAddress, revoked, b.LastPull.Format(time.RFC3339), b.Type, b.Version, b.AuthType})
 			if err != nil {
-				return nil, errors.Wrap(err, "failed to write raw")
+				return errors.Wrap(err, "failed to write raw")
 			}
 		}
 		csvwriter.Flush()
 	}
-	return w.Bytes(), nil
+	return nil
 }
 
 func NewBouncersCmd() *cobra.Command {
@@ -109,11 +92,10 @@ Note: This command requires database direct access, so is intended to be run on
 		Args:              cobra.ExactArgs(0),
 		DisableAutoGenTag: true,
 		Run: func(cmd *cobra.Command, arg []string) {
-			bouncers, err := getBouncers(dbClient)
+			err := getBouncers(colorable.NewColorableStdout(), dbClient)
 			if err != nil {
 				log.Fatalf("unable to list bouncers: %s", err)
 			}
-			fmt.Printf("%s", bouncers)
 		},
 	}
 	cmdBouncers.AddCommand(cmdBouncersList)

+ 31 - 0
cmd/crowdsec-cli/bouncers_table.go

@@ -0,0 +1,31 @@
+package main
+
+import (
+	"io"
+	"time"
+
+	"github.com/aquasecurity/table"
+	"github.com/enescakir/emoji"
+
+	"github.com/crowdsecurity/crowdsec/pkg/database/ent"
+)
+
+func getBouncersTable(out io.Writer, bouncers []*ent.Bouncer) {
+	t := newLightTable(out)
+	t.SetHeaders("Name", "IP Address", "Valid", "Last API pull", "Type", "Version", "Auth Type")
+	t.SetHeaderAlignment(table.AlignLeft, table.AlignLeft, table.AlignLeft, table.AlignLeft, table.AlignLeft, table.AlignLeft)
+	t.SetAlignment(table.AlignLeft, table.AlignLeft, table.AlignLeft, table.AlignLeft, table.AlignLeft, table.AlignLeft)
+
+	for _, b := range bouncers {
+		var revoked string
+		if !b.Revoked {
+			revoked = emoji.CheckMark.String()
+		} else {
+			revoked = emoji.Prohibited.String()
+		}
+
+		t.AddRow(b.Name, b.IPAddress, revoked, b.LastPull.Format(time.RFC3339), b.Type, b.Version, b.AuthType)
+	}
+
+	t.Render()
+}

+ 4 - 5
cmd/crowdsec-cli/collections.go

@@ -3,11 +3,11 @@ package main
 import (
 	"fmt"
 
-	"github.com/crowdsecurity/crowdsec/pkg/cwhub"
-
+	colorable "github.com/mattn/go-colorable"
 	log "github.com/sirupsen/logrus"
-
 	"github.com/spf13/cobra"
+
+	"github.com/crowdsecurity/crowdsec/pkg/cwhub"
 )
 
 func NewCollectionsCmd() *cobra.Command {
@@ -173,8 +173,7 @@ func NewCollectionsCmd() *cobra.Command {
 		Args:              cobra.ExactArgs(0),
 		DisableAutoGenTag: true,
 		Run: func(cmd *cobra.Command, args []string) {
-			items := ListItems([]string{cwhub.COLLECTIONS}, args, false, true, all)
-			fmt.Printf("%s\n", string(items))
+			ListItems(colorable.NewColorableStdout(), []string{cwhub.COLLECTIONS}, args, false, true, all)
 		},
 	}
 	cmdCollectionsList.PersistentFlags().BoolVarP(&all, "all", "a", false, "List disabled items as well")

+ 6 - 33
cmd/crowdsec-cli/console.go

@@ -10,16 +10,16 @@ import (
 	"net/url"
 	"os"
 
+	"github.com/go-openapi/strfmt"
+	colorable "github.com/mattn/go-colorable"
+	log "github.com/sirupsen/logrus"
+	"github.com/spf13/cobra"
+
 	"github.com/crowdsecurity/crowdsec/pkg/apiclient"
 	"github.com/crowdsecurity/crowdsec/pkg/csconfig"
 	"github.com/crowdsecurity/crowdsec/pkg/cwhub"
 	"github.com/crowdsecurity/crowdsec/pkg/cwversion"
 	"github.com/crowdsecurity/crowdsec/pkg/types"
-	"github.com/enescakir/emoji"
-	"github.com/go-openapi/strfmt"
-	"github.com/olekukonko/tablewriter"
-	log "github.com/sirupsen/logrus"
-	"github.com/spf13/cobra"
 )
 
 func NewConsoleCmd() *cobra.Command {
@@ -194,34 +194,7 @@ Disable given information push to the central API.`,
 		Run: func(cmd *cobra.Command, args []string) {
 			switch csConfig.Cscli.Output {
 			case "human":
-				table := tablewriter.NewWriter(os.Stdout)
-
-				table.SetHeaderAlignment(tablewriter.ALIGN_LEFT)
-				table.SetAlignment(tablewriter.ALIGN_LEFT)
-				table.SetHeader([]string{"Option Name", "Activated", "Description"})
-				for _, option := range csconfig.CONSOLE_CONFIGS {
-					switch option {
-					case csconfig.SEND_CUSTOM_SCENARIOS:
-						activated := string(emoji.CrossMark)
-						if *csConfig.API.Server.ConsoleConfig.ShareCustomScenarios {
-							activated = string(emoji.CheckMarkButton)
-						}
-						table.Append([]string{option, activated, "Send alerts from custom scenarios to the console"})
-					case csconfig.SEND_MANUAL_SCENARIOS:
-						activated := string(emoji.CrossMark)
-						if *csConfig.API.Server.ConsoleConfig.ShareManualDecisions {
-							activated = string(emoji.CheckMarkButton)
-						}
-						table.Append([]string{option, activated, "Send manual decisions to the console"})
-					case csconfig.SEND_TAINTED_SCENARIOS:
-						activated := string(emoji.CrossMark)
-						if *csConfig.API.Server.ConsoleConfig.ShareTaintedScenarios {
-							activated = string(emoji.CheckMarkButton)
-						}
-						table.Append([]string{option, activated, "Send alerts from tainted scenarios to the console"})
-					}
-				}
-				table.Render()
+				cmdConsoleStatusTable(colorable.NewColorableStdout(), *csConfig)
 			case "json":
 				data, err := json.MarshalIndent(csConfig.API.Server.ConsoleConfig, "", "  ")
 				if err != nil {

+ 48 - 0
cmd/crowdsec-cli/console_table.go

@@ -0,0 +1,48 @@
+package main
+
+import (
+	"io"
+
+	"github.com/aquasecurity/table"
+	"github.com/enescakir/emoji"
+
+	"github.com/crowdsecurity/crowdsec/pkg/csconfig"
+)
+
+func cmdConsoleStatusTable(out io.Writer, csConfig csconfig.Config) {
+	t := newTable(out)
+	t.SetRowLines(false)
+
+	t.SetHeaders("Option Name", "Activated", "Description")
+	t.SetHeaderAlignment(table.AlignLeft, table.AlignLeft, table.AlignLeft)
+
+	for _, option := range csconfig.CONSOLE_CONFIGS {
+		switch option {
+		case csconfig.SEND_CUSTOM_SCENARIOS:
+			activated := string(emoji.CrossMark)
+			if *csConfig.API.Server.ConsoleConfig.ShareCustomScenarios {
+				activated = string(emoji.CheckMarkButton)
+			}
+
+			t.AddRow(option, activated, "Send alerts from custom scenarios to the console")
+
+		case csconfig.SEND_MANUAL_SCENARIOS:
+			activated := string(emoji.CrossMark)
+			if *csConfig.API.Server.ConsoleConfig.ShareManualDecisions {
+				activated = string(emoji.CheckMarkButton)
+			}
+
+			t.AddRow(option, activated, "Send manual decisions to the console")
+
+		case csconfig.SEND_TAINTED_SCENARIOS:
+			activated := string(emoji.CrossMark)
+			if *csConfig.API.Server.ConsoleConfig.ShareTaintedScenarios {
+				activated = string(emoji.CheckMarkButton)
+			}
+
+			t.AddRow(option, activated, "Send alerts from tainted scenarios to the console")
+		}
+	}
+
+	t.Render()
+}

+ 7 - 39
cmd/crowdsec-cli/decisions.go

@@ -12,16 +12,17 @@ import (
 	"strings"
 	"time"
 
-	"github.com/crowdsecurity/crowdsec/pkg/apiclient"
-	"github.com/crowdsecurity/crowdsec/pkg/cwversion"
-	"github.com/crowdsecurity/crowdsec/pkg/models"
-	"github.com/crowdsecurity/crowdsec/pkg/types"
 	"github.com/go-openapi/strfmt"
 	"github.com/jszwec/csvutil"
-	"github.com/olekukonko/tablewriter"
+	colorable "github.com/mattn/go-colorable"
 	"github.com/pkg/errors"
 	log "github.com/sirupsen/logrus"
 	"github.com/spf13/cobra"
+
+	"github.com/crowdsecurity/crowdsec/pkg/apiclient"
+	"github.com/crowdsecurity/crowdsec/pkg/cwversion"
+	"github.com/crowdsecurity/crowdsec/pkg/models"
+	"github.com/crowdsecurity/crowdsec/pkg/types"
 )
 
 var Client *apiclient.ApiClient
@@ -92,44 +93,11 @@ func DecisionsToTable(alerts *models.GetAlertsResponse, printMachine bool) error
 		x, _ := json.MarshalIndent(alerts, "", " ")
 		fmt.Printf("%s", string(x))
 	} else if csConfig.Cscli.Output == "human" {
-		table := tablewriter.NewWriter(os.Stdout)
-		header := []string{"ID", "Source", "Scope:Value", "Reason", "Action", "Country", "AS", "Events", "expiration", "Alert ID"}
-		if printMachine {
-			header = append(header, "Machine")
-		}
-		table.SetHeader(header)
-
 		if len(*alerts) == 0 {
 			fmt.Println("No active decisions")
 			return nil
 		}
-
-		for _, alertItem := range *alerts {
-			for _, decisionItem := range alertItem.Decisions {
-				if *alertItem.Simulated {
-					*decisionItem.Type = fmt.Sprintf("(simul)%s", *decisionItem.Type)
-				}
-				raw := []string{
-					strconv.Itoa(int(decisionItem.ID)),
-					*decisionItem.Origin,
-					*decisionItem.Scope + ":" + *decisionItem.Value,
-					*decisionItem.Scenario,
-					*decisionItem.Type,
-					alertItem.Source.Cn,
-					alertItem.Source.AsNumber + " " + alertItem.Source.AsName,
-					strconv.Itoa(int(*alertItem.EventsCount)),
-					*decisionItem.Duration,
-					strconv.Itoa(int(alertItem.ID)),
-				}
-
-				if printMachine {
-					raw = append(raw, alertItem.MachineID)
-				}
-
-				table.Append(raw)
-			}
-		}
-		table.Render() // Send output
+		decisionsTable(colorable.NewColorableStdout(), alerts, printMachine)
 		if skipped > 0 {
 			fmt.Printf("%d duplicated entries skipped\n", skipped)
 		}

+ 46 - 0
cmd/crowdsec-cli/decisions_table.go

@@ -0,0 +1,46 @@
+package main
+
+import (
+	"fmt"
+	"io"
+	"strconv"
+
+	"github.com/crowdsecurity/crowdsec/pkg/models"
+)
+
+func decisionsTable(out io.Writer, alerts *models.GetAlertsResponse, printMachine bool) {
+	t := newTable(out)
+	t.SetRowLines(false)
+	header := []string{"ID", "Source", "Scope:Value", "Reason", "Action", "Country", "AS", "Events", "expiration", "Alert ID"}
+	if printMachine {
+		header = append(header, "Machine")
+	}
+	t.SetHeaders(header...)
+
+	for _, alertItem := range *alerts {
+		for _, decisionItem := range alertItem.Decisions {
+			if *alertItem.Simulated {
+				*decisionItem.Type = fmt.Sprintf("(simul)%s", *decisionItem.Type)
+			}
+			row := []string{
+				strconv.Itoa(int(decisionItem.ID)),
+				*decisionItem.Origin,
+				*decisionItem.Scope + ":" + *decisionItem.Value,
+				*decisionItem.Scenario,
+				*decisionItem.Type,
+				alertItem.Source.Cn,
+				alertItem.Source.AsNumber + " " + alertItem.Source.AsName,
+				strconv.Itoa(int(*alertItem.EventsCount)),
+				*decisionItem.Duration,
+				strconv.Itoa(int(alertItem.ID)),
+			}
+
+			if printMachine {
+				row = append(row, alertItem.MachineID)
+			}
+
+			t.AddRow(row...)
+		}
+	}
+	t.Render()
+}

+ 4 - 4
cmd/crowdsec-cli/hub.go

@@ -3,10 +3,11 @@ package main
 import (
 	"fmt"
 
-	"github.com/crowdsecurity/crowdsec/pkg/cwhub"
-
+	colorable "github.com/mattn/go-colorable"
 	log "github.com/sirupsen/logrus"
 	"github.com/spf13/cobra"
+
+	"github.com/crowdsecurity/crowdsec/pkg/cwhub"
 )
 
 func NewHubCmd() *cobra.Command {
@@ -56,10 +57,9 @@ cscli hub update # Download list of available configurations from the hub
 				log.Info(v)
 			}
 			cwhub.DisplaySummary()
-			items := ListItems([]string{
+			ListItems(colorable.NewColorableStdout(), []string{
 				cwhub.COLLECTIONS, cwhub.PARSERS, cwhub.SCENARIOS, cwhub.PARSERS_OVFLW,
 			}, args, true, false, all)
-			fmt.Printf("%s\n", items)
 		},
 	}
 	cmdHubList.PersistentFlags().BoolVarP(&all, "all", "a", false, "List disabled items as well")

+ 19 - 67
cmd/crowdsec-cli/hubtest.go

@@ -9,12 +9,13 @@ import (
 	"strings"
 
 	"github.com/AlecAivazis/survey/v2"
-	"github.com/crowdsecurity/crowdsec/pkg/cstest"
 	"github.com/enescakir/emoji"
-	"github.com/olekukonko/tablewriter"
+	colorable "github.com/mattn/go-colorable"
 	log "github.com/sirupsen/logrus"
 	"github.com/spf13/cobra"
 	"gopkg.in/yaml.v2"
+
+	"github.com/crowdsecurity/crowdsec/pkg/cstest"
 )
 
 var (
@@ -272,22 +273,7 @@ cscli hubtest create my-scenario-test --parsers crowdsecurity/nginx --scenarios
 				}
 			}
 			if csConfig.Cscli.Output == "human" {
-				table := tablewriter.NewWriter(os.Stdout)
-				table.SetCenterSeparator("")
-				table.SetColumnSeparator("")
-
-				table.SetHeaderAlignment(tablewriter.ALIGN_LEFT)
-				table.SetAlignment(tablewriter.ALIGN_LEFT)
-
-				table.SetHeader([]string{"Test", "Result"})
-				for testName, success := range testResult {
-					status := emoji.CheckMarkButton.String()
-					if !success {
-						status = emoji.CrossMark.String()
-					}
-					table.Append([]string{testName, status})
-				}
-				table.Render()
+				hubTestResultTable(colorable.NewColorableStdout(), testResult)
 			} else if csConfig.Cscli.Output == "json" {
 				jsonResult := make(map[string][]string, 0)
 				jsonResult["success"] = make([]string, 0)
@@ -367,18 +353,18 @@ cscli hubtest create my-scenario-test --parsers crowdsecurity/nginx --scenarios
 				log.Fatalf("unable to load all tests: %+v", err)
 			}
 
-			table := tablewriter.NewWriter(os.Stdout)
-			table.SetCenterSeparator("")
-			table.SetColumnSeparator("")
-
-			table.SetHeaderAlignment(tablewriter.ALIGN_LEFT)
-			table.SetAlignment(tablewriter.ALIGN_LEFT)
-			table.SetHeader([]string{"Name", "Path"})
-			for _, test := range HubTest.Tests {
-				table.Append([]string{test.Name, test.Path})
+			switch csConfig.Cscli.Output {
+			case "human":
+				hubTestListTable(colorable.NewColorableStdout(), HubTest.Tests)
+			case "json":
+				j, err := json.MarshalIndent(HubTest.Tests, " ", "  ")
+				if err != nil {
+					log.Fatal(err)
+				}
+				fmt.Println(string(j))
+			default:
+				log.Fatalf("only human/json output modes are supported")
 			}
-			table.Render()
-
 		},
 	}
 	cmdHubTest.AddCommand(cmdHubTestList)
@@ -399,11 +385,9 @@ cscli hubtest create my-scenario-test --parsers crowdsecurity/nginx --scenarios
 			parserCoverage := []cstest.ParserCoverage{}
 			scenarioCoveragePercent := 0
 			parserCoveragePercent := 0
-			showAll := false
 
-			if !showScenarioCov && !showParserCov { // if both are false (flag by default), show both
-				showAll = true
-			}
+			// if both are false (flag by default), show both
+			showAll := !showScenarioCov && !showParserCov
 
 			if showParserCov || showAll {
 				parserCoverage, err = HubTest.GetParsersCoverage()
@@ -446,43 +430,11 @@ cscli hubtest create my-scenario-test --parsers crowdsecurity/nginx --scenarios
 
 			if csConfig.Cscli.Output == "human" {
 				if showParserCov || showAll {
-					table := tablewriter.NewWriter(os.Stdout)
-					table.SetCenterSeparator("")
-					table.SetColumnSeparator("")
-
-					table.SetHeaderAlignment(tablewriter.ALIGN_LEFT)
-					table.SetAlignment(tablewriter.ALIGN_LEFT)
-
-					table.SetHeader([]string{"Parser", "Status", "Number of tests"})
-					parserTested := 0
-					for _, test := range parserCoverage {
-						status := emoji.RedCircle.String()
-						if test.TestsCount > 0 {
-							status = emoji.GreenCircle.String()
-							parserTested += 1
-						}
-						table.Append([]string{test.Parser, status, fmt.Sprintf("%d times (across %d tests)", test.TestsCount, len(test.PresentIn))})
-					}
-					table.Render()
+					hubTestParserCoverageTable(colorable.NewColorableStdout(), parserCoverage)
 				}
 
 				if showScenarioCov || showAll {
-					table := tablewriter.NewWriter(os.Stdout)
-					table.SetCenterSeparator("")
-					table.SetColumnSeparator("")
-
-					table.SetHeaderAlignment(tablewriter.ALIGN_LEFT)
-					table.SetAlignment(tablewriter.ALIGN_LEFT)
-
-					table.SetHeader([]string{"Scenario", "Status", "Number of tests"})
-					for _, test := range scenarioCoverage {
-						status := emoji.RedCircle.String()
-						if test.TestsCount > 0 {
-							status = emoji.GreenCircle.String()
-						}
-						table.Append([]string{test.Scenario, status, fmt.Sprintf("%d times (across %d tests)", test.TestsCount, len(test.PresentIn))})
-					}
-					table.Render()
+					hubTestScenarioCoverageTable(colorable.NewColorableStdout(), scenarioCoverage)
 				}
 				fmt.Println()
 				if showParserCov || showAll {

+ 80 - 0
cmd/crowdsec-cli/hubtest_table.go

@@ -0,0 +1,80 @@
+package main
+
+import (
+	"fmt"
+	"io"
+
+	"github.com/aquasecurity/table"
+	"github.com/enescakir/emoji"
+
+	"github.com/crowdsecurity/crowdsec/pkg/cstest"
+)
+
+func hubTestResultTable(out io.Writer, testResult map[string]bool) {
+	t := newLightTable(out)
+	t.SetHeaders("Test", "Result")
+	t.SetHeaderAlignment(table.AlignLeft)
+	t.SetAlignment(table.AlignLeft)
+
+	for testName, success := range testResult {
+		status := emoji.CheckMarkButton.String()
+		if !success {
+			status = emoji.CrossMark.String()
+		}
+
+		t.AddRow(testName, status)
+	}
+
+	t.Render()
+}
+
+func hubTestListTable(out io.Writer, tests []*cstest.HubTestItem) {
+	t := newLightTable(out)
+	t.SetHeaders("Name", "Path")
+	t.SetHeaderAlignment(table.AlignLeft, table.AlignLeft)
+	t.SetAlignment(table.AlignLeft, table.AlignLeft)
+
+	for _, test := range tests {
+		t.AddRow(test.Name, test.Path)
+	}
+
+	t.Render()
+}
+
+func hubTestParserCoverageTable(out io.Writer, coverage []cstest.ParserCoverage) {
+	t := newLightTable(out)
+	t.SetHeaders("Parser", "Status", "Number of tests")
+	t.SetHeaderAlignment(table.AlignLeft, table.AlignLeft, table.AlignLeft)
+	t.SetAlignment(table.AlignLeft, table.AlignLeft, table.AlignLeft)
+
+	parserTested := 0
+	for _, test := range coverage {
+		status := emoji.RedCircle.String()
+		if test.TestsCount > 0 {
+			status = emoji.GreenCircle.String()
+			parserTested++
+		}
+		t.AddRow(test.Parser, status, fmt.Sprintf("%d times (across %d tests)", test.TestsCount, len(test.PresentIn)))
+	}
+
+	t.Render()
+}
+
+func hubTestScenarioCoverageTable(out io.Writer, coverage []cstest.ScenarioCoverage) {
+	t := newLightTable(out)
+	t.SetHeaders("Scenario", "Status", "Number of tests")
+	t.SetHeaderAlignment(table.AlignLeft, table.AlignLeft, table.AlignLeft)
+	t.SetAlignment(table.AlignLeft, table.AlignLeft, table.AlignLeft)
+
+	parserTested := 0
+	for _, test := range coverage {
+		status := emoji.RedCircle.String()
+		if test.TestsCount > 0 {
+			status = emoji.GreenCircle.String()
+			parserTested++
+		}
+		t.AddRow(test.Scenario, status, fmt.Sprintf("%d times (across %d tests)", test.TestsCount, len(test.PresentIn)))
+	}
+
+	t.Render()
+}

+ 22 - 38
cmd/crowdsec-cli/machines.go

@@ -1,30 +1,32 @@
 package main
 
 import (
-	"bytes"
 	saferand "crypto/rand"
 	"encoding/csv"
 	"encoding/json"
 	"fmt"
+	"io"
 	"math/big"
 	"os"
 	"strings"
 	"time"
 
 	"github.com/AlecAivazis/survey/v2"
-	"github.com/crowdsecurity/crowdsec/pkg/csconfig"
-	"github.com/crowdsecurity/crowdsec/pkg/database"
-	"github.com/crowdsecurity/crowdsec/pkg/database/ent"
-	"github.com/crowdsecurity/crowdsec/pkg/types"
-	"github.com/crowdsecurity/machineid"
 	"github.com/enescakir/emoji"
 	"github.com/go-openapi/strfmt"
 	"github.com/google/uuid"
-	"github.com/olekukonko/tablewriter"
+	colorable "github.com/mattn/go-colorable"
 	"github.com/pkg/errors"
 	log "github.com/sirupsen/logrus"
 	"github.com/spf13/cobra"
 	"gopkg.in/yaml.v2"
+
+	"github.com/crowdsecurity/machineid"
+
+	"github.com/crowdsecurity/crowdsec/pkg/csconfig"
+	"github.com/crowdsecurity/crowdsec/pkg/database"
+	"github.com/crowdsecurity/crowdsec/pkg/database/ent"
+	"github.com/crowdsecurity/crowdsec/pkg/types"
 )
 
 var machineID string
@@ -43,7 +45,6 @@ var (
 )
 
 func generatePassword(length int) string {
-
 	charset := upper + lower + digits
 	charsetLength := len(charset)
 
@@ -109,50 +110,34 @@ func displayLastHeartBeat(m *ent.Machine, fancy bool) string {
 	return hbDisplay
 }
 
-func getAgents(dbClient *database.Client) ([]byte, error) {
-	w := bytes.NewBuffer(nil)
+func getAgents(out io.Writer, dbClient *database.Client) error {
 	machines, err := dbClient.ListMachines()
 	if err != nil {
-		return nil, fmt.Errorf("unable to list machines: %s", err)
+		return fmt.Errorf("unable to list machines: %s", err)
 	}
 	if csConfig.Cscli.Output == "human" {
-		table := tablewriter.NewWriter(w)
-		table.SetCenterSeparator("")
-		table.SetColumnSeparator("")
-
-		table.SetHeaderAlignment(tablewriter.ALIGN_LEFT)
-		table.SetAlignment(tablewriter.ALIGN_LEFT)
-		table.SetHeader([]string{"Name", "IP Address", "Last Update", "Status", "Version", "Auth Type", "Last Heartbeat"})
-		for _, w := range machines {
-			var validated string
-			if w.IsValidated {
-				validated = emoji.CheckMark.String()
-			} else {
-				validated = emoji.Prohibited.String()
-			}
-			table.Append([]string{w.MachineId, w.IpAddress, w.UpdatedAt.Format(time.RFC3339), validated, w.Version, w.AuthType, displayLastHeartBeat(w, true)})
-		}
-		table.Render()
+		getAgentsTable(out, machines)
 	} else if csConfig.Cscli.Output == "json" {
-		x, err := json.MarshalIndent(machines, "", " ")
-		if err != nil {
+		enc := json.NewEncoder(out)
+		enc.SetIndent("", "  ")
+		if err := enc.Encode(machines); err != nil {
 			log.Fatalf("failed to unmarshal")
 		}
-		return x, nil
+		return nil
 	} else if csConfig.Cscli.Output == "raw" {
-		csvwriter := csv.NewWriter(w)
+		csvwriter := csv.NewWriter(out)
 		err := csvwriter.Write([]string{"machine_id", "ip_address", "updated_at", "validated", "version", "auth_type", "last_heartbeat"})
 		if err != nil {
 			log.Fatalf("failed to write header: %s", err)
 		}
-		for _, w := range machines {
+		for _, m := range machines {
 			var validated string
-			if w.IsValidated {
+			if m.IsValidated {
 				validated = "true"
 			} else {
 				validated = "false"
 			}
-			err := csvwriter.Write([]string{w.MachineId, w.IpAddress, w.UpdatedAt.Format(time.RFC3339), validated, w.Version, w.AuthType, displayLastHeartBeat(w, false)})
+			err := csvwriter.Write([]string{m.MachineId, m.IpAddress, m.UpdatedAt.Format(time.RFC3339), validated, m.Version, m.AuthType, displayLastHeartBeat(m, false)})
 			if err != nil {
 				log.Fatalf("failed to write raw output : %s", err)
 			}
@@ -161,7 +146,7 @@ func getAgents(dbClient *database.Client) ([]byte, error) {
 	} else {
 		log.Errorf("unknown output '%s'", csConfig.Cscli.Output)
 	}
-	return w.Bytes(), nil
+	return nil
 }
 
 func NewMachinesCmd() *cobra.Command {
@@ -204,11 +189,10 @@ Note: This command requires database direct access, so is intended to be run on
 			}
 		},
 		Run: func(cmd *cobra.Command, args []string) {
-			agents, err := getAgents(dbClient)
+			err := getAgents(colorable.NewColorableStdout(), dbClient)
 			if err != nil {
 				log.Fatalf("unable to list machines: %s", err)
 			}
-			fmt.Printf("%s\n", agents)
 		},
 	}
 	cmdMachines.AddCommand(cmdMachinesList)

+ 31 - 0
cmd/crowdsec-cli/machines_table.go

@@ -0,0 +1,31 @@
+package main
+
+import (
+	"io"
+	"time"
+
+	"github.com/aquasecurity/table"
+	"github.com/enescakir/emoji"
+
+	"github.com/crowdsecurity/crowdsec/pkg/database/ent"
+)
+
+func getAgentsTable(out io.Writer, machines []*ent.Machine) {
+	t := newLightTable(out)
+	t.SetHeaders("Name", "IP Address", "Last Update", "Status", "Version", "Auth Type", "Last Heartbeat")
+	t.SetHeaderAlignment(table.AlignLeft, table.AlignLeft, table.AlignLeft, table.AlignLeft, table.AlignLeft, table.AlignLeft, table.AlignLeft)
+	t.SetAlignment(table.AlignLeft, table.AlignLeft, table.AlignLeft, table.AlignLeft, table.AlignLeft, table.AlignLeft, table.AlignLeft)
+
+	for _, m := range machines {
+		var validated string
+		if m.IsValidated {
+			validated = emoji.CheckMark.String()
+		} else {
+			validated = emoji.Prohibited.String()
+		}
+
+		t.AddRow(m.MachineId, m.IpAddress, m.UpdatedAt.Format(time.RFC3339), validated, m.Version, m.AuthType, displayLastHeartBeat(m, true))
+	}
+
+	t.Render()
+}

+ 13 - 5
cmd/crowdsec-cli/main.go

@@ -8,14 +8,14 @@ import (
 	"strings"
 
 	"github.com/confluentinc/bincover"
+	log "github.com/sirupsen/logrus"
+	"github.com/spf13/cobra"
+	"github.com/spf13/cobra/doc"
+
 	"github.com/crowdsecurity/crowdsec/pkg/csconfig"
 	"github.com/crowdsecurity/crowdsec/pkg/cwhub"
 	"github.com/crowdsecurity/crowdsec/pkg/cwversion"
 	"github.com/crowdsecurity/crowdsec/pkg/database"
-
-	log "github.com/sirupsen/logrus"
-	"github.com/spf13/cobra"
-	"github.com/spf13/cobra/doc"
 )
 
 var bincoverTesting = ""
@@ -27,6 +27,7 @@ var csConfig *csconfig.Config
 var dbClient *database.Client
 
 var OutputFormat string
+var OutputColor string
 
 var downloadOnly bool
 var forceAction bool
@@ -88,6 +89,12 @@ func initConfig() {
 		log.SetLevel(log.ErrorLevel)
 	}
 
+	if OutputColor != "" {
+		csConfig.Cscli.Color = OutputColor
+		if OutputColor != "yes" && OutputColor != "no" && OutputColor != "auto" {
+			log.Fatalf("output color %s unknown", OutputColor)
+		}
+	}
 }
 
 var validArgs = []string{
@@ -159,7 +166,8 @@ It is meant to allow you to manage bans, parsers/scenarios/etc, api and generall
 	rootCmd.AddCommand(cmdVersion)
 
 	rootCmd.PersistentFlags().StringVarP(&ConfigFilePath, "config", "c", csconfig.DefaultConfigPath("config.yaml"), "path to crowdsec config file")
-	rootCmd.PersistentFlags().StringVarP(&OutputFormat, "output", "o", "", "Output format : human, json, raw.")
+	rootCmd.PersistentFlags().StringVarP(&OutputFormat, "output", "o", "", "Output format: human, json, raw.")
+	rootCmd.PersistentFlags().StringVarP(&OutputColor, "color", "", csconfig.ColorDefault(), "Output color: yes, no, auto.")
 	rootCmd.PersistentFlags().BoolVar(&dbg_lvl, "debug", false, "Set logging to debug.")
 	rootCmd.PersistentFlags().BoolVar(&nfo_lvl, "info", false, "Set logging to info.")
 	rootCmd.PersistentFlags().BoolVar(&wrn_lvl, "warning", false, "Set logging to warning.")

+ 25 - 229
cmd/crowdsec-cli/metrics.go

@@ -1,96 +1,27 @@
 package main
 
 import (
-	"bytes"
 	"encoding/json"
 	"fmt"
+	"io"
 	"net/http"
 	"os"
-	"sort"
 	"strconv"
 	"strings"
 	"time"
 
-	"github.com/crowdsecurity/crowdsec/pkg/types"
-	log "github.com/sirupsen/logrus"
-	"gopkg.in/yaml.v2"
-
-	"github.com/olekukonko/tablewriter"
+	colorable "github.com/mattn/go-colorable"
 	dto "github.com/prometheus/client_model/go"
 	"github.com/prometheus/prom2json"
+	log "github.com/sirupsen/logrus"
 	"github.com/spf13/cobra"
-)
-
-func lapiMetricsToTable(table *tablewriter.Table, stats map[string]map[string]map[string]int) error {
-
-	//stats : machine -> route -> method -> count
-	/*we want consistent display order*/
-	machineKeys := []string{}
-	for k := range stats {
-		machineKeys = append(machineKeys, k)
-	}
-	sort.Strings(machineKeys)
-
-	for _, machine := range machineKeys {
-		//oneRow : route -> method -> count
-		machineRow := stats[machine]
-		for routeName, route := range machineRow {
-			for methodName, count := range route {
-				row := []string{}
-				row = append(row, machine)
-				row = append(row, routeName)
-				row = append(row, methodName)
-				if count != 0 {
-					row = append(row, fmt.Sprintf("%d", count))
-				} else {
-					row = append(row, "-")
-				}
-				table.Append(row)
-			}
-		}
-	}
-	return nil
-}
-
-func metricsToTable(table *tablewriter.Table, stats map[string]map[string]int, keys []string) error {
-
-	var sortedKeys []string
+	"gopkg.in/yaml.v2"
 
-	if table == nil {
-		return fmt.Errorf("nil table")
-	}
-	//sort keys to keep consistent order when printing
-	sortedKeys = []string{}
-	for akey := range stats {
-		sortedKeys = append(sortedKeys, akey)
-	}
-	sort.Strings(sortedKeys)
-	//
-	for _, alabel := range sortedKeys {
-		astats, ok := stats[alabel]
-		if !ok {
-			continue
-		}
-		row := []string{}
-		row = append(row, alabel) //name
-		for _, sl := range keys {
-			if v, ok := astats[sl]; ok && v != 0 {
-				numberToShow := fmt.Sprintf("%d", v)
-				if !noUnit {
-					numberToShow = formatNumber(v)
-				}
-				row = append(row, numberToShow)
-			} else {
-				row = append(row, "-")
-			}
-		}
-		table.Append(row)
-	}
-	return nil
-}
+	"github.com/crowdsecurity/crowdsec/pkg/types"
+)
 
-/*This is a complete rip from prom2json*/
-func FormatPrometheusMetric(url string, formatType string) ([]byte, error) {
+// FormatPrometheusMetrics is a complete rip from prom2json
+func FormatPrometheusMetrics(out io.Writer, url string, formatType string) error {
 	mfChan := make(chan *dto.MetricFamily, 1024)
 
 	// Start with the DefaultTransport for sane defaults.
@@ -284,171 +215,37 @@ func FormatPrometheusMetric(url string, formatType string) ([]byte, error) {
 		}
 	}
 
-	ret := bytes.NewBuffer(nil)
-
 	if formatType == "human" {
-
-		acquisTable := tablewriter.NewWriter(ret)
-		acquisTable.SetHeader([]string{"Source", "Lines read", "Lines parsed", "Lines unparsed", "Lines poured to bucket"})
-		keys := []string{"reads", "parsed", "unparsed", "pour"}
-		if err := metricsToTable(acquisTable, acquis_stats, keys); err != nil {
-			log.Warningf("while collecting acquis stats : %s", err)
-		}
-		bucketsTable := tablewriter.NewWriter(ret)
-		bucketsTable.SetHeader([]string{"Bucket", "Current Count", "Overflows", "Instantiated", "Poured", "Expired"})
-		keys = []string{"curr_count", "overflow", "instantiation", "pour", "underflow"}
-		if err := metricsToTable(bucketsTable, buckets_stats, keys); err != nil {
-			log.Warningf("while collecting acquis stats : %s", err)
-		}
-
-		parsersTable := tablewriter.NewWriter(ret)
-		parsersTable.SetHeader([]string{"Parsers", "Hits", "Parsed", "Unparsed"})
-		keys = []string{"hits", "parsed", "unparsed"}
-		if err := metricsToTable(parsersTable, parsers_stats, keys); err != nil {
-			log.Warningf("while collecting acquis stats : %s", err)
-		}
-
-		lapiMachinesTable := tablewriter.NewWriter(ret)
-		lapiMachinesTable.SetHeader([]string{"Machine", "Route", "Method", "Hits"})
-		if err := lapiMetricsToTable(lapiMachinesTable, lapi_machine_stats); err != nil {
-			log.Warningf("while collecting machine lapi stats : %s", err)
-		}
-
-		//lapiMetricsToTable
-		lapiBouncersTable := tablewriter.NewWriter(ret)
-		lapiBouncersTable.SetHeader([]string{"Bouncer", "Route", "Method", "Hits"})
-		if err := lapiMetricsToTable(lapiBouncersTable, lapi_bouncer_stats); err != nil {
-			log.Warningf("while collecting bouncer lapi stats : %s", err)
-		}
-
-		lapiDecisionsTable := tablewriter.NewWriter(ret)
-		lapiDecisionsTable.SetHeader([]string{"Bouncer", "Empty answers", "Non-empty answers"})
-		for bouncer, hits := range lapi_decisions_stats {
-			row := []string{}
-			row = append(row, bouncer)
-			row = append(row, fmt.Sprintf("%d", hits.Empty))
-			row = append(row, fmt.Sprintf("%d", hits.NonEmpty))
-			lapiDecisionsTable.Append(row)
-		}
-
-		/*unfortunately, we can't reuse metricsToTable as the structure is too different :/*/
-		lapiTable := tablewriter.NewWriter(ret)
-		lapiTable.SetHeader([]string{"Route", "Method", "Hits"})
-		sortedKeys := []string{}
-		for akey := range lapi_stats {
-			sortedKeys = append(sortedKeys, akey)
-		}
-		sort.Strings(sortedKeys)
-		for _, alabel := range sortedKeys {
-			astats := lapi_stats[alabel]
-			subKeys := []string{}
-			for skey := range astats {
-				subKeys = append(subKeys, skey)
-			}
-			sort.Strings(subKeys)
-			for _, sl := range subKeys {
-				row := []string{}
-				row = append(row, alabel)
-				row = append(row, sl)
-				row = append(row, fmt.Sprintf("%d", astats[sl]))
-				lapiTable.Append(row)
-			}
-		}
-
-		decisionsTable := tablewriter.NewWriter(ret)
-		decisionsTable.SetHeader([]string{"Reason", "Origin", "Action", "Count"})
-		for reason, origins := range decisions_stats {
-			for origin, actions := range origins {
-				for action, hits := range actions {
-					row := []string{}
-					row = append(row, reason)
-					row = append(row, origin)
-					row = append(row, action)
-					row = append(row, fmt.Sprintf("%d", hits))
-					decisionsTable.Append(row)
-				}
-			}
-		}
-
-		alertsTable := tablewriter.NewWriter(ret)
-		alertsTable.SetHeader([]string{"Reason", "Count"})
-		for scenario, hits := range alerts_stats {
-			row := []string{}
-			row = append(row, scenario)
-			row = append(row, fmt.Sprintf("%d", hits))
-			alertsTable.Append(row)
-		}
-
-		if bucketsTable.NumLines() > 0 {
-			fmt.Fprintf(ret, "Buckets Metrics:\n")
-			bucketsTable.SetAlignment(tablewriter.ALIGN_LEFT)
-			bucketsTable.Render()
-		}
-		if acquisTable.NumLines() > 0 {
-			fmt.Fprintf(ret, "Acquisition Metrics:\n")
-			acquisTable.SetAlignment(tablewriter.ALIGN_LEFT)
-			acquisTable.Render()
-		}
-		if parsersTable.NumLines() > 0 {
-			fmt.Fprintf(ret, "Parser Metrics:\n")
-			parsersTable.SetAlignment(tablewriter.ALIGN_LEFT)
-			parsersTable.Render()
-		}
-		if lapiTable.NumLines() > 0 {
-			fmt.Fprintf(ret, "Local Api Metrics:\n")
-			lapiTable.SetAlignment(tablewriter.ALIGN_LEFT)
-			lapiTable.Render()
-		}
-		if lapiMachinesTable.NumLines() > 0 {
-			fmt.Fprintf(ret, "Local Api Machines Metrics:\n")
-			lapiMachinesTable.SetAlignment(tablewriter.ALIGN_LEFT)
-			lapiMachinesTable.Render()
-		}
-		if lapiBouncersTable.NumLines() > 0 {
-			fmt.Fprintf(ret, "Local Api Bouncers Metrics:\n")
-			lapiBouncersTable.SetAlignment(tablewriter.ALIGN_LEFT)
-			lapiBouncersTable.Render()
-		}
-
-		if lapiDecisionsTable.NumLines() > 0 {
-			fmt.Fprintf(ret, "Local Api Bouncers Decisions:\n")
-			lapiDecisionsTable.SetAlignment(tablewriter.ALIGN_LEFT)
-			lapiDecisionsTable.Render()
-		}
-
-		if decisionsTable.NumLines() > 0 {
-			fmt.Fprintf(ret, "Local Api Decisions:\n")
-			decisionsTable.SetAlignment(tablewriter.ALIGN_LEFT)
-			decisionsTable.Render()
-		}
-
-		if alertsTable.NumLines() > 0 {
-			fmt.Fprintf(ret, "Local Api Alerts:\n")
-			alertsTable.SetAlignment(tablewriter.ALIGN_LEFT)
-			alertsTable.Render()
-		}
-
+		acquisStatsTable(out, acquis_stats)
+		bucketStatsTable(out, buckets_stats)
+		parserStatsTable(out, parsers_stats)
+		lapiStatsTable(out, lapi_stats)
+		lapiMachineStatsTable(out, lapi_machine_stats)
+		lapiBouncerStatsTable(out, lapi_bouncer_stats)
+		lapiDecisionStatsTable(out, lapi_decisions_stats)
+		decisionStatsTable(out, decisions_stats)
+		alertStatsTable(out, alerts_stats)
 	} else if formatType == "json" {
 		for _, val := range []interface{}{acquis_stats, parsers_stats, buckets_stats, lapi_stats, lapi_bouncer_stats, lapi_machine_stats, lapi_decisions_stats, decisions_stats, alerts_stats} {
 			x, err := json.MarshalIndent(val, "", " ")
 			if err != nil {
-				return nil, fmt.Errorf("failed to unmarshal metrics : %v", err)
+				return fmt.Errorf("failed to unmarshal metrics : %v", err)
 			}
-			ret.Write(x)
+			out.Write(x)
 		}
-		return ret.Bytes(), nil
+		return nil
 
 	} else if formatType == "raw" {
 		for _, val := range []interface{}{acquis_stats, parsers_stats, buckets_stats, lapi_stats, lapi_bouncer_stats, lapi_machine_stats, lapi_decisions_stats, decisions_stats, alerts_stats} {
 			x, err := yaml.Marshal(val)
 			if err != nil {
-				return nil, fmt.Errorf("failed to unmarshal metrics : %v", err)
+				return fmt.Errorf("failed to unmarshal metrics : %v", err)
 			}
-			ret.Write(x)
+			out.Write(x)
 		}
-		return ret.Bytes(), nil
+		return nil
 	}
-	return ret.Bytes(), nil
+	return nil
 }
 
 var noUnit bool
@@ -479,11 +276,10 @@ func NewMetricsCmd() *cobra.Command {
 				os.Exit(1)
 			}
 
-			metrics, err := FormatPrometheusMetric(prometheusURL+"/metrics", csConfig.Cscli.Output)
+			err := FormatPrometheusMetrics(colorable.NewColorableStdout(), prometheusURL+"/metrics", csConfig.Cscli.Output)
 			if err != nil {
 				log.Fatalf("could not fetch prometheus metrics: %s", err)
 			}
-			fmt.Printf("%s", metrics)
 		},
 	}
 	cmdMetrics.PersistentFlags().StringVarP(&prometheusURL, "url", "u", "", "Prometheus url (http://<ip>:<port>/metrics)")

+ 272 - 0
cmd/crowdsec-cli/metrics_table.go

@@ -0,0 +1,272 @@
+package main
+
+import (
+	"fmt"
+	"io"
+	"sort"
+
+	"github.com/aquasecurity/table"
+	log "github.com/sirupsen/logrus"
+)
+
+func lapiMetricsToTable(t *table.Table, stats map[string]map[string]map[string]int) int {
+	// stats: machine -> route -> method -> count
+
+	// sort keys to keep consistent order when printing
+	machineKeys := []string{}
+	for k := range stats {
+		machineKeys = append(machineKeys, k)
+	}
+	sort.Strings(machineKeys)
+
+	numRows := 0
+	for _, machine := range machineKeys {
+		// oneRow: route -> method -> count
+		machineRow := stats[machine]
+		for routeName, route := range machineRow {
+			for methodName, count := range route {
+				row := []string{
+					machine,
+					routeName,
+					methodName,
+				}
+				if count != 0 {
+					row = append(row, fmt.Sprintf("%d", count))
+				} else {
+					row = append(row, "-")
+				}
+				t.AddRow(row...)
+				numRows++
+			}
+		}
+	}
+	return numRows
+}
+
+func metricsToTable(t *table.Table, stats map[string]map[string]int, keys []string) (int, error) {
+	if t == nil {
+		return 0, fmt.Errorf("nil table")
+	}
+
+	// sort keys to keep consistent order when printing
+	sortedKeys := []string{}
+	for k := range stats {
+		sortedKeys = append(sortedKeys, k)
+	}
+	sort.Strings(sortedKeys)
+
+	numRows := 0
+	for _, alabel := range sortedKeys {
+		astats, ok := stats[alabel]
+		if !ok {
+			continue
+		}
+		row := []string{
+			alabel,
+		}
+		for _, sl := range keys {
+			if v, ok := astats[sl]; ok && v != 0 {
+				numberToShow := fmt.Sprintf("%d", v)
+				if !noUnit {
+					numberToShow = formatNumber(v)
+				}
+
+				row = append(row, numberToShow)
+			} else {
+				row = append(row, "-")
+			}
+		}
+		t.AddRow(row...)
+	}
+	return numRows, nil
+}
+
+func bucketStatsTable(out io.Writer, stats map[string]map[string]int) {
+	t := newTable(out)
+	t.SetRowLines(false)
+	t.SetHeaders("Bucket", "Current Count", "Overflows", "Instantiated", "Poured", "Expired")
+	t.SetAlignment(table.AlignLeft, table.AlignLeft, table.AlignLeft, table.AlignLeft, table.AlignLeft, table.AlignLeft)
+
+	keys := []string{"curr_count", "overflow", "instantiation", "pour", "underflow"}
+
+	if numRows, err := metricsToTable(t, stats, keys); err != nil {
+		log.Warningf("while collecting acquis stats: %s", err)
+	} else if numRows > 0 {
+		renderTableTitle(out, "\nBucket Metrics:")
+		t.Render()
+	}
+}
+
+func acquisStatsTable(out io.Writer, stats map[string]map[string]int) {
+	t := newTable(out)
+	t.SetRowLines(false)
+	t.SetHeaders("Source", "Lines read", "Lines parsed", "Lines unparsed", "Lines poured to bucket")
+	t.SetAlignment(table.AlignLeft, table.AlignLeft, table.AlignLeft, table.AlignLeft, table.AlignLeft)
+
+	keys := []string{"reads", "parsed", "unparsed", "pour"}
+
+	if numRows, err := metricsToTable(t, stats, keys); err != nil {
+		log.Warningf("while collecting acquis stats: %s", err)
+	} else if numRows > 0 {
+		renderTableTitle(out, "\nAcquisition Metrics:")
+		t.Render()
+	}
+}
+
+func parserStatsTable(out io.Writer, stats map[string]map[string]int) {
+	t := newTable(out)
+	t.SetRowLines(false)
+	t.SetHeaders("Parsers", "Hits", "Parsed", "Unparsed")
+	t.SetAlignment(table.AlignLeft, table.AlignLeft, table.AlignLeft, table.AlignLeft)
+
+	keys := []string{"hits", "parsed", "unparsed"}
+
+	if numRows, err := metricsToTable(t, stats, keys); err != nil {
+		log.Warningf("while collecting acquis stats: %s", err)
+	} else if numRows > 0 {
+		renderTableTitle(out, "\nParser Metrics:")
+		t.Render()
+	}
+}
+
+func lapiStatsTable(out io.Writer, stats map[string]map[string]int) {
+	t := newTable(out)
+	t.SetRowLines(false)
+	t.SetHeaders("Route", "Method", "Hits")
+	t.SetAlignment(table.AlignLeft, table.AlignLeft, table.AlignLeft)
+
+	// unfortunately, we can't reuse metricsToTable as the structure is too different :/
+	sortedKeys := []string{}
+	for k := range stats {
+		sortedKeys = append(sortedKeys, k)
+	}
+	sort.Strings(sortedKeys)
+
+	numRows := 0
+	for _, alabel := range sortedKeys {
+		astats := stats[alabel]
+
+		subKeys := []string{}
+		for skey := range astats {
+			subKeys = append(subKeys, skey)
+		}
+		sort.Strings(subKeys)
+
+		for _, sl := range subKeys {
+			row := []string{
+				alabel,
+				sl,
+				fmt.Sprintf("%d", astats[sl]),
+			}
+			t.AddRow(row...)
+			numRows++
+		}
+	}
+
+	if numRows > 0 {
+		renderTableTitle(out, "\nLocal Api Metrics:")
+		t.Render()
+	}
+}
+
+func lapiMachineStatsTable(out io.Writer, stats map[string]map[string]map[string]int) {
+	t := newTable(out)
+	t.SetRowLines(false)
+	t.SetHeaders("Machine", "Route", "Method", "Hits")
+	t.SetAlignment(table.AlignLeft, table.AlignLeft, table.AlignLeft, table.AlignLeft)
+
+	numRows := lapiMetricsToTable(t, stats)
+
+	if numRows > 0 {
+		renderTableTitle(out, "\nLocal Api Machines Metrics:")
+		t.Render()
+	}
+}
+
+func lapiBouncerStatsTable(out io.Writer, stats map[string]map[string]map[string]int) {
+	t := newTable(out)
+	t.SetRowLines(false)
+	t.SetHeaders("Bouncer", "Route", "Method", "Hits")
+	t.SetAlignment(table.AlignLeft, table.AlignLeft, table.AlignLeft, table.AlignLeft)
+
+	numRows := lapiMetricsToTable(t, stats)
+
+	if numRows > 0 {
+		renderTableTitle(out, "\nLocal Api Bouncers Metrics:")
+		t.Render()
+	}
+}
+
+func lapiDecisionStatsTable(out io.Writer, stats map[string]struct {
+	NonEmpty int
+	Empty    int
+},
+) {
+	t := newTable(out)
+	t.SetRowLines(false)
+	t.SetHeaders("Bouncer", "Empty answers", "Non-empty answers")
+	t.SetAlignment(table.AlignLeft, table.AlignLeft, table.AlignLeft)
+
+	numRows := 0
+	for bouncer, hits := range stats {
+		t.AddRow(
+			bouncer,
+			fmt.Sprintf("%d", hits.Empty),
+			fmt.Sprintf("%d", hits.NonEmpty),
+		)
+		numRows++
+	}
+
+	if numRows > 0 {
+		renderTableTitle(out, "\nLocal Api Bouncers Decisions:")
+		t.Render()
+	}
+}
+
+func decisionStatsTable(out io.Writer, stats map[string]map[string]map[string]int) {
+	t := newTable(out)
+	t.SetRowLines(false)
+	t.SetHeaders("Reason", "Origin", "Action", "Count")
+	t.SetAlignment(table.AlignLeft, table.AlignLeft, table.AlignLeft, table.AlignLeft)
+
+	numRows := 0
+	for reason, origins := range stats {
+		for origin, actions := range origins {
+			for action, hits := range actions {
+				t.AddRow(
+					reason,
+					origin,
+					action,
+					fmt.Sprintf("%d", hits),
+				)
+				numRows++
+			}
+		}
+	}
+
+	if numRows > 0 {
+		renderTableTitle(out, "\nLocal Api Decisions:")
+		t.Render()
+	}
+}
+
+func alertStatsTable(out io.Writer, stats map[string]int) {
+	t := newTable(out)
+	t.SetRowLines(false)
+	t.SetHeaders("Reason", "Count")
+	t.SetAlignment(table.AlignLeft, table.AlignLeft)
+
+	numRows := 0
+	for scenario, hits := range stats {
+		t.AddRow(
+			scenario,
+			fmt.Sprintf("%d", hits),
+		)
+		numRows++
+	}
+
+	if numRows > 0 {
+		renderTableTitle(out, "\nLocal Api Alerts:")
+		t.Render()
+	}
+}

+ 8 - 22
cmd/crowdsec-cli/notifications.go

@@ -13,17 +13,18 @@ import (
 	"strings"
 	"time"
 
-	"github.com/crowdsecurity/crowdsec/pkg/apiclient"
-	"github.com/crowdsecurity/crowdsec/pkg/csconfig"
-	"github.com/crowdsecurity/crowdsec/pkg/csplugin"
-	"github.com/crowdsecurity/crowdsec/pkg/csprofiles"
-	"github.com/crowdsecurity/crowdsec/pkg/cwversion"
 	"github.com/go-openapi/strfmt"
-	"github.com/olekukonko/tablewriter"
+	colorable "github.com/mattn/go-colorable"
 	"github.com/pkg/errors"
 	log "github.com/sirupsen/logrus"
 	"github.com/spf13/cobra"
 	"gopkg.in/tomb.v2"
+
+	"github.com/crowdsecurity/crowdsec/pkg/apiclient"
+	"github.com/crowdsecurity/crowdsec/pkg/csconfig"
+	"github.com/crowdsecurity/crowdsec/pkg/csplugin"
+	"github.com/crowdsecurity/crowdsec/pkg/csprofiles"
+	"github.com/crowdsecurity/crowdsec/pkg/cwversion"
 )
 
 type NotificationsCfg struct {
@@ -67,22 +68,7 @@ func NewNotificationsCmd() *cobra.Command {
 			}
 
 			if csConfig.Cscli.Output == "human" {
-				table := tablewriter.NewWriter(os.Stdout)
-				table.SetCenterSeparator("")
-				table.SetColumnSeparator("")
-
-				table.SetHeaderAlignment(tablewriter.ALIGN_LEFT)
-				table.SetAlignment(tablewriter.ALIGN_LEFT)
-				table.SetHeader([]string{"Name", "Type", "Profile name"})
-				for _, b := range ncfgs {
-					profilesList := []string{}
-					for _, p := range b.Profiles {
-						profilesList = append(profilesList, p.Name)
-					}
-					table.Append([]string{b.Config.Name, b.Config.Type, strings.Join(profilesList, ", ")})
-				}
-				table.Render()
-
+				notificationListTable(colorable.NewColorableStdout(), ncfgs)
 			} else if csConfig.Cscli.Output == "json" {
 				x, err := json.MarshalIndent(ncfgs, "", " ")
 				if err != nil {

+ 25 - 0
cmd/crowdsec-cli/notifications_table.go

@@ -0,0 +1,25 @@
+package main
+
+import (
+	"io"
+	"strings"
+
+	"github.com/aquasecurity/table"
+)
+
+func notificationListTable(out io.Writer, ncfgs map[string]NotificationsCfg) {
+	t := newLightTable(out)
+	t.SetHeaders("Name", "Type", "Profile name")
+	t.SetHeaderAlignment(table.AlignLeft, table.AlignLeft, table.AlignLeft)
+	t.SetAlignment(table.AlignLeft, table.AlignLeft, table.AlignLeft)
+
+	for _, b := range ncfgs {
+		profilesList := []string{}
+		for _, p := range b.Profiles {
+			profilesList = append(profilesList, p.Name)
+		}
+		t.AddRow(b.Config.Name, b.Config.Type, strings.Join(profilesList, ", "))
+	}
+
+	t.Render()
+}

+ 4 - 5
cmd/crowdsec-cli/parsers.go

@@ -3,11 +3,11 @@ package main
 import (
 	"fmt"
 
-	"github.com/crowdsecurity/crowdsec/pkg/cwhub"
-
+	colorable "github.com/mattn/go-colorable"
 	log "github.com/sirupsen/logrus"
-
 	"github.com/spf13/cobra"
+
+	"github.com/crowdsecurity/crowdsec/pkg/cwhub"
 )
 
 func NewParsersCmd() *cobra.Command {
@@ -164,8 +164,7 @@ cscli parsers remove crowdsecurity/sshd-logs
 cscli parser list crowdsecurity/xxx`,
 		DisableAutoGenTag: true,
 		Run: func(cmd *cobra.Command, args []string) {
-			items := ListItems([]string{cwhub.PARSERS}, args, false, true, all)
-			fmt.Printf("%s\n", items)
+			ListItems(colorable.NewColorableStdout(), []string{cwhub.PARSERS}, args, false, true, all)
 		},
 	}
 	cmdParsersList.PersistentFlags().BoolVarP(&all, "all", "a", false, "List disabled items as well")

+ 4 - 5
cmd/crowdsec-cli/postoverflows.go

@@ -3,11 +3,11 @@ package main
 import (
 	"fmt"
 
-	"github.com/crowdsecurity/crowdsec/pkg/cwhub"
-
+	colorable "github.com/mattn/go-colorable"
 	log "github.com/sirupsen/logrus"
-
 	"github.com/spf13/cobra"
+
+	"github.com/crowdsecurity/crowdsec/pkg/cwhub"
 )
 
 func NewPostOverflowsCmd() *cobra.Command {
@@ -162,8 +162,7 @@ func NewPostOverflowsCmd() *cobra.Command {
 cscli postoverflows list crowdsecurity/xxx`,
 		DisableAutoGenTag: true,
 		Run: func(cmd *cobra.Command, args []string) {
-			items := ListItems([]string{cwhub.PARSERS_OVFLW}, args, false, true, all)
-			fmt.Printf("%s\n", items)
+			ListItems(colorable.NewColorableStdout(), []string{cwhub.PARSERS_OVFLW}, args, false, true, all)
 		},
 	}
 	cmdPostOverflowsList.PersistentFlags().BoolVarP(&all, "all", "a", false, "List disabled items as well")

+ 4 - 4
cmd/crowdsec-cli/scenarios.go

@@ -3,11 +3,12 @@ package main
 import (
 	"fmt"
 
-	"github.com/crowdsecurity/crowdsec/pkg/cwhub"
+	colorable "github.com/mattn/go-colorable"
 	"github.com/pkg/errors"
 	log "github.com/sirupsen/logrus"
-
 	"github.com/spf13/cobra"
+
+	"github.com/crowdsecurity/crowdsec/pkg/cwhub"
 )
 
 func NewScenariosCmd() *cobra.Command {
@@ -166,8 +167,7 @@ cscli scenarios remove crowdsecurity/ssh-bf
 cscli scenarios list crowdsecurity/xxx`,
 		DisableAutoGenTag: true,
 		Run: func(cmd *cobra.Command, args []string) {
-			items := ListItems([]string{cwhub.SCENARIOS}, args, false, true, all)
-			fmt.Printf("%s\n", items)
+			ListItems(colorable.NewColorableStdout(), []string{cwhub.SCENARIOS}, args, false, true, all)
 		},
 	}
 	cmdScenariosList.PersistentFlags().BoolVarP(&all, "all", "a", false, "List disabled items as well")

+ 24 - 11
cmd/crowdsec-cli/support.go

@@ -14,15 +14,16 @@ import (
 	"strings"
 
 	"github.com/blackfireio/osinfo"
+	"github.com/go-openapi/strfmt"
+	log "github.com/sirupsen/logrus"
+	"github.com/spf13/cobra"
+
 	"github.com/crowdsecurity/crowdsec/pkg/apiclient"
 	"github.com/crowdsecurity/crowdsec/pkg/cwhub"
 	"github.com/crowdsecurity/crowdsec/pkg/cwversion"
 	"github.com/crowdsecurity/crowdsec/pkg/database"
 	"github.com/crowdsecurity/crowdsec/pkg/models"
-	"github.com/go-openapi/strfmt"
-	log "github.com/sirupsen/logrus"
-
-	"github.com/spf13/cobra"
+	"github.com/crowdsecurity/crowdsec/pkg/types"
 )
 
 const (
@@ -55,7 +56,8 @@ func collectMetrics() ([]byte, []byte, error) {
 		return nil, nil, fmt.Errorf("prometheus_uri is not set")
 	}
 
-	humanMetrics, err := FormatPrometheusMetric(csConfig.Cscli.PrometheusUrl+"/metrics", "human")
+	humanMetrics := bytes.NewBuffer(nil)
+	err = FormatPrometheusMetrics(humanMetrics, csConfig.Cscli.PrometheusUrl+"/metrics", "human")
 
 	if err != nil {
 		return nil, nil, fmt.Errorf("could not fetch promtheus metrics: %s", err)
@@ -79,7 +81,7 @@ func collectMetrics() ([]byte, []byte, error) {
 		return nil, nil, fmt.Errorf("could not read metrics from prometheus endpoint: %s", err)
 	}
 
-	return humanMetrics, body, nil
+	return humanMetrics.Bytes(), body, nil
 }
 
 func collectVersion() []byte {
@@ -126,17 +128,28 @@ func initHub() error {
 }
 
 func collectHubItems(itemType string) []byte {
+	out := bytes.NewBuffer(nil)
 	log.Infof("Collecting %s list", itemType)
-	items := ListItems([]string{itemType}, []string{}, false, true, all)
-	return items
+	ListItems(out, []string{itemType}, []string{}, false, true, all)
+	return out.Bytes()
 }
 
 func collectBouncers(dbClient *database.Client) ([]byte, error) {
-	return getBouncers(dbClient)
+	out := bytes.NewBuffer(nil)
+	err := getBouncers(out, dbClient)
+	if err != nil {
+		return nil, err
+	}
+	return out.Bytes(), nil
 }
 
 func collectAgents(dbClient *database.Client) ([]byte, error) {
-	return getAgents(dbClient)
+	out := bytes.NewBuffer(nil)
+	err := getAgents(out, dbClient)
+	if err != nil {
+		return nil, err
+	}
+	return out.Bytes(), nil
 }
 
 func collectAPIStatus(login string, password string, endpoint string, prefix string) []byte {
@@ -374,7 +387,7 @@ cscli support dump -f /tmp/crowdsec-support.zip
 					log.Errorf("Could not add zip entry for %s: %s", filename, err)
 					continue
 				}
-				fw.Write(data)
+				fw.Write([]byte(types.StripAnsiString(string(data))))
 			}
 			err = zipWriter.Close()
 			if err != nil {

+ 95 - 0
cmd/crowdsec-cli/tables.go

@@ -0,0 +1,95 @@
+package main
+
+import (
+	"fmt"
+	"io"
+	"os"
+
+	"github.com/aquasecurity/table"
+	isatty "github.com/mattn/go-isatty"
+)
+
+func shouldWeColorize() bool {
+	if csConfig.Cscli.Color == "yes" {
+		return true
+	}
+	if csConfig.Cscli.Color == "no" {
+		return false
+	}
+	return isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd())
+}
+
+func newTable(out io.Writer) *table.Table {
+	if out == nil {
+		panic("newTable: out is nil")
+	}
+	t := table.New(out)
+	if shouldWeColorize() {
+		t.SetLineStyle(table.StyleBrightBlack)
+		t.SetHeaderStyle(table.StyleItalic)
+	}
+
+	if shouldWeColorize() {
+		t.SetDividers(table.UnicodeRoundedDividers)
+	} else {
+		t.SetDividers(table.ASCIIDividers)
+	}
+
+	return t
+}
+
+func newLightTable(out io.Writer) *table.Table {
+	if out == nil {
+		panic("newTable: out is nil")
+	}
+	t := newTable(out)
+	t.SetRowLines(false)
+	t.SetBorderLeft(false)
+	t.SetBorderRight(false)
+	// This leaves three spaces between columns:
+	// left padding, invisible border, right padding
+	// There is no way to make two spaces without
+	// a SetColumnLines() method, but it's close enough.
+	t.SetPadding(1)
+
+	if shouldWeColorize() {
+		t.SetDividers(table.Dividers{
+			ALL: "─",
+			NES: "─",
+			NSW: "─",
+			NEW: "─",
+			ESW: "─",
+			NE:  "─",
+			NW:  "─",
+			SW:  "─",
+			ES:  "─",
+			EW:  "─",
+			NS:  " ",
+		})
+	} else {
+		t.SetDividers(table.Dividers{
+			ALL: "-",
+			NES: "-",
+			NSW: "-",
+			NEW: "-",
+			ESW: "-",
+			NE:  "-",
+			NW:  "-",
+			SW:  "-",
+			ES:  "-",
+			EW:  "-",
+			NS:  " ",
+		})
+	}
+	return t
+}
+
+func renderTableTitle(out io.Writer, title string) {
+	if out == nil {
+		panic("renderTableTitle: out is nil")
+	}
+	if title == "" {
+		return
+	}
+	fmt.Fprintln(out, title)
+}

+ 16 - 61
cmd/crowdsec-cli/utils.go

@@ -1,10 +1,10 @@
 package main
 
 import (
-	"bytes"
 	"encoding/csv"
 	"encoding/json"
 	"fmt"
+	"io"
 	"math"
 	"net"
 	"net/http"
@@ -13,16 +13,16 @@ import (
 	"strings"
 	"time"
 
-	"github.com/crowdsecurity/crowdsec/pkg/cwhub"
-	"github.com/crowdsecurity/crowdsec/pkg/types"
-	"github.com/enescakir/emoji"
-	"github.com/olekukonko/tablewriter"
+	colorable "github.com/mattn/go-colorable"
 	dto "github.com/prometheus/client_model/go"
 	"github.com/prometheus/prom2json"
 	log "github.com/sirupsen/logrus"
 	"github.com/spf13/cobra"
 	"github.com/texttheater/golang-levenshtein/levenshtein"
 	"gopkg.in/yaml.v2"
+
+	"github.com/crowdsecurity/crowdsec/pkg/cwhub"
+	"github.com/crowdsecurity/crowdsec/pkg/types"
 )
 
 const MaxDistance = 7
@@ -161,8 +161,7 @@ func compInstalledItems(itemType string, args []string, toComplete string) ([]st
 	return comp, cobra.ShellCompDirectiveNoFileComp
 }
 
-func ListItems(itemTypes []string, args []string, showType bool, showHeader bool, all bool) []byte {
-
+func ListItems(out io.Writer, itemTypes []string, args []string, showType bool, showHeader bool, all bool) {
 	var hubStatusByItemType = make(map[string][]cwhub.ItemHubStatus)
 
 	for _, itemType := range itemTypes {
@@ -173,8 +172,6 @@ func ListItems(itemTypes []string, args []string, showType bool, showHeader bool
 		hubStatusByItemType[itemType] = cwhub.GetHubStatusForItemType(itemType, itemName, all)
 	}
 
-	w := bytes.NewBuffer(nil)
-
 	if csConfig.Cscli.Output == "human" {
 		for _, itemType := range itemTypes {
 			var statuses []cwhub.ItemHubStatus
@@ -183,26 +180,16 @@ func ListItems(itemTypes []string, args []string, showType bool, showHeader bool
 				log.Errorf("unknown item type: %s", itemType)
 				continue
 			}
-			fmt.Fprintf(w, "%s\n", strings.ToUpper(itemType))
-			table := tablewriter.NewWriter(w)
-			table.SetCenterSeparator("")
-			table.SetColumnSeparator("")
-			table.SetHeaderAlignment(tablewriter.ALIGN_LEFT)
-			table.SetAlignment(tablewriter.ALIGN_LEFT)
-			table.SetHeader([]string{"Name", fmt.Sprintf("%v Status", emoji.Package), "Version", "Local Path"})
-			for _, status := range statuses {
-				table.Append([]string{status.Name, status.UTF8_Status, status.LocalVersion, status.LocalPath})
-			}
-			table.Render()
+			listHubItemTable(out, "\n"+strings.ToUpper(itemType), statuses)
 		}
 	} else if csConfig.Cscli.Output == "json" {
 		x, err := json.MarshalIndent(hubStatusByItemType, "", " ")
 		if err != nil {
 			log.Fatalf("failed to unmarshal")
 		}
-		w.Write(x)
+		out.Write(x)
 	} else if csConfig.Cscli.Output == "raw" {
-		csvwriter := csv.NewWriter(w)
+		csvwriter := csv.NewWriter(out)
 		if showHeader {
 			header := []string{"name", "status", "version", "description"}
 			if showType {
@@ -242,7 +229,6 @@ func ListItems(itemTypes []string, args []string, showType bool, showHeader bool
 		}
 		csvwriter.Flush()
 	}
-	return w.Bytes()
 }
 
 func InspectItem(name string, objecitemType string) {
@@ -279,7 +265,7 @@ func InspectItem(name string, objecitemType string) {
 			log.Debugf("No prometheus URL provided using: %s:%d", csConfig.Prometheus.ListenAddr, csConfig.Prometheus.ListenPort)
 			prometheusURL = fmt.Sprintf("http://%s:%d/metrics", csConfig.Prometheus.ListenAddr, csConfig.Prometheus.ListenPort)
 		}
-		fmt.Printf("\nCurrent metrics : \n\n")
+		fmt.Printf("\nCurrent metrics : \n")
 		ShowMetrics(hubItem)
 	}
 }
@@ -318,18 +304,18 @@ func ShowMetrics(hubItem *cwhub.Item) {
 	switch hubItem.Type {
 	case cwhub.PARSERS:
 		metrics := GetParserMetric(prometheusURL, hubItem.Name)
-		ShowParserMetric(hubItem.Name, metrics)
+		parserMetricsTable(colorable.NewColorableStdout(), hubItem.Name, metrics)
 	case cwhub.SCENARIOS:
 		metrics := GetScenarioMetric(prometheusURL, hubItem.Name)
-		ShowScenarioMetric(hubItem.Name, metrics)
+		scenarioMetricsTable(colorable.NewColorableStdout(), hubItem.Name, metrics)
 	case cwhub.COLLECTIONS:
 		for _, item := range hubItem.Parsers {
 			metrics := GetParserMetric(prometheusURL, item)
-			ShowParserMetric(item, metrics)
+			parserMetricsTable(colorable.NewColorableStdout(), item, metrics)
 		}
 		for _, item := range hubItem.Scenarios {
 			metrics := GetScenarioMetric(prometheusURL, item)
-			ShowScenarioMetric(item, metrics)
+			scenarioMetricsTable(colorable.NewColorableStdout(), item, metrics)
 		}
 		for _, item := range hubItem.Collections {
 			hubItem = cwhub.GetItem(cwhub.COLLECTIONS, item)
@@ -343,7 +329,7 @@ func ShowMetrics(hubItem *cwhub.Item) {
 	}
 }
 
-/*This is a complete rip from prom2json*/
+// GetParserMetric is a complete rip from prom2json
 func GetParserMetric(url string, itemName string) map[string]map[string]int {
 	stats := make(map[string]map[string]int)
 
@@ -480,7 +466,7 @@ func GetScenarioMetric(url string, itemName string) map[string]int {
 	return stats
 }
 
-//it's a rip of the cli version, but in silent-mode
+// it's a rip of the cli version, but in silent-mode
 func silenceInstallItem(name string, obtype string) (string, error) {
 	var item = cwhub.GetItem(obtype, name)
 	if item == nil {
@@ -539,37 +525,6 @@ func GetPrometheusMetric(url string) []*prom2json.Family {
 	return result
 }
 
-func ShowScenarioMetric(itemName string, metrics map[string]int) {
-	if metrics["instantiation"] == 0 {
-		return
-	}
-	table := tablewriter.NewWriter(os.Stdout)
-	table.SetHeader([]string{"Current Count", "Overflows", "Instantiated", "Poured", "Expired"})
-	table.Append([]string{fmt.Sprintf("%d", metrics["curr_count"]), fmt.Sprintf("%d", metrics["overflow"]), fmt.Sprintf("%d", metrics["instantiation"]), fmt.Sprintf("%d", metrics["pour"]), fmt.Sprintf("%d", metrics["underflow"])})
-
-	fmt.Printf(" - (Scenario) %s: \n", itemName)
-	table.Render()
-	fmt.Println()
-}
-
-func ShowParserMetric(itemName string, metrics map[string]map[string]int) {
-	skip := true
-
-	table := tablewriter.NewWriter(os.Stdout)
-	table.SetHeader([]string{"Parsers", "Hits", "Parsed", "Unparsed"})
-	for source, stats := range metrics {
-		if stats["hits"] > 0 {
-			table.Append([]string{source, fmt.Sprintf("%d", stats["hits"]), fmt.Sprintf("%d", stats["parsed"]), fmt.Sprintf("%d", stats["unparsed"])})
-			skip = false
-		}
-	}
-	if !skip {
-		fmt.Printf(" - (Parser) %s: \n", itemName)
-		table.Render()
-		fmt.Println()
-	}
-}
-
 func RestoreHub(dirPath string) error {
 	var err error
 

+ 66 - 0
cmd/crowdsec-cli/utils_table.go

@@ -0,0 +1,66 @@
+package main
+
+import (
+	"fmt"
+	"io"
+
+	"github.com/aquasecurity/table"
+	"github.com/enescakir/emoji"
+
+	"github.com/crowdsecurity/crowdsec/pkg/cwhub"
+)
+
+func listHubItemTable(out io.Writer, title string, statuses []cwhub.ItemHubStatus) {
+	t := newLightTable(out)
+	t.SetHeaders("Name", fmt.Sprintf("%v Status", emoji.Package), "Version", "Local Path")
+	t.SetHeaderAlignment(table.AlignLeft, table.AlignLeft, table.AlignLeft, table.AlignLeft)
+	t.SetAlignment(table.AlignLeft, table.AlignLeft, table.AlignLeft, table.AlignLeft)
+
+	for _, status := range statuses {
+		t.AddRow(status.Name, status.UTF8_Status, status.LocalVersion, status.LocalPath)
+	}
+	renderTableTitle(out, title)
+	t.Render()
+}
+
+func scenarioMetricsTable(out io.Writer, itemName string, metrics map[string]int) {
+	if metrics["instantiation"] == 0 {
+		return
+	}
+	t := newTable(out)
+	t.SetHeaders("Current Count", "Overflows", "Instantiated", "Poured", "Expired")
+
+	t.AddRow(
+		fmt.Sprintf("%d", metrics["curr_count"]),
+		fmt.Sprintf("%d", metrics["overflow"]),
+		fmt.Sprintf("%d", metrics["instantiation"]),
+		fmt.Sprintf("%d", metrics["pour"]),
+		fmt.Sprintf("%d", metrics["underflow"]),
+	)
+
+	renderTableTitle(out, fmt.Sprintf("\n - (Scenario) %s:", itemName))
+	t.Render()
+}
+
+func parserMetricsTable(out io.Writer, itemName string, metrics map[string]map[string]int) {
+	skip := true
+	t := newTable(out)
+	t.SetHeaders("Parsers", "Hits", "Parsed", "Unparsed")
+
+	for source, stats := range metrics {
+		if stats["hits"] > 0 {
+			t.AddRow(
+				source,
+				fmt.Sprintf("%d", stats["hits"]),
+				fmt.Sprintf("%d", stats["parsed"]),
+				fmt.Sprintf("%d", stats["unparsed"]),
+			)
+			skip = false
+		}
+	}
+
+	if !skip {
+		renderTableTitle(out, fmt.Sprintf("\n - (Parser) %s:", itemName))
+		t.Render()
+	}
+}

+ 3 - 3
go.mod

@@ -6,7 +6,6 @@ require (
 	entgo.io/ent v0.11.3
 	github.com/AlecAivazis/survey/v2 v2.2.7
 	github.com/Microsoft/go-winio v0.5.2 // indirect
-	github.com/crowdsecurity/dlog v0.0.0-20170105205344-4fb5f8204f26
 	github.com/alexliesenfeld/health v0.5.1
 	github.com/antonmedv/expr v1.9.0
 	github.com/appleboy/gin-jwt/v2 v2.8.0
@@ -15,6 +14,7 @@ require (
 	github.com/c-robinson/iplib v1.0.3
 	github.com/confluentinc/bincover v0.2.0
 	github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf
+	github.com/crowdsecurity/dlog v0.0.0-20170105205344-4fb5f8204f26
 	github.com/crowdsecurity/grokky v0.1.0
 	github.com/crowdsecurity/machineid v1.0.2
 	github.com/davecgh/go-spew v1.1.1
@@ -83,8 +83,8 @@ require (
 	github.com/PuerkitoBio/purell v1.1.1 // indirect
 	github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
 	github.com/agext/levenshtein v1.2.3 // indirect
-	github.com/crowdsecurity/dlog v0.0.0-20170105205344-4fb5f8204f26 // indirect
 	github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect
+	github.com/aquasecurity/table v1.8.0 // indirect
 	github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef // indirect
 	github.com/beorn7/perks v1.0.1 // indirect
 	github.com/cespare/xxhash/v2 v2.1.2 // indirect
@@ -165,7 +165,7 @@ require (
 	go.mongodb.org/mongo-driver v1.9.0 // indirect
 	golang.org/x/net v0.0.0-20220706163947-c90051bbdb60 // indirect
 	golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4 // indirect
-	golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect
+	golang.org/x/term v0.0.0-20220526004731-065cf7ba2467 // indirect
 	golang.org/x/text v0.3.7 // indirect
 	google.golang.org/appengine v1.6.7 // indirect
 	google.golang.org/genproto v0.0.0-20220414192740-2d67ff6cf2b4 // indirect

+ 6 - 2
go.sum

@@ -88,6 +88,8 @@ github.com/appleboy/gin-jwt/v2 v2.8.0 h1:Glo7cb9eBR+hj8Y7WzgfkOlqCaNLjP+RV4dNO3f
 github.com/appleboy/gin-jwt/v2 v2.8.0/go.mod h1:KsK7E8HTvRg3vOiumTsr/ntNTHbZ3IbHLe4Eto31p7k=
 github.com/appleboy/gofight/v2 v2.1.2 h1:VOy3jow4vIK8BRQJoC/I9muxyYlJ2yb9ht2hZoS3rf4=
 github.com/appleboy/gofight/v2 v2.1.2/go.mod h1:frW+U1QZEdDgixycTj4CygQ48yLTUhplt43+Wczp3rw=
+github.com/aquasecurity/table v1.8.0 h1:9ntpSwrUfjrM6/YviArlx/ZBGd6ix8W+MtojQcM7tv0=
+github.com/aquasecurity/table v1.8.0/go.mod h1:eqOmvjjB7AhXFgFqpJUEE/ietg7RrMSJZXyTN8E/wZw=
 github.com/asaskevich/govalidator v0.0.0-20180720115003-f9ffefc3facf/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY=
 github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY=
 github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
@@ -140,6 +142,8 @@ github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7Do
 github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
 github.com/creack/pty v1.1.11 h1:07n33Z8lZxZ2qwegKbObQohDhXDQxiMMz1NOUGYlesw=
 github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/crowdsecurity/dlog v0.0.0-20170105205344-4fb5f8204f26 h1:r97WNVC30Uen+7WnLs4xDScS/Ex988+id2k6mDf8psU=
+github.com/crowdsecurity/dlog v0.0.0-20170105205344-4fb5f8204f26/go.mod h1:zpv7r+7KXwgVUZnUNjyP22zc/D7LKjyoY02weH2RBbk=
 github.com/crowdsecurity/grokky v0.0.0-20220120093523-d5b3478363fa h1:pcHZgbBbIkNDO1cAgipEgaGeFJ0se+FOPvq6A4d/g9c=
 github.com/crowdsecurity/grokky v0.0.0-20220120093523-d5b3478363fa/go.mod h1:fx5UYUYAFIrOUNAkFCUOM2wJcsp9EWSQE9R0/9kaFJg=
 github.com/crowdsecurity/grokky v0.1.0 h1:jLUzZd3vKxYrM4hQ8n5HWLfvs5ag4UP08eT9OTekI4U=
@@ -997,6 +1001,8 @@ golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXR
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 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/term v0.0.0-20220526004731-065cf7ba2467 h1:CBpWXWQpIRjzmkkA+M7q9Fqnwd2mZr3AFqexg8YTfoM=
+golang.org/x/term v0.0.0-20220526004731-065cf7ba2467/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -1207,5 +1213,3 @@ honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9
 rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
 rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
 rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
-github.com/crowdsecurity/dlog v0.0.0-20170105205344-4fb5f8204f26 h1:r97WNVC30Uen+7WnLs4xDScS/Ex988+id2k6mDf8psU=
-github.com/crowdsecurity/dlog v0.0.0-20170105205344-4fb5f8204f26/go.mod h1:zpv7r+7KXwgVUZnUNjyP22zc/D7LKjyoY02weH2RBbk=

+ 4 - 2
pkg/csconfig/config.go

@@ -5,11 +5,12 @@ import (
 	"os"
 	"path/filepath"
 
-	"github.com/crowdsecurity/crowdsec/pkg/types"
-	"github.com/crowdsecurity/crowdsec/pkg/yamlpatch"
 	"github.com/pkg/errors"
 	log "github.com/sirupsen/logrus"
 	"gopkg.in/yaml.v2"
+
+	"github.com/crowdsecurity/crowdsec/pkg/types"
+	"github.com/crowdsecurity/crowdsec/pkg/yamlpatch"
 )
 
 // defaultConfigDir is the base path to all configuration files, to be overridden in the Makefile */
@@ -94,6 +95,7 @@ func NewDefaultConfig() *Config {
 
 	cscliCfg := CscliCfg{
 		Output: "human",
+		Color:  ColorDefault(),
 	}
 
 	apiCfg := APICfg{

+ 12 - 0
pkg/csconfig/cscli.go

@@ -1,8 +1,13 @@
 package csconfig
 
+import (
+	"runtime"
+)
+
 /*cscli specific config, such as hub directory*/
 type CscliCfg struct {
 	Output             string            `yaml:"output,omitempty"`
+	Color              string            `yaml:"color,omitempty"`
 	HubBranch          string            `yaml:"hub_branch"`
 	SimulationConfig   *SimulationConfig `yaml:"-"`
 	DbConfig           *DatabaseCfg      `yaml:"-"`
@@ -14,6 +19,13 @@ type CscliCfg struct {
 	PrometheusUrl      string            `yaml:"prometheus_uri"`
 }
 
+func ColorDefault() string {
+	if runtime.GOOS == "windows" {
+		return "no"
+	}
+	return "auto"
+}
+
 func (c *Config) LoadCSCLI() error {
 	if c.Cscli == nil {
 		c.Cscli = &CscliCfg{}

+ 1 - 1
pkg/leakybucket/bucket.go

@@ -24,7 +24,7 @@ const (
 	TIMEMACHINE
 )
 
-//Leaky represents one instance of a bucket
+// Leaky represents one instance of a bucket
 type Leaky struct {
 	Name string
 	Mode int //LIVE or TIMEMACHINE

+ 11 - 1
pkg/types/utils.go

@@ -8,14 +8,16 @@ import (
 	"io"
 	"os"
 	"path/filepath"
+	"regexp"
 	"runtime/debug"
 	"strconv"
 	"strings"
 	"time"
 
-	"github.com/crowdsecurity/crowdsec/pkg/cwversion"
 	log "github.com/sirupsen/logrus"
 	"gopkg.in/natefinch/lumberjack.v2"
+
+	"github.com/crowdsecurity/crowdsec/pkg/cwversion"
 )
 
 var logFormatter log.Formatter
@@ -257,3 +259,11 @@ func GetLineCountForFile(filepath string) int {
 	}
 	return lc
 }
+
+// from https://github.com/acarl005/stripansi
+var reStripAnsi = regexp.MustCompile("[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))")
+
+func StripAnsiString(str string) string {
+	// the byte version doesn't strip correctly
+	return reStripAnsi.ReplaceAllString(str, "")
+}

+ 1 - 1
tests/bats/01_base.bats

@@ -213,7 +213,7 @@ declare stderr
 @test "cscli metrics" {
     run -0 cscli lapi status
     run -0 --separate-stderr cscli metrics
-    assert_output --partial "ROUTE"
+    assert_output --partial "Route"
     assert_output --partial '/v1/watchers/login'
     assert_output --partial "Local Api Metrics:"
 

+ 1 - 2
tests/bats/04_nocapi.bats

@@ -75,8 +75,7 @@ teardown() {
     ./instance-crowdsec start
     run -0 cscli lapi status
     run -0 --separate-stderr cscli metrics
-    assert_output --partial "ROUTE"
+    assert_output --partial "Route"
     assert_output --partial '/v1/watchers/login'
     assert_output --partial "Local Api Metrics:"
-
 }

+ 13 - 11
tests/bats/80_alerts.bats

@@ -28,27 +28,28 @@ teardown() {
     run -0 cscli decisions add -i 10.20.30.40 -t ban
 
     run -0 cscli alerts list
-    refute_output --partial 'MACHINE'
+    refute_output --partial 'machine'
     # machine name appears quoted in the "REASON" column
-    assert_output --regexp "\| 'githubciXXXXXXXXXXXXXXXXXXXXXXXX([a-zA-Z0-9]{16})?' \|"
-    refute_output --regexp "\| githubciXXXXXXXXXXXXXXXXXXXXXXXX([a-zA-Z0-9]{16})? \|"
+    assert_output --regexp " 'githubciXXXXXXXXXXXXXXXXXXXXXXXX([a-zA-Z0-9]{16})?' "
+    refute_output --regexp " githubciXXXXXXXXXXXXXXXXXXXXXXXX([a-zA-Z0-9]{16})? "
 
     run -0 cscli alerts list -m
-    assert_output --partial 'MACHINE'
-    assert_output --regexp "\| 'githubciXXXXXXXXXXXXXXXXXXXXXXXX([a-zA-Z0-9]{16})?' \|"
-    assert_output --regexp "\| githubciXXXXXXXXXXXXXXXXXXXXXXXX([a-zA-Z0-9]{16})? \|"
+    assert_output --partial 'machine'
+    assert_output --regexp " 'githubciXXXXXXXXXXXXXXXXXXXXXXXX([a-zA-Z0-9]{16})?' "
+    assert_output --regexp " githubciXXXXXXXXXXXXXXXXXXXXXXXX([a-zA-Z0-9]{16})? "
 
     run -0 cscli alerts list --machine
-    assert_output --partial 'MACHINE'
-    assert_output --regexp "\| 'githubciXXXXXXXXXXXXXXXXXXXXXXXX([a-zA-Z0-9]{16})?' \|"
-    assert_output --regexp "\| githubciXXXXXXXXXXXXXXXXXXXXXXXX([a-zA-Z0-9]{16})? \|"
+    assert_output --partial 'machine'
+    assert_output --regexp " 'githubciXXXXXXXXXXXXXXXXXXXXXXXX([a-zA-Z0-9]{16})?' "
+    assert_output --regexp " githubciXXXXXXXXXXXXXXXXXXXXXXXX([a-zA-Z0-9]{16})? "
 }
 
 @test "cscli alerts list, human/json/raw" {
     run -0 cscli decisions add -i 10.20.30.40 -t ban
 
     run -0 cscli alerts list -o human
-    assert_output --regexp ".* ID .* VALUE .* REASON .* COUNTRY .* AS .* DECISIONS .* CREATED AT .*"
+    run -0 plaintext < <(output)
+    assert_output --regexp ".* ID .* value .* reason .* country .* as .* decisions .* created_at .*"
     assert_output --regexp ".*Ip:10.20.30.40.*manual 'ban' from.*ban:1.*"
 
     run -0 cscli alerts list -o json
@@ -72,6 +73,7 @@ teardown() {
     ALERT_ID="${output}"
 
     run -0 cscli alerts inspect "${ALERT_ID}" -o human
+    run -0 plaintext < <(output)
     assert_line --regexp '^#+$'
     assert_line --regexp "^ - ID *: ${ALERT_ID}$"
     assert_line --regexp "^ - Date *: .*$"
@@ -85,7 +87,7 @@ teardown() {
     assert_line --regexp "^ - Begin *: .*$"
     assert_line --regexp "^ - End *: .*$"
     assert_line --regexp "^ - Active Decisions *:$"
-    assert_line --regexp "^.* ID .* SCOPE:VALUE .* ACTION .* EXPIRATION .* CREATED AT .*$"
+    assert_line --regexp "^.* ID .* scope:value .* action .* expiration .* created_at .*$"
     assert_line --regexp "^.* Ip:10.20.30.40 .* ban .*$"
 
     run -0 cscli alerts inspect "${ALERT_ID}" -o human --details

+ 9 - 9
tests/bats/90_decisions.bats

@@ -41,20 +41,20 @@ declare stderr
     run -0 cscli decisions add -i 10.20.30.40 -t ban
 
     run -0 cscli decisions list
-    refute_output --partial 'MACHINE'
+    refute_output --partial 'Machine'
     # machine name appears quoted in the "REASON" column
-    assert_output --regexp "\| 'githubciXXXXXXXXXXXXXXXXXXXXXXXX([a-zA-Z0-9]{16})?' \|"
-    refute_output --regexp "\| githubciXXXXXXXXXXXXXXXXXXXXXXXX([a-zA-Z0-9]{16})? \|"
+    assert_output --regexp " 'githubciXXXXXXXXXXXXXXXXXXXXXXXX([a-zA-Z0-9]{16})?' "
+    refute_output --regexp " githubciXXXXXXXXXXXXXXXXXXXXXXXX([a-zA-Z0-9]{16})? "
 
     run -0 cscli decisions list -m
-    assert_output --partial 'MACHINE'
-    assert_output --regexp "\| 'githubciXXXXXXXXXXXXXXXXXXXXXXXX([a-zA-Z0-9]{16})?' \|"
-    assert_output --regexp "\| githubciXXXXXXXXXXXXXXXXXXXXXXXX([a-zA-Z0-9]{16})? \|"
+    assert_output --partial 'Machine'
+    assert_output --regexp " 'githubciXXXXXXXXXXXXXXXXXXXXXXXX([a-zA-Z0-9]{16})?' "
+    assert_output --regexp " githubciXXXXXXXXXXXXXXXXXXXXXXXX([a-zA-Z0-9]{16})? "
 
     run -0 cscli decisions list --machine
-    assert_output --partial 'MACHINE'
-    assert_output --regexp "\| 'githubciXXXXXXXXXXXXXXXXXXXXXXXX([a-zA-Z0-9]{16})?' \|"
-    assert_output --regexp "\| githubciXXXXXXXXXXXXXXXXXXXXXXXX([a-zA-Z0-9]{16})? \|"
+    assert_output --partial 'Machine'
+    assert_output --regexp " 'githubciXXXXXXXXXXXXXXXXXXXXXXXX([a-zA-Z0-9]{16})?' "
+    assert_output --regexp " githubciXXXXXXXXXXXXXXXXXXXXXXXX([a-zA-Z0-9]{16})? "
 }
 
 @test "cscli decisions list, incorrect parameters" {

+ 1 - 1
tests/bin/generate-hub-tests

@@ -37,7 +37,7 @@ EOT
 
 echo "Generating hub tests..."
 
-for testname in $("${CSCLI}" --crowdsec "${CROWDSEC}" --cscli "${CSCLI}" hubtest --hub "${hubdir}" list -o json | grep -v NAME | grep -v -- '-------' | awk '{print $1}'); do
+for testname in $("${CSCLI}" --crowdsec "${CROWDSEC}" --cscli "${CSCLI}" hubtest --hub "${hubdir}" list -o json | jq -r '.[] | .Name'); do
     cat << EOT >> "${HUBTESTS_BATS}"
 
 @test "${testname}" {

+ 8 - 0
tests/lib/setup_file.sh

@@ -148,6 +148,7 @@ assert_json() {
 }
 export -f assert_json
 
+# like assert_output, but for stderr
 assert_stderr() {
     oldout="${output}"
     run -0 echo "${stderr}"
@@ -156,6 +157,7 @@ assert_stderr() {
 }
 export -f assert_stderr
 
+# like refute_output, but for stderr
 refute_stderr() {
     oldout="${output}"
     run -0 echo "${stderr}"
@@ -164,6 +166,7 @@ refute_stderr() {
 }
 export -f refute_stderr
 
+# like assert_output, but for stderr
 assert_stderr_line() {
     oldout="${output}"
     run -0 echo "${stderr}"
@@ -172,3 +175,8 @@ assert_stderr_line() {
 }
 export -f assert_stderr_line
 
+# remove color and style sequences from stdin
+plaintext() {
+    sed -E 's/\x1B\[[0-9;]*[JKmsu]//g'
+}
+export -f plaintext