diff --git a/pkger/models.go b/pkger/models.go index d07a92ab41..1d5565b45c 100644 --- a/pkger/models.go +++ b/pkger/models.go @@ -167,13 +167,14 @@ type ChartKind string // available chart kinds const ( - ChartKindUnknown ChartKind = "" - ChartKindSingleStat ChartKind = "single_stat" + ChartKindUnknown ChartKind = "" + ChartKindSingleStat ChartKind = "single_stat" + ChartKindSingleStatPlusLine ChartKind = "single_stat_plus_line" ) func (c ChartKind) ok() bool { switch c { - case ChartKindSingleStat: + case ChartKindSingleStat, ChartKindSingleStatPlusLine: return true default: return false @@ -457,8 +458,10 @@ type chart struct { DecimalPlaces int EnforceDecimals bool Shade bool - Colors []*color - Queries []query + Legend legend + Colors colors + Queries queries + Axes axes XCol, YCol string XPos, YPos int @@ -478,8 +481,27 @@ func (c chart) properties() influxdb.ViewProperties { }, Note: c.Note, ShowNoteWhenEmpty: c.NoteOnEmpty, - Queries: queries(c.Queries).influxDashQueries(), - ViewColors: colors(c.Colors).influxViewColors(), + Queries: c.Queries.influxDashQueries(), + ViewColors: c.Colors.influxViewColors(), + } + case ChartKindSingleStatPlusLine: + return influxdb.LinePlusSingleStatProperties{ + Type: "line-plus-single-stat", + Prefix: c.Prefix, + Suffix: c.Suffix, + DecimalPlaces: influxdb.DecimalPlaces{ + IsEnforced: c.EnforceDecimals, + Digits: int32(c.DecimalPlaces), + }, + Note: c.Note, + ShowNoteWhenEmpty: c.NoteOnEmpty, + XColumn: c.XCol, + YColumn: c.YCol, + ShadeBelow: c.Shade, + Legend: c.Legend.influxLegend(), + Queries: c.Queries.influxDashQueries(), + ViewColors: c.Colors.influxViewColors(), + Axes: c.Axes.influxAxes(), } default: return nil @@ -491,8 +513,8 @@ func (c chart) validProperties() []failure { validatorFns := []func() []failure{ c.validBaseProps, - queries(c.Queries).valid, - colors(c.Colors).valid, + c.Queries.valid, + c.Colors.valid, } for _, validatorFn := range validatorFns { fails = append(fails, validatorFn()...) @@ -501,14 +523,10 @@ func (c chart) validProperties() []failure { // chart kind specific validations switch c.Kind { case ChartKindSingleStat: - for i, clr := range c.Colors { - if clr.Type != colorTypeText { - fails = append(fails, failure{ - Field: fmt.Sprintf("colors[%d].type", i), - Msg: "single stat charts must have color type of \"text\"", - }) - } - } + fails = append(fails, c.Colors.hasTypes(colorTypeText)...) + case ChartKindSingleStatPlusLine: + fails = append(fails, c.Colors.hasTypes(colorTypeText, colorTypeScale)...) + fails = append(fails, c.Axes.hasAxes("x", "y")...) } return fails @@ -533,7 +551,8 @@ func (c chart) validBaseProps() []failure { } const ( - colorTypeText = "text" + colorTypeText = "text" + colorTypeScale = "scale" ) type color struct { @@ -565,6 +584,25 @@ func (c colors) influxViewColors() []influxdb.ViewColor { return iColors } +func (c colors) hasTypes(types ...string) []failure { + tMap := make(map[string]bool) + for _, cc := range c { + tMap[cc.Type] = true + } + + var failures []failure + for _, t := range types { + if !tMap[t] { + failures = append(failures, failure{ + Field: "colors", + Msg: fmt.Sprintf("type not found: %q", t), + }) + } + } + + return failures +} + func (c colors) valid() []failure { var fails []failure if len(c) == 0 { @@ -626,3 +664,60 @@ func (q queries) valid() []failure { return fails } + +type axis struct { + Base string + Label string + Name string + Prefix string + Scale string + Suffix string +} + +type axes []axis + +func (a axes) influxAxes() map[string]influxdb.Axis { + m := make(map[string]influxdb.Axis) + for _, ax := range a { + m[ax.Name] = influxdb.Axis{ + Bounds: []string{}, + Label: ax.Label, + Prefix: ax.Prefix, + Suffix: ax.Suffix, + Base: ax.Base, + Scale: ax.Scale, + } + } + return m +} + +func (a axes) hasAxes(expectedAxes ...string) []failure { + mAxes := make(map[string]bool) + for _, ax := range a { + mAxes[ax.Name] = true + } + + var failures []failure + for _, expected := range expectedAxes { + if !mAxes[expected] { + failures = append(failures, failure{ + Field: "axes", + Msg: fmt.Sprintf("axis not found: %q", expected), + }) + } + } + + return failures +} + +type legend struct { + Orientation string + Type string +} + +func (l legend) influxLegend() influxdb.Legend { + return influxdb.Legend{ + Type: l.Type, + Orientation: l.Orientation, + } +} diff --git a/pkger/parser.go b/pkger/parser.go index d1f59734ec..9c26017dc2 100644 --- a/pkger/parser.go +++ b/pkger/parser.go @@ -577,6 +577,11 @@ func parseChart(r Resource) (chart, []failure) { Width: r.intShort("width"), } + if leg, ok := ifaceMapToResource(r["legend"]); ok { + c.Legend.Type = leg.stringShort("type") + c.Legend.Orientation = leg.stringShort("orientation") + } + if dp, ok := r.int("decimalPlaces"); ok { c.EnforceDecimals = true c.DecimalPlaces = dp @@ -585,7 +590,7 @@ func parseChart(r Resource) (chart, []failure) { var failures []failure for _, rq := range r.slcResource("queries") { c.Queries = append(c.Queries, query{ - Query: rq.stringShort("query"), + Query: strings.TrimSpace(rq.stringShort("query")), }) } @@ -599,6 +604,17 @@ func parseChart(r Resource) (chart, []failure) { }) } + for _, ra := range r.slcResource("axes") { + c.Axes = append(c.Axes, axis{ + Base: ra.stringShort("base"), + Label: ra.stringShort("label"), + Name: ra.Name(), + Prefix: ra.stringShort("prefix"), + Scale: ra.stringShort("scale"), + Suffix: ra.stringShort("suffix"), + }) + } + if fails := c.validProperties(); len(fails) > 0 { failures = append(failures, fails...) } @@ -716,8 +732,15 @@ func (r Resource) intShort(key string) int { } func (r Resource) string(key string) (string, bool) { - s, ok := r[key].(string) - return s, ok + if s, ok := r[key].(string); ok { + return s, true + } + + if i, ok := r[key].(int); ok { + return strconv.Itoa(i), true + } + + return "", false } func (r Resource) stringShort(key string) string { diff --git a/pkger/parser_test.go b/pkger/parser_test.go index 293c194bd9..c937c1ef43 100644 --- a/pkger/parser_test.go +++ b/pkger/parser_test.go @@ -532,6 +532,7 @@ spec: props, ok := actualChart.Properties.(influxdb.SingleStatViewProperties) require.True(t, ok) + assert.Equal(t, "single-stat", props.GetType()) assert.Equal(t, "single stat note", props.Note) assert.True(t, props.ShowNoteWhenEmpty) assert.True(t, props.DecimalPlaces.IsEnforced) @@ -593,8 +594,8 @@ spec: }, { name: "no colors provided", - numErrs: 1, - errFields: []string{"charts[0].colors"}, + numErrs: 2, + errFields: []string{"charts[0].colors", "charts[0].colors"}, ymlStr: `apiVersion: 0.1.0 kind: Package meta: @@ -751,7 +752,436 @@ spec: resErr := pErr.Resources[0] assert.Equal(t, "dashboard", resErr.Kind) - require.Len(t, resErr.ValidationFails, 1) + require.Len(t, resErr.ValidationFails, tt.numErrs) + for i, vFail := range resErr.ValidationFails { + assert.Equal(t, tt.errFields[i], vFail.Field) + } + } + t.Run(tt.name, fn) + } + }) + }) + + t.Run("single stat plus line chart", func(t *testing.T) { + testfileRunner(t, "testdata/dashboard_single_stat_plus_line", func(t *testing.T, pkg *Pkg) { + sum := pkg.Summary() + require.Len(t, sum.Dashboards, 1) + + actual := sum.Dashboards[0] + assert.Equal(t, "dash_1", actual.Name) + assert.Equal(t, "desc1", actual.Description) + + require.Len(t, actual.Charts, 1) + actualChart := actual.Charts[0] + assert.Equal(t, ChartKindSingleStatPlusLine, actualChart.Kind) + assert.Equal(t, 3, actualChart.Height) + assert.Equal(t, 6, actualChart.Width) + assert.Equal(t, 1, actualChart.XPosition) + assert.Equal(t, 2, actualChart.YPosition) + + props, ok := actualChart.Properties.(influxdb.LinePlusSingleStatProperties) + require.True(t, ok) + assert.Equal(t, "single stat plus line note", props.Note) + assert.True(t, props.ShowNoteWhenEmpty) + assert.True(t, props.DecimalPlaces.IsEnforced) + assert.Equal(t, int32(1), props.DecimalPlaces.Digits) + assert.Equal(t, "days", props.Suffix) + assert.Equal(t, "sumtin", props.Prefix) + assert.Equal(t, "leg_type", props.Legend.Type) + assert.Equal(t, "horizontal", props.Legend.Orientation) + + require.Len(t, props.Queries, 1) + q := props.Queries[0] + expectedQuery := `from(bucket: v.bucket) |> range(start: v.timeRangeStart) |> filter(fn: (r) => r._measurement == "mem") |> filter(fn: (r) => r._field == "used_percent") |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false) |> yield(name: "mean")` + assert.Equal(t, expectedQuery, q.Text) + assert.Equal(t, "advanced", q.EditMode) + + for _, key := range []string{"x", "y"} { + xAxis, ok := props.Axes[key] + require.True(t, ok, "key="+key) + assert.Equal(t, "10", xAxis.Base, "key="+key) + assert.Equal(t, key+"_label", xAxis.Label, "key="+key) + assert.Equal(t, key+"_prefix", xAxis.Prefix, "key="+key) + assert.Equal(t, "linear", xAxis.Scale, "key="+key) + assert.Equal(t, key+"_suffix", xAxis.Suffix, "key="+key) + } + + require.Len(t, props.ViewColors, 2) + c := props.ViewColors[0] + assert.NotZero(t, c.ID) + assert.Equal(t, "laser", c.Name) + assert.Equal(t, "text", c.Type) + assert.Equal(t, "#8F8AF4", c.Hex) + assert.Equal(t, 3.0, c.Value) + + c = props.ViewColors[1] + assert.NotZero(t, c.ID) + assert.Equal(t, "android", c.Name) + assert.Equal(t, "scale", c.Type) + assert.Equal(t, "#F4CF31", c.Hex) + assert.Equal(t, 1.0, c.Value) + }) + + t.Run("handles invalid config", func(t *testing.T) { + tests := []struct { + name string + ymlStr string + numErrs int + errFields []string + }{ + { + name: "color missing hex value", + numErrs: 1, + errFields: []string{"charts[0].colors[0].hex"}, + ymlStr: `apiVersion: 0.1.0 +kind: Package +meta: + pkgName: pkg_name + pkgVersion: 1 + description: pack description +spec: + resources: + - kind: Dashboard + name: dash_1 + description: desc1 + charts: + - kind: Single_Stat_Plus_Line + name: single stat plus line + width: 6 + height: 3 + queries: + - query: > + from(bucket: v.bucket) |> range(start: v.timeRangeStart) |> filter(fn: (r) => r._measurement == "system") |> filter(fn: (r) => r._field == "uptime") |> last() |> map(fn: (r) => ({r with _value: r._value / 86400})) |> yield(name: "last") + colors: + - name: laser + type: text + - name: android + type: scale + hex: "#F4CF31" + axes: + - name : "x" + label: x_label + base: 10 + scale: linear + - name: "y" + label: y_label + base: 10 + scale: linear +`, + }, + { + name: "no colors provided", + numErrs: 3, + errFields: []string{"charts[0].colors", "charts[0].colors", "charts[0].colors"}, + ymlStr: `apiVersion: 0.1.0 +kind: Package +meta: + pkgName: pkg_name + pkgVersion: 1 + description: pack description +spec: + resources: + - kind: Dashboard + name: dash_1 + description: desc1 + charts: + - kind: Single_Stat_Plus_Line + name: single stat plus line + width: 6 + height: 3 + queries: + - query: > + from(bucket: v.bucket) |> range(start: v.timeRangeStart) |> filter(fn: (r) => r._measurement == "system") |> filter(fn: (r) => r._field == "uptime") |> last() |> map(fn: (r) => ({r with _value: r._value / 86400})) |> yield(name: "last") + axes: + - name : "x" + label: x_label + base: 10 + scale: linear + - name: "y" + label: y_label + base: 10 + scale: linear +`, + }, + { + name: "missing query value", + numErrs: 1, + errFields: []string{"charts[0].queries[0].query"}, + ymlStr: `apiVersion: 0.1.0 +kind: Package +meta: + pkgName: pkg_name + pkgVersion: 1 + description: pack description +spec: + resources: + - kind: Dashboard + name: dash_1 + description: desc1 + charts: + - kind: Single_Stat_Plus_Line + name: single stat plus line + width: 6 + height: 3 + queries: + - query: + colors: + - name: laser + type: text + hex: "#abcabc" + - name: android + type: scale + hex: "#F4CF31" + axes: + - name : "x" + label: x_label + base: 10 + scale: linear + - name: "y" + label: y_label + base: 10 + scale: linear +`, + }, + { + name: "no queries provided", + numErrs: 1, + errFields: []string{"charts[0].queries"}, + ymlStr: `apiVersion: 0.1.0 +kind: Package +meta: + pkgName: pkg_name + pkgVersion: 1 + description: pack description +spec: + resources: + - kind: Dashboard + name: dash_1 + description: desc1 + charts: + - kind: Single_Stat_Plus_Line + name: single stat plus line + width: 6 + height: 3 + colors: + - name: laser + type: text + hex: "red" + - name: android + type: scale + hex: "#F4CF31" + axes: + - name : "x" + label: x_label + base: 10 + scale: linear + - name: "y" + label: y_label + base: 10 + scale: linear`, + }, + { + name: "no width provided", + numErrs: 1, + errFields: []string{"charts[0].width"}, + ymlStr: `apiVersion: 0.1.0 +kind: Package +meta: + pkgName: pkg_name + pkgVersion: 1 + description: pack description +spec: + resources: + - kind: Dashboard + name: dash_1 + description: desc1 + charts: + - kind: Single_Stat_Plus_Line + name: single stat plus line + height: 3 + queries: + - query: > + from(bucket: v.bucket) |> range(start: v.timeRangeStart) |> filter(fn: (r) => r._measurement == "system") |> filter(fn: (r) => r._field == "uptime") |> last() |> map(fn: (r) => ({r with _value: r._value / 86400})) |> yield(name: "last") + colors: + - name: laser + type: text + hex: green + - name: android + type: scale + hex: "#F4CF31" + axes: + - name : "x" + label: x_label + base: 10 + scale: linear + - name: "y" + label: y_label + base: 10 + scale: linear`, + }, + { + name: "no height provided", + numErrs: 1, + errFields: []string{"charts[0].height"}, + ymlStr: `apiVersion: 0.1.0 +kind: Package +meta: + pkgName: pkg_name + pkgVersion: 1 + description: pack description +spec: + resources: + - kind: Dashboard + name: dash_1 + description: desc1 + charts: + - kind: Single_Stat_Plus_Line + name: single stat plus line + width: 3 + queries: + - query: > + from(bucket: v.bucket) |> range(start: v.timeRangeStart) |> filter(fn: (r) => r._measurement == "system") |> filter(fn: (r) => r._field == "uptime") |> last() |> map(fn: (r) => ({r with _value: r._value / 86400})) |> yield(name: "last") + colors: + - name: laser + type: text + hex: green + - name: android + type: scale + hex: "#F4CF31" + axes: + - name : "x" + label: x_label + base: 10 + scale: linear + - name: "y" + label: y_label + base: 10 + scale: linear`, + }, + { + name: "missing text color but has scale color", + numErrs: 1, + errFields: []string{"charts[0].colors"}, + ymlStr: `apiVersion: 0.1.0 +kind: Package +meta: + pkgName: pkg_name + pkgVersion: 1 + description: pack description +spec: + resources: + - kind: Dashboard + name: dash_1 + description: desc1 + charts: + - kind: Single_Stat_Plus_Line + name: single stat plus line + width: 3 + height: 3 + queries: + - query: > + from(bucket: v.bucket) |> range(start: v.timeRangeStart) |> filter(fn: (r) => r._measurement == "system") |> filter(fn: (r) => r._field == "uptime") |> last() |> map(fn: (r) => ({r with _value: r._value / 86400})) |> yield(name: "last") + colors: + - name: android + type: scale + hex: "#F4CF31" + axes: + - name : "x" + label: x_label + base: 10 + scale: linear + - name: "y" + label: y_label + base: 10 + scale: linear`, + }, + { + name: "missing x axis", + numErrs: 1, + errFields: []string{"charts[0].axes"}, + ymlStr: `apiVersion: 0.1.0 +kind: Package +meta: + pkgName: pkg_name + pkgVersion: 1 + description: pack description +spec: + resources: + - kind: Dashboard + name: dash_1 + description: desc1 + charts: + - kind: Single_Stat_Plus_Line + name: single stat plus line + width: 3 + height: 3 + queries: + - query: > + from(bucket: v.bucket) |> range(start: v.timeRangeStart) |> filter(fn: (r) => r._measurement == "system") |> filter(fn: (r) => r._field == "uptime") |> last() |> map(fn: (r) => ({r with _value: r._value / 86400})) |> yield(name: "last") + colors: + - name: first + type: text + hex: "#aabbaa" + - name: android + type: scale + hex: "#F4CF31" + axes: + - name: "y" + label: y_label + base: 10 + scale: linear`, + }, + { + name: "missing y axis", + numErrs: 1, + errFields: []string{"charts[0].axes"}, + ymlStr: `apiVersion: 0.1.0 +kind: Package +meta: + pkgName: pkg_name + pkgVersion: 1 + description: pack description +spec: + resources: + - kind: Dashboard + name: dash_1 + description: desc1 + charts: + - kind: Single_Stat_Plus_Line + name: single stat plus line + width: 3 + height: 3 + queries: + - query: > + from(bucket: v.bucket) |> range(start: v.timeRangeStart) |> filter(fn: (r) => r._measurement == "system") |> filter(fn: (r) => r._field == "uptime") |> last() |> map(fn: (r) => ({r with _value: r._value / 86400})) |> yield(name: "last") + colors: + - name: first + type: text + hex: "#aabbaa" + - name: android + type: scale + hex: "#F4CF31" + axes: + - name: "x" + base: 10 + scale: linear`, + }, + } + + for _, tt := range tests { + fn := func(t *testing.T) { + _, err := Parse(EncodingYAML, FromString(tt.ymlStr)) + require.Error(t, err) + + pErr, ok := IsParseErr(err) + require.True(t, ok, err) + + require.Len(t, pErr.Resources, 1) + + resErr := pErr.Resources[0] + assert.Equal(t, "dashboard", resErr.Kind) + + require.Len(t, resErr.ValidationFails, tt.numErrs) for i, vFail := range resErr.ValidationFails { assert.Equal(t, tt.errFields[i], vFail.Field) } diff --git a/pkger/testdata/dashboard_single_stat_plus_line.json b/pkger/testdata/dashboard_single_stat_plus_line.json new file mode 100644 index 0000000000..1d5905b2dd --- /dev/null +++ b/pkger/testdata/dashboard_single_stat_plus_line.json @@ -0,0 +1,77 @@ +{ + "apiVersion": "0.1.0", + "kind": "Package", + "meta": { + "pkgName": "pkg_name", + "pkgVersion": "1", + "description": "pack description" + }, + "spec": { + "resources": [ + { + "kind": "Dashboard", + "name": "dash_1", + "description": "desc1", + "charts": [ + { + "kind": "Single_Stat_Plus_Line", + "name": "single stat plus line", + "prefix": "sumtin", + "suffix": "days", + "note": "single stat plus line note", + "noteOnEmpty": true, + "xPos": 1, + "yPos": 2, + "width": 6, + "height": 3, + "decimalPlaces": 1, + "shade": true, + "xColumn": "_time", + "yColumn": "_value", + "legend": { + "type": "leg_type", + "orientation": "horizontal" + }, + "queries": [ + { + "query": "from(bucket: v.bucket) |> range(start: v.timeRangeStart) |> filter(fn: (r) => r._measurement == \"mem\") |> filter(fn: (r) => r._field == \"used_percent\") |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false) |> yield(name: \"mean\")" + } + ], + "colors": [ + { + "name": "laser", + "type": "text", + "hex": "#8F8AF4", + "value": 3 + }, + { + "name": "android", + "type": "scale", + "hex": "#F4CF31", + "value": 1 + } + ], + "axes":[ + { + "name": "x", + "label": "x_label", + "prefix": "x_prefix", + "suffix": "x_suffix", + "base": "10", + "scale": "linear" + }, + { + "name": "y", + "label": "y_label", + "prefix": "y_prefix", + "suffix": "y_suffix", + "base": "10", + "scale": "linear" + } + ] + } + ] + } + ] + } +} diff --git a/pkger/testdata/dashboard_single_stat_plus_line.yml b/pkger/testdata/dashboard_single_stat_plus_line.yml new file mode 100644 index 0000000000..0c7a1a9e14 --- /dev/null +++ b/pkger/testdata/dashboard_single_stat_plus_line.yml @@ -0,0 +1,52 @@ +apiVersion: 0.1.0 +kind: Package +meta: + pkgName: pkg_name + pkgVersion: 1 + description: pack description +spec: + resources: + - kind: Dashboard + name: dash_1 + description: desc1 + charts: + - kind: Single_Stat_Plus_Line + name: single stat plus line + note: single stat plus line note + noteOnEmpty: true + decimalPlaces: 1 + prefix: sumtin + suffix: days + xPos: 1 + yPos: 2 + width: 6 + height: 3 + shade: true + legend: + type: leg_type + orientation: horizontal + queries: + - query: > + from(bucket: v.bucket) |> range(start: v.timeRangeStart) |> filter(fn: (r) => r._measurement == "mem") |> filter(fn: (r) => r._field == "used_percent") |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: false) |> yield(name: "mean") + colors: + - name: laser + type: text + hex: "#8F8AF4" + value: 3 + - name: android + type: scale + hex: "#F4CF31" + value: 1 + axes: + - name : "x" + label: x_label + prefix: x_prefix + suffix: x_suffix + base: 10 + scale: linear + - name: "y" + label: y_label + prefix: y_prefix + suffix: y_suffix + base: 10 + scale: linear