diff --git a/cue/dashboard/layout_go_gen.cue b/cue/dashboard/layout_go_gen.cue index df269db..cede1e3 100644 --- a/cue/dashboard/layout_go_gen.cue +++ b/cue/dashboard/layout_go_gen.cue @@ -15,12 +15,28 @@ import "github.com/perses/spec/cue/common" #KindGridLayout: #LayoutKind & "Grid" #KindTabLayout: #LayoutKind & "Tabs" +#RepeatVariableAlignment: _ // #enumRepeatVariableAlignment + +#enumRepeatVariableAlignment: + #RepeatVariableAlignmentHorizontal | + #RepeatVariableAlignmentVertical + +#RepeatVariableAlignmentHorizontal: #RepeatVariableAlignment & "horizontal" +#RepeatVariableAlignmentVertical: #RepeatVariableAlignment & "vertical" + +#RepeatVariable: { + value: string @go(Value) + maxPer?: null | int @go(MaxPer,*int) + alignment?: #RepeatVariableAlignment @go(Alignment) +} + #GridItem: { - x: int @go(X) - y: int @go(Y) - width: int @go(Width) - height: int @go(Height) - content?: null | common.#JSONRef @go(Content,*common.JSONRef) + x: int @go(X) + y: int @go(Y) + width: int @go(Width) + height: int @go(Height) + content?: null | common.#JSONRef @go(Content,*common.JSONRef) + repeatVariable?: null | #RepeatVariable @go(RepeatVariable,*RepeatVariable) } #GridLayoutCollapse: { diff --git a/go/dashboard/layout.go b/go/dashboard/layout.go index 5cd208d..9cb618f 100644 --- a/go/dashboard/layout.go +++ b/go/dashboard/layout.go @@ -69,12 +69,64 @@ func (k *LayoutKind) validate() error { return nil } +type RepeatVariableAlignment string + +const ( + RepeatVariableAlignmentHorizontal RepeatVariableAlignment = "horizontal" + RepeatVariableAlignmentVertical RepeatVariableAlignment = "vertical" +) + +var repeatVariableAlignmentMap = map[RepeatVariableAlignment]bool{ + RepeatVariableAlignmentHorizontal: true, + RepeatVariableAlignmentVertical: true, +} + +func (a *RepeatVariableAlignment) UnmarshalJSON(data []byte) error { + var tmp RepeatVariableAlignment + type plain RepeatVariableAlignment + if err := json.Unmarshal(data, (*plain)(&tmp)); err != nil { + return err + } + if err := (&tmp).validate(); err != nil { + return err + } + *a = tmp + return nil +} + +func (a *RepeatVariableAlignment) UnmarshalYAML(unmarshal func(any) error) error { + var tmp RepeatVariableAlignment + type plain RepeatVariableAlignment + if err := unmarshal((*plain)(&tmp)); err != nil { + return err + } + if err := (&tmp).validate(); err != nil { + return err + } + *a = tmp + return nil +} + +func (a *RepeatVariableAlignment) validate() error { + if _, ok := repeatVariableAlignmentMap[*a]; !ok { + return fmt.Errorf("unknown repeatVariable.alignment %q used", *a) + } + return nil +} + +type RepeatVariable struct { + Value string `json:"value" yaml:"value"` + MaxPer *int `json:"maxPer,omitempty" yaml:"maxPer,omitempty"` + Alignment RepeatVariableAlignment `json:"alignment,omitempty" yaml:"alignment,omitempty"` +} + type GridItem struct { - X int `json:"x" yaml:"x"` - Y int `json:"y" yaml:"y"` - Width int `json:"width" yaml:"width"` - Height int `json:"height" yaml:"height"` - Content *common.JSONRef `json:"content" yaml:"content"` + X int `json:"x" yaml:"x"` + Y int `json:"y" yaml:"y"` + Width int `json:"width" yaml:"width"` + Height int `json:"height" yaml:"height"` + Content *common.JSONRef `json:"content" yaml:"content"` + RepeatVariable *RepeatVariable `json:"repeatVariable,omitempty" yaml:"repeatVariable,omitempty"` } type GridLayoutCollapse struct { diff --git a/go/dashboard/layout_test.go b/go/dashboard/layout_test.go index 680f154..1c869b2 100644 --- a/go/dashboard/layout_test.go +++ b/go/dashboard/layout_test.go @@ -22,8 +22,94 @@ import ( "gopkg.in/yaml.v3" ) -func TestUnmarshalJSONLayout(t *testing.T) { +func TestRepeatVariableAlignmentUnmarshalJSON(t *testing.T) { testSuite := []struct { + title string + jason string + result RepeatVariableAlignment + expectError bool + }{ + { + title: "valid horizontal", + jason: `"horizontal"`, + result: RepeatVariableAlignmentHorizontal, + }, + { + title: "valid vertical", + jason: `"vertical"`, + result: RepeatVariableAlignmentVertical, + }, + { + title: "empty string is rejected", + jason: `""`, + expectError: true, + }, + { + title: "unknown value is rejected", + jason: `"diagonal"`, + expectError: true, + }, + } + for _, test := range testSuite { + t.Run(test.title, func(t *testing.T) { + var result RepeatVariableAlignment + err := json.Unmarshal([]byte(test.jason), &result) + if test.expectError { + assert.Error(t, err) + assert.Contains(t, err.Error(), "unknown repeatVariable.alignment") + } else { + assert.NoError(t, err) + assert.Equal(t, test.result, result) + } + }) + } +} + +func TestRepeatVariableAlignmentUnmarshalYAML(t *testing.T) { + testSuite := []struct { + title string + yamele string + result RepeatVariableAlignment + expectError bool + }{ + { + title: "valid horizontal", + yamele: `horizontal`, + result: RepeatVariableAlignmentHorizontal, + }, + { + title: "valid vertical", + yamele: `vertical`, + result: RepeatVariableAlignmentVertical, + }, + { + title: "empty string is rejected", + yamele: `""`, + expectError: true, + }, + { + title: "unknown value is rejected", + yamele: `diagonal`, + expectError: true, + }, + } + for _, test := range testSuite { + t.Run(test.title, func(t *testing.T) { + var result RepeatVariableAlignment + err := yaml.Unmarshal([]byte(test.yamele), &result) + if test.expectError { + assert.Error(t, err) + assert.Contains(t, err.Error(), "unknown repeatVariable.alignment") + } else { + assert.NoError(t, err) + assert.Equal(t, test.result, result) + } + }) + } +} + +func TestUnmarshalJSONLayout(t *testing.T) { + validTestSuite := []struct { title string jason string result Layout @@ -310,18 +396,137 @@ func TestUnmarshalJSONLayout(t *testing.T) { }, }, }, + { + title: "grid item with repeat variable (value only)", + jason: ` +{ + "kind": "Grid", + "spec": { + "items": [ + { + "x": 0, + "y": 0, + "width": 12, + "height": 4, + "content": { "$ref": "#/panels/panel1" }, + "repeatVariable": { "value": "env" } + } + ] + } +} +`, + result: Layout{ + Kind: KindGridLayout, + Spec: &GridLayoutSpec{ + Items: []GridItem{ + { + X: 0, + Y: 0, + Width: 12, + Height: 4, + Content: &common.JSONRef{ + Ref: "#/panels/panel1", + Path: []string{"panels", "panel1"}, + }, + RepeatVariable: &RepeatVariable{ + Value: "env", + }, + }, + }, + }, + }, + }, + { + title: "grid item with repeat variable (all fields)", + jason: ` +{ + "kind": "Grid", + "spec": { + "items": [ + { + "x": 0, + "y": 0, + "width": 12, + "height": 4, + "content": { "$ref": "#/panels/panel1" }, + "repeatVariable": { "value": "env", "alignment": "vertical", "maxPer": 2 } + } + ] + } +} +`, + result: Layout{ + Kind: KindGridLayout, + Spec: &GridLayoutSpec{ + Items: []GridItem{ + { + X: 0, + Y: 0, + Width: 12, + Height: 4, + Content: &common.JSONRef{ + Ref: "#/panels/panel1", + Path: []string{"panels", "panel1"}, + }, + RepeatVariable: &RepeatVariable{ + Value: "env", + Alignment: RepeatVariableAlignmentVertical, + MaxPer: func() *int { v := 2; return &v }(), + }, + }, + }, + }, + }, + }, } - for _, test := range testSuite { + for _, test := range validTestSuite { t.Run(test.title, func(t *testing.T) { result := Layout{} assert.NoError(t, json.Unmarshal([]byte(test.jason), &result)) assert.Equal(t, test.result, result) }) } + + errorTestSuite := []struct { + title string + jason string + errorMsg string + }{ + { + title: "unknown layout kind", + jason: `{"kind": "Unknown", "spec": {"items": []}}`, + errorMsg: "unknown layout.kind", + }, + { + title: "unknown repeatVariable alignment", + jason: ` + { + "kind": "Grid", + "spec": { + "items": [ + { + "x": 0, "y": 0, "width": 12, "height": 4, + "content": { "$ref": "#/panels/p1" }, + "repeatVariable": { "value": "env", "alignment": "diagonal" } + } + ] + } + }`, + errorMsg: "unknown repeatVariable.alignment", + }, + } + for _, test := range errorTestSuite { + t.Run(test.title, func(t *testing.T) { + result := Layout{} + err := json.Unmarshal([]byte(test.jason), &result) + assert.Error(t, err) + assert.Contains(t, err.Error(), test.errorMsg) + }) + } } func TestUnmarshalYAMLLayout(t *testing.T) { - testSuite := []struct { + validTestSuite := []struct { title string yamele string result Layout @@ -564,12 +769,126 @@ spec: }, }, }, + { + title: "grid item with repeat variable (value only)", + yamele: ` +kind: "Grid" +spec: + items: + - x: 0 + y: 0 + width: 12 + height: 4 + content: + $ref: "#/panels/panel1" + repeatVariable: + value: env +`, + result: Layout{ + Kind: KindGridLayout, + Spec: &GridLayoutSpec{ + Items: []GridItem{ + { + X: 0, + Y: 0, + Width: 12, + Height: 4, + Content: &common.JSONRef{ + Ref: "#/panels/panel1", + Path: []string{"panels", "panel1"}, + }, + RepeatVariable: &RepeatVariable{ + Value: "env", + }, + }, + }, + }, + }, + }, + { + title: "grid item with repeat variable (all fields)", + yamele: ` +kind: "Grid" +spec: + items: + - x: 0 + y: 0 + width: 12 + height: 4 + content: + $ref: "#/panels/panel1" + repeatVariable: + value: env + alignment: vertical + maxPer: 2 +`, + result: Layout{ + Kind: KindGridLayout, + Spec: &GridLayoutSpec{ + Items: []GridItem{ + { + X: 0, + Y: 0, + Width: 12, + Height: 4, + Content: &common.JSONRef{ + Ref: "#/panels/panel1", + Path: []string{"panels", "panel1"}, + }, + RepeatVariable: &RepeatVariable{ + Value: "env", + Alignment: RepeatVariableAlignmentVertical, + MaxPer: func() *int { v := 2; return &v }(), + }, + }, + }, + }, + }, + }, } - for _, test := range testSuite { + for _, test := range validTestSuite { t.Run(test.title, func(t *testing.T) { result := Layout{} assert.NoError(t, yaml.Unmarshal([]byte(test.yamele), &result)) assert.Equal(t, test.result, result) }) } + + errorTestSuite := []struct { + title string + yamele string + errorMsg string + }{ + { + title: "unknown layout kind", + yamele: "kind: Unknown\nspec:\n items: []\n", + errorMsg: "unknown layout.kind", + }, + { + title: "unknown repeatVariable alignment", + yamele: ` +kind: "Grid" +spec: + items: + - x: 0 + y: 0 + width: 12 + height: 4 + content: + $ref: "#/panels/p1" + repeatVariable: + value: env + alignment: diagonal +`, + errorMsg: "unknown repeatVariable.alignment", + }, + } + for _, test := range errorTestSuite { + t.Run(test.title, func(t *testing.T) { + result := Layout{} + err := yaml.Unmarshal([]byte(test.yamele), &result) + assert.Error(t, err) + assert.Contains(t, err.Error(), test.errorMsg) + }) + } } diff --git a/java/src/main/java/dev/perses/spec/dashboard/Layout.java b/java/src/main/java/dev/perses/spec/dashboard/Layout.java index 57fde13..900326b 100644 --- a/java/src/main/java/dev/perses/spec/dashboard/Layout.java +++ b/java/src/main/java/dev/perses/spec/dashboard/Layout.java @@ -29,6 +29,22 @@ public enum LayoutKind {Grid, Tabs} @JsonProperty("spec") public Object spec; // GridLayoutSpec when kind == Grid, TabLayoutSpec when kind == Tabs + public enum RepeatVariableAlignment { + @JsonProperty("horizontal") + horizontal, + @JsonProperty("vertical") + vertical + } + + public static class RepeatVariable { + @JsonProperty(value = "value", required = true) + public String value; + @JsonProperty("maxPer") + public Integer maxPer; + @JsonProperty("alignment") + public RepeatVariableAlignment alignment; + } + public static class GridItem { @JsonProperty("x") public int x; @@ -40,6 +56,8 @@ public static class GridItem { public int height; @JsonProperty("content") public JSONRef content; + @JsonProperty("repeatVariable") + public RepeatVariable repeatVariable; } public static class GridLayoutCollapse { diff --git a/java/src/test/java/dev/perses/spec/dashboard/SpecUnmarshalTest.java b/java/src/test/java/dev/perses/spec/dashboard/SpecUnmarshalTest.java index dd00383..e577e6b 100644 --- a/java/src/test/java/dev/perses/spec/dashboard/SpecUnmarshalTest.java +++ b/java/src/test/java/dev/perses/spec/dashboard/SpecUnmarshalTest.java @@ -14,10 +14,13 @@ package dev.perses.spec.dashboard; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.exc.InvalidFormatException; import org.junit.jupiter.api.Test; import java.io.InputStream; import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; import static org.junit.jupiter.api.Assertions.*; @@ -45,6 +48,49 @@ public void testUnmarshalTabsLayout() throws Exception { assertNotNull(layout.spec); } + @Test + public void testUnmarshalRepeatVariable() throws Exception { + String json = readResource("/dev/perses/spec/dashboard/repeat_variable_dashboard.json"); + + Spec d = mapper.readValue(json, Spec.class); + assertNotNull(d); + assertEquals("Repeat Variable Dashboard", d.display.name); + assertEquals(1, d.layouts.size()); + + Layout layout = d.layouts.getFirst(); + assertEquals(Layout.LayoutKind.Grid, layout.kind); + + @SuppressWarnings("unchecked") + Map spec = (Map) layout.spec; + assertNotNull(spec); + + @SuppressWarnings("unchecked") + List> items = + (List>) spec.get("items"); + assertEquals(1, items.size()); + + @SuppressWarnings("unchecked") + Map rv = (Map) items.getFirst().get("repeatVariable"); + assertNotNull(rv); + assertEquals("env", rv.get("value")); + assertEquals(3, rv.get("maxPer")); + assertEquals("horizontal", rv.get("alignment")); + } + + @Test + public void testUnmarshalRepeatVariableInvalidAlignmentIsRejected() { + String json = """ + { + "value": "env", + "alignment": "diagonal" + } + """; + + assertThrows(InvalidFormatException.class, + () -> mapper.readValue(json, Layout.RepeatVariable.class), + "Expected InvalidFormatException for unknown alignment value"); + } + @Test public void testUnmarshalFullDashboard() throws Exception { String json = readResource("/dev/perses/spec/dashboard/simple_spec_dashboard.json"); diff --git a/java/src/test/resources/dev/perses/spec/dashboard/repeat_variable_dashboard.json b/java/src/test/resources/dev/perses/spec/dashboard/repeat_variable_dashboard.json new file mode 100644 index 0000000..43fdc3f --- /dev/null +++ b/java/src/test/resources/dev/perses/spec/dashboard/repeat_variable_dashboard.json @@ -0,0 +1,29 @@ +{ + "display": { + "name": "Repeat Variable Dashboard" + }, + "panels": {}, + "layouts": [ + { + "kind": "Grid", + "spec": { + "items": [ + { + "x": 0, + "y": 0, + "width": 12, + "height": 6, + "content": { "$ref": "#/spec/panels/panel1" }, + "repeatVariable": { + "value": "env", + "maxPer": 3, + "alignment": "horizontal" + } + } + ] + } + } + ], + "duration": null +} + diff --git a/ts/src/dashboard/layout.ts b/ts/src/dashboard/layout.ts index 4022b5e..75b6147 100644 --- a/ts/src/dashboard/layout.ts +++ b/ts/src/dashboard/layout.ts @@ -29,12 +29,19 @@ export interface GridDefinition { }; } +export interface RepeatVariable { + value: string; + maxPer?: number; + alignment?: 'horizontal' | 'vertical'; +} + export interface GridItemDefinition { x: number; y: number; width: number; height: number; content: PanelRef; + repeatVariable?: RepeatVariable; } export interface TabDefinition { diff --git a/ts/src/dashboard/panel.ts b/ts/src/dashboard/panel.ts index 333229d..9eef89d 100644 --- a/ts/src/dashboard/panel.ts +++ b/ts/src/dashboard/panel.ts @@ -15,6 +15,8 @@ import { Definition, UnknownSpec } from '../common'; import { Link } from './link'; import { QueryDefinition } from './query-type'; +import type { GridItemDefinition } from './layout'; + export interface PanelDisplay { name?: string; description?: string; @@ -46,4 +48,5 @@ export type PanelGroupId = number; export interface PanelEditorValues { groupId: PanelGroupId; panelDefinition: PanelDefinition; + layoutDefinition?: Pick; } diff --git a/ts/src/schema/panel.ts b/ts/src/schema/panel.ts index c3cf327..b1f5967 100644 --- a/ts/src/schema/panel.ts +++ b/ts/src/schema/panel.ts @@ -64,14 +64,28 @@ export function buildPanelDefinitionSchema(pluginSchema: PluginSchema): z.ZodSch }); } +export const repeatVariableSchema = z.object({ + value: z.string().min(1), + maxPer: z.number().int('Provide valid number.').positive().optional(), + alignment: z.enum(['horizontal', 'vertical']).optional(), +}); + +export const layoutDefinitionSchema = z.object({ + repeatVariable: repeatVariableSchema.optional(), + width: z.number().int().positive(), + height: z.number().int().positive(), +}); + export const panelEditorSchema: z.ZodSchema = z.object({ groupId: z.number(), panelDefinition: panelDefinitionSchema, + layoutDefinition: layoutDefinitionSchema.optional(), }); export function buildPanelEditorSchema(pluginSchema: PluginSchema): z.ZodSchema { return z.object({ groupId: z.number(), panelDefinition: buildPanelDefinitionSchema(pluginSchema), + layoutDefinition: layoutDefinitionSchema.optional(), }); }