diff --git a/docs/user_guide/outputs/asciigraph_output.md b/docs/user_guide/outputs/asciigraph_output.md new file mode 100644 index 00000000..ea522911 --- /dev/null +++ b/docs/user_guide/outputs/asciigraph_output.md @@ -0,0 +1,155 @@ +`gnmic` supports displaying collected metrics as an ASCII graph on the terminal. +The graph is generated using the [asciigraph](https://github.com/guptarohit/asciigraph) package. + +### Configuration sample + +```yaml + +outputs: + output1: + # required + type: asciigraph + # string, the graph caption + caption: + # integer, the graph height. If unset, defaults to the terminal height + height: + # integer, the graph width. If unset, defaults to the terminal width + width: + # float, the graph minimum value for the vertical axis. + lower-bound: + # float, the graph minimum value for the vertical axis. + upper-bound: + # integer, the graph left offset. + offset: + # integer, the decimal point precision of the label values. + precision: + # string, the caption color. one of ANSI colors. + caption-color: + # string, the axis color. one of ANSI colors. + axis-color: + # string, the label color. one of ANSI colors. + label-color: + # duration, the graph refresh timer. + refresh-timer: 1s + # string, one of `overwrite`, `if-not-present`, `` + # This field allows populating/changing the value of Prefix.Target in the received message. + # if set to ``, nothing changes + # if set to `overwrite`, the target value is overwritten using the template configured under `target-template` + # if set to `if-not-present`, the target value is populated only if it is empty, still using the `target-template` + add-target: + # string, a GoTemplate that allows for the customization of the target field in Prefix.Target. + # it applies only if the previous field `add-target` is not empty. + # if left empty, it defaults to: + # {{- if index . "subscription-target" -}} + # {{ index . "subscription-target" }} + # {{- else -}} + # {{ index . "source" | host }} + # {{- end -}}` + # which will set the target to the value configured under `subscription.$subscription-name.target` if any, + # otherwise it will set it to the target name stripped of the port number (if present) + target-template: + # list of processors to apply on the message before writing + event-processors: + # bool enable debug + debug: false +``` + +### Example + +This example shows how to use the `asciigraph` output. + +gNMIc config + +```shell +cat gnmic_asciiout.yaml +``` + +```yaml +targets: + clab-nfd33-spine1-1: + username: admin + password: NokiaSrl1! + skip-verify: true + +subscriptions: + sub1: + paths: + - /interface[name=ethernet-1/3]/statistics/out-octets + - /interface[name=ethernet-1/3]/statistics/in-octets + stream-mode: sample + sample-interval: 1s + encoding: ascii + +outputs: + out1: + type: asciigraph + caption: in/out octets per second + event-processors: + - rate + +processors: + rate: + event-starlark: + script: rate.star +``` + +Starlark processor + +```shell +cat rate.star +``` + +```python +cache = {} + +values_names = [ + '/interface/statistics/out-octets', + '/interface/statistics/in-octets' +] + +N=2 + +def apply(*events): + for e in events: + for value_name in values_names: + v = e.values.get(value_name) + # check if v is not None and is a digit to proceed + if not v: + continue + if not v.isdigit(): + continue + # update cache with the latest value + val_key = "_".join([e.tags["source"], e.tags["interface_name"], value_name]) + if not cache.get(val_key): + # initialize the cache entry if empty + cache.update({val_key: []}) + if len(cache[val_key]) > N: + # remove the oldest entry if the number of entries reached N + cache[val_key] = cache[val_key][1:] + # update cache entry + cache[val_key].append((int(v), e.timestamp)) + # get the list of values + val_list = cache[val_key] + # calculate rate + e.values[value_name+"_rate"] = rate(val_list) + e.values.pop(value_name) + + return events + +def rate(vals): + previous_value, previous_timestamp = None, None + for value, timestamp in vals: + if previous_value != None and previous_timestamp != None: + time_diff = (timestamp - previous_timestamp) / 1000000000 # 1 000 000 000 + if time_diff > 0: + value_diff = value - previous_value + rate = value_diff / time_diff + return rate + + previous_value = value + previous_timestamp = timestamp + + return 0 +``` + + diff --git a/go.mod b/go.mod index 41eee4f5..6aee0b5e 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( github.com/gorilla/mux v1.8.0 github.com/gosnmp/gosnmp v1.35.0 github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 + github.com/guptarohit/asciigraph v0.5.6 github.com/hairyhenderson/gomplate/v3 v3.11.5 github.com/hashicorp/consul/api v1.25.1 github.com/hashicorp/golang-lru/v2 v2.0.7 diff --git a/go.sum b/go.sum index 90342114..6df42ae5 100644 --- a/go.sum +++ b/go.sum @@ -566,6 +566,8 @@ github.com/grafana/regexp v0.0.0-20221122212121-6b5c0a4cb7fd/go.mod h1:M5qHK+eWf github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 h1:Ovs26xHkKqVztRpIrF/92BcuyuQ/YW4NSIpoGtfXNho= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= +github.com/guptarohit/asciigraph v0.5.6 h1:0tra3HEhfdj1sP/9IedrCpfSiXYTtHdCgBhBL09Yx6E= +github.com/guptarohit/asciigraph v0.5.6/go.mod h1:dYl5wwK4gNsnFf9Zp+l06rFiDZ5YtXM6x7SRWZ3KGag= github.com/hairyhenderson/go-fsimpl v0.0.0-20220529183339-9deae3e35047 h1:nSSfN9G8O8XXDqB3aDEHJ8K+0llYYToNlTcWOe1Pti8= github.com/hairyhenderson/go-fsimpl v0.0.0-20220529183339-9deae3e35047/go.mod h1:30RY4Ey+bg+BGKBufZE2IEmxk7hok9U9mjdgZYomwN4= github.com/hairyhenderson/gomplate/v3 v3.11.5 h1:LSDxCw8tWC/ltOzbZaleUNjGJOIEgnR/SN3GM9eClsA= diff --git a/mkdocs.yml b/mkdocs.yml index 54c72224..a4f0e18a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -67,6 +67,7 @@ nav: - TCP: user_guide/outputs/tcp_output.md - UDP: user_guide/outputs/udp_output.md - SNMP: user_guide/outputs/snmp_output.md + - ASCII Graph: user_guide/outputs/asciigraph_output.md - Processors: - Introduction: user_guide/event_processors/intro.md diff --git a/outputs/all/all.go b/outputs/all/all.go index 619aa3e2..80ce0bb5 100644 --- a/outputs/all/all.go +++ b/outputs/all/all.go @@ -9,6 +9,7 @@ package all import ( + _ "github.com/openconfig/gnmic/outputs/asciigraph_output" _ "github.com/openconfig/gnmic/outputs/file" _ "github.com/openconfig/gnmic/outputs/gnmi_output" _ "github.com/openconfig/gnmic/outputs/influxdb_output" diff --git a/outputs/asciigraph_output/asciigraph.go b/outputs/asciigraph_output/asciigraph.go new file mode 100644 index 00000000..6a14e30c --- /dev/null +++ b/outputs/asciigraph_output/asciigraph.go @@ -0,0 +1,537 @@ +// © 2023 Nokia. +// +// This code is a Contribution to the gNMIc project (“Work”) made under the Google Software Grant and Corporate Contributor License Agreement (“CLA”) and governed by the Apache License 2.0. +// No other rights or licenses in or to any of Nokia’s intellectual property are granted for any other purpose. +// This code is provided on an “as is” basis without any warranties of any kind. +// +// SPDX-License-Identifier: Apache-2.0 + +package asciigraph_output + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "math" + "os" + "sort" + "strconv" + "strings" + "sync" + "text/template" + "time" + + "github.com/guptarohit/asciigraph" + "github.com/nsf/termbox-go" + "github.com/openconfig/gnmi/proto/gnmi" + "github.com/prometheus/client_golang/prometheus" + "google.golang.org/protobuf/proto" + + "github.com/openconfig/gnmic/formatters" + "github.com/openconfig/gnmic/outputs" + "github.com/openconfig/gnmic/types" + "github.com/openconfig/gnmic/utils" +) + +const ( + loggingPrefix = "[asciigraph_output:%s] " + defaultRefreshTimer = time.Second + defaultPrecision = 2 + defaultTimeout = 10 * time.Second +) + +var ( + defaultLabelColor = asciigraph.Blue + defaultCaptionColor = asciigraph.Default + defaultAxisColor = asciigraph.Default +) + +func init() { + outputs.Register("asciigraph", func() outputs.Output { + return &asciigraphOutput{ + cfg: &cfg{}, + logger: log.New(io.Discard, loggingPrefix, utils.DefaultLoggingFlags), + eventCh: make(chan *formatters.EventMsg, 100), + m: new(sync.RWMutex), + data: make(map[string]*series), + colors: make(map[asciigraph.AnsiColor]struct{}), + } + }) +} + +// asciigraphOutput // +type asciigraphOutput struct { + cfg *cfg + logger *log.Logger + eventCh chan *formatters.EventMsg + + m *sync.RWMutex + data map[string]*series + colors map[asciigraph.AnsiColor]struct{} + caption string + + captionColor asciigraph.AnsiColor + axisColor asciigraph.AnsiColor + labelColor asciigraph.AnsiColor + evps []formatters.EventProcessor + + targetTpl *template.Template +} + +type series struct { + name string + data []float64 + color asciigraph.AnsiColor +} + +// cfg // +type cfg struct { + // The caption to be displayed under the graph + Caption string `mapstructure:"caption,omitempty" json:"caption,omitempty"` + // The graph height + Height int `mapstructure:"height,omitempty" json:"height,omitempty"` + // The graph width + Width int `mapstructure:"width,omitempty" json:"width,omitempty"` + // The graph minimum value for the vertical axis + LowerBound *float64 `mapstructure:"lower-bound,omitempty" json:"lower-bound,omitempty"` + // the graph maximum value for the vertical axis + UpperBound *float64 `mapstructure:"upper-bound,omitempty" json:"upper-bound,omitempty"` + // The graph offset + Offset int `mapstructure:"offset,omitempty" json:"offset,omitempty"` + // The decimal point precision of the label values + Precision uint `mapstructure:"precision,omitempty" json:"precision,omitempty"` + // The caption color + CaptionColor string `mapstructure:"caption-color,omitempty" json:"caption-color,omitempty"` + // The axis color + AxisColor string `mapstructure:"axis-color,omitempty" json:"axis-color,omitempty"` + // The label color + LabelColor string `mapstructure:"label-color,omitempty" json:"label-color,omitempty"` + // The graph refresh timer + RefreshTimer time.Duration `mapstructure:"refresh-timer,omitempty" json:"refresh-timer,omitempty"` + // Add target the received subscribe responses + AddTarget string `mapstructure:"add-target,omitempty" json:"add-target,omitempty"` + // + TargetTemplate string `mapstructure:"target-template,omitempty" json:"target-template,omitempty"` + // list of event processors + EventProcessors []string `mapstructure:"event-processors,omitempty" json:"event-processors,omitempty"` + // enable extra logging + Debug bool `mapstructure:"debug,omitempty" json:"debug,omitempty"` +} + +func (a *asciigraphOutput) String() string { + b, err := json.Marshal(a.cfg) + if err != nil { + return "" + } + return string(b) +} + +func (a *asciigraphOutput) SetEventProcessors(ps map[string]map[string]interface{}, + logger *log.Logger, + tcs map[string]*types.TargetConfig, + acts map[string]map[string]interface{}) { + for _, epName := range a.cfg.EventProcessors { + if epCfg, ok := ps[epName]; ok { + epType := "" + for k := range epCfg { + epType = k + break + } + if in, ok := formatters.EventProcessors[epType]; ok { + ep := in() + err := ep.Init(epCfg[epType], + formatters.WithLogger(logger), + formatters.WithTargets(tcs), + formatters.WithActions(acts), + ) + if err != nil { + a.logger.Printf("failed initializing event processor '%s' of type='%s': %v", epName, epType, err) + continue + } + a.evps = append(a.evps, ep) + a.logger.Printf("added event processor '%s' of type=%s to file output", epName, epType) + continue + } + a.logger.Printf("%q event processor has an unknown type=%q", epName, epType) + continue + } + a.logger.Printf("%q event processor not found!", epName) + } +} + +func (a *asciigraphOutput) SetLogger(logger *log.Logger) { + if logger != nil && a.logger != nil { + a.logger.SetOutput(logger.Writer()) + a.logger.SetFlags(logger.Flags()) + } +} + +// Init // +func (a *asciigraphOutput) Init(ctx context.Context, name string, cfg map[string]interface{}, opts ...outputs.Option) error { + err := outputs.DecodeConfig(cfg, a.cfg) + if err != nil { + return err + } + + a.logger.SetPrefix(fmt.Sprintf(loggingPrefix, name)) + + for _, opt := range opts { + opt(a) + } + + if a.cfg.TargetTemplate == "" { + a.targetTpl = outputs.DefaultTargetTemplate + } else if a.cfg.AddTarget != "" { + a.targetTpl, err = utils.CreateTemplate("target-template", a.cfg.TargetTemplate) + if err != nil { + return err + } + a.targetTpl = a.targetTpl.Funcs(outputs.TemplateFuncs) + } + // set defaults + err = a.setDefaults() + if err != nil { + return err + } + // + go a.graph(ctx) + a.logger.Printf("initialized asciigraph output: %s", a.String()) + return nil +} + +func (a *asciigraphOutput) setDefaults() error { + a.labelColor = defaultLabelColor + if a.cfg.LabelColor != "" { + if lc, ok := asciigraph.ColorNames[a.cfg.LabelColor]; ok { + a.labelColor = lc + } else { + return fmt.Errorf("unknown label color %s", a.cfg.LabelColor) + } + } + + a.captionColor = defaultCaptionColor + if a.cfg.CaptionColor != "" { + if lc, ok := asciigraph.ColorNames[a.cfg.CaptionColor]; ok { + a.captionColor = lc + } else { + return fmt.Errorf("unknown caption color %s", a.cfg.CaptionColor) + } + } + + a.axisColor = defaultAxisColor + if a.cfg.AxisColor != "" { + if lc, ok := asciigraph.ColorNames[a.cfg.AxisColor]; ok { + a.axisColor = lc + } else { + return fmt.Errorf("unknown axis color %s", a.cfg.AxisColor) + } + + } + + if a.cfg.RefreshTimer <= 0 { + a.cfg.RefreshTimer = defaultRefreshTimer + } + if a.cfg.Precision <= 0 { + a.cfg.Precision = defaultPrecision + } + + return a.getTermSize() +} + +// Write // +func (a *asciigraphOutput) Write(ctx context.Context, rsp proto.Message, meta outputs.Meta) { + if rsp == nil { + return + } + + subRsp, err := outputs.AddSubscriptionTarget(rsp, meta, a.cfg.AddTarget, a.targetTpl) + if err != nil { + a.logger.Printf("failed to add target to the response: %v", err) + return + } + evs, err := formatters.ResponseToEventMsgs(meta["subscription-name"], subRsp, meta, a.evps...) + if err != nil { + a.logger.Printf("failed to convert messages to events: %v", err) + return + } + for _, ev := range evs { + a.WriteEvent(ctx, ev) + } +} + +func (a *asciigraphOutput) WriteEvent(ctx context.Context, ev *formatters.EventMsg) { + ctx, cancel := context.WithTimeout(ctx, defaultTimeout) + defer cancel() + select { + case <-ctx.Done(): + a.logger.Printf("write timeout: %v", ctx.Err()) + case a.eventCh <- ev: + } +} + +// Close // +func (a *asciigraphOutput) Close() error { + return nil +} + +// Metrics // +func (a *asciigraphOutput) RegisterMetrics(reg *prometheus.Registry) { +} + +func (a *asciigraphOutput) SetName(name string) {} + +func (a *asciigraphOutput) SetClusterName(name string) {} + +func (a *asciigraphOutput) SetTargetsConfig(map[string]*types.TargetConfig) {} + +func (a *asciigraphOutput) graph(ctx context.Context) { + for { + select { + case <-ctx.Done(): + return + case ev, ok := <-a.eventCh: + if !ok { + return + } + a.plot(ev) + case <-time.After(a.cfg.RefreshTimer): + a.plot(nil) + } + } +} + +func (a *asciigraphOutput) plot(e *formatters.EventMsg) { + a.m.Lock() + defer a.m.Unlock() + a.getTermSize() + if e != nil && len(e.Values) > 0 { + a.updateData(e) + } + + data, colors := a.buildData() + if len(data) == 0 { + return + } + opts := []asciigraph.Option{ + asciigraph.Height(a.cfg.Height), + asciigraph.Width(a.cfg.Width), + asciigraph.Offset(a.cfg.Offset), + asciigraph.Precision(a.cfg.Precision), + asciigraph.Caption(a.caption), + asciigraph.CaptionColor(a.captionColor), + asciigraph.SeriesColors(colors...), + asciigraph.AxisColor(a.axisColor), + asciigraph.LabelColor(a.labelColor), + } + if a.cfg.LowerBound != nil { + opts = append(opts, asciigraph.LowerBound(*a.cfg.LowerBound)) + } + if a.cfg.UpperBound != nil { + opts = append(opts, asciigraph.UpperBound(*a.cfg.UpperBound)) + } + plot := asciigraph.PlotMany(data, opts...) + asciigraph.Clear() + fmt.Fprintln(os.Stdout, plot) +} + +func (a *asciigraphOutput) updateData(e *formatters.EventMsg) { + if e == nil { + return + } + evs := splitEvent(e) + for _, ev := range evs { + sn := a.buildSeriesName(e) + serie := a.getOrCreateSerie(sn) + for _, v := range ev.Values { + i, err := toFloat(v) + if err != nil { + continue + } + serie.data = append(serie.data, i) + break + } + } +} + +func (a *asciigraphOutput) getOrCreateSerie(name string) *series { + serie, ok := a.data[name] + if ok { + return serie + } + color := a.pickColor() + serie = &series{ + name: name, + data: make([]float64, 0, a.cfg.Width-a.cfg.Offset), + color: color, + } + a.data[name] = serie + a.colors[serie.color] = struct{}{} + + a.setCaption() + return serie +} + +func (a *asciigraphOutput) setCaption() { + seriesNames := make([]string, 0, len(a.data)) + for seriesName := range a.data { + seriesNames = append(seriesNames, seriesName) + } + sort.Strings(seriesNames) + a.caption = "" + if a.cfg.Debug { + a.caption = fmt.Sprintf("(h=%d,w=%d)\n", a.cfg.Height, a.cfg.Width) + } + a.caption = fmt.Sprintf("%s\n", a.cfg.Caption) + + for _, sn := range seriesNames { + color := a.data[sn].color + a.caption += color.String() + "-+- " + sn + asciigraph.Default.String() + "\n" + } +} + +func (a *asciigraphOutput) buildData() ([][]float64, []asciigraph.AnsiColor) { + numgraphs := len(a.data) + series := make([]*series, 0, numgraphs) + // sort series by name + for _, serie := range a.data { + size := len(serie.data) + if size == 0 { + continue + } + if size > a.cfg.Width { + serie.data = serie.data[size-a.cfg.Width:] + } + series = append(series, serie) + } + sort.Slice(series, + func(i, j int) bool { + return series[i].name < series[j].name + }) + + data := make([][]float64, 0, numgraphs) + colors := make([]asciigraph.AnsiColor, 0, numgraphs) + // get float slices and colors + for _, serie := range series { + data = append(data, serie.data) + colors = append(colors, serie.color) + } + return data, colors +} + +func splitEvent(e *formatters.EventMsg) []*formatters.EventMsg { + numVals := len(e.Values) + switch numVals { + case 0: + return nil + case 1: + return []*formatters.EventMsg{e} + } + + evs := make([]*formatters.EventMsg, 0, numVals) + for k, v := range e.Values { + ev := &formatters.EventMsg{ + Name: e.Name, + Timestamp: e.Timestamp, + Tags: e.Tags, + Values: map[string]interface{}{k: v}, + } + evs = append(evs, ev) + } + return evs +} + +func (a *asciigraphOutput) buildSeriesName(e *formatters.EventMsg) string { + sb := &strings.Builder{} + sb.WriteString(e.Name) + sb.WriteString(":") + for k := range e.Values { + sb.WriteString(k) + } + numTags := len(e.Tags) + if numTags == 0 { + return sb.String() + } + sb.WriteString("{") + tagNames := make([]string, 0, numTags) + for k := range e.Tags { + tagNames = append(tagNames, k) + } + sort.Strings(tagNames) + for i, tn := range tagNames { + fmt.Fprintf(sb, "%s=%s", tn, e.Tags[tn]) + if numTags != i+1 { + sb.WriteString(", ") + } + } + sb.WriteString("}") + return sb.String() +} + +func toFloat(v interface{}) (float64, error) { + switch i := v.(type) { + case float64: + return float64(i), nil + case float32: + return float64(i), nil + case int64: + return float64(i), nil + case int32: + return float64(i), nil + case int16: + return float64(i), nil + case int8: + return float64(i), nil + case uint64: + return float64(i), nil + case uint32: + return float64(i), nil + case uint16: + return float64(i), nil + case uint8: + return float64(i), nil + case int: + return float64(i), nil + case uint: + return float64(i), nil + case string: + f, err := strconv.ParseFloat(i, 64) + if err != nil { + return math.NaN(), err + } + return f, err + //lint:ignore SA1019 still need DecimalVal for backward compatibility + case *gnmi.Decimal64: + return float64(i.Digits) / math.Pow10(int(i.Precision)), nil + default: + return math.NaN(), errors.New("getFloat: unknown value is of incompatible type") + } +} + +func (a *asciigraphOutput) pickColor() asciigraph.AnsiColor { + for _, c := range asciigraph.ColorNames { + if _, ok := a.colors[c]; !ok { + return c + } + } + return 0 +} + +func (a *asciigraphOutput) getTermSize() error { + err := termbox.Init() + if err != nil { + return fmt.Errorf("could not initialize a terminal box: %v", err) + } + w, h := termbox.Size() + termbox.Close() + if a.cfg.Width <= 0 || a.cfg.Width > w-10 { + a.cfg.Width = w - 10 + } + numSeries := len(a.data) + if a.cfg.Height <= 0 || a.cfg.Height > h-(numSeries+1)-5 { + a.cfg.Height = h - (numSeries + 1) - 5 + } + return nil +} diff --git a/outputs/output.go b/outputs/output.go index 6fc12d66..4b607828 100644 --- a/outputs/output.go +++ b/outputs/output.go @@ -19,13 +19,14 @@ import ( "github.com/mitchellh/mapstructure" "github.com/openconfig/gnmi/proto/gnmi" + "github.com/prometheus/client_golang/prometheus" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoreflect" + "github.com/openconfig/gnmic/formatters" _ "github.com/openconfig/gnmic/formatters/all" "github.com/openconfig/gnmic/types" "github.com/openconfig/gnmic/utils" - "github.com/prometheus/client_golang/prometheus" - "google.golang.org/protobuf/proto" - "google.golang.org/protobuf/reflect/protoreflect" ) type Output interface { @@ -60,6 +61,7 @@ var OutputTypes = map[string]struct{}{ "gnmi": {}, "jetstream": {}, "snmp": {}, + "asciigraph": {}, } func Register(name string, initFn Initializer) {