diff --git a/cue/dashboard/dashboard_go_gen.cue b/cue/dashboard/dashboard_go_gen.cue index a082989..653cdfa 100644 --- a/cue/dashboard/dashboard_go_gen.cue +++ b/cue/dashboard/dashboard_go_gen.cue @@ -24,6 +24,19 @@ import plugin_9 "github.com/perses/spec/cue/plugin" plugin: plugin_9.#Plugin @go(Plugin) queries?: [...#Query] @go(Queries,[]Query) links?: [...#Link] @go(Links,[]Link) + annotations?: null | #PanelAnnotations @go(Annotations,*PanelAnnotations) +} + +// PanelAnnotations controls the annotations displayed on a single panel. +#PanelAnnotations: { + // Enabled toggles the display of dashboard-level annotations on this panel. + // A nil value is treated as enabled, matching the "on by default" behavior of the + // two-level annotation design. + enabled?: null | bool @go(Enabled,*bool) + + // Definitions holds annotations defined locally on this panel. They use the same shape + // as dashboard-level annotations (AnnotationSpec). + definitions?: [...#AnnotationSpec] @go(Definitions,[]AnnotationSpec) } #Panel: { diff --git a/go/dashboard/dashboard.go b/go/dashboard/dashboard.go index 5929c93..e91125e 100644 --- a/go/dashboard/dashboard.go +++ b/go/dashboard/dashboard.go @@ -37,10 +37,22 @@ type PanelDisplay struct { } type PanelSpec struct { - Display *PanelDisplay `json:"display,omitempty" yaml:"display,omitempty"` - Plugin plugin.Plugin `json:"plugin" yaml:"plugin"` - Queries []Query `json:"queries,omitempty" yaml:"queries,omitempty"` - Links []Link `json:"links,omitempty" yaml:"links,omitempty"` + Display *PanelDisplay `json:"display,omitempty" yaml:"display,omitempty"` + Plugin plugin.Plugin `json:"plugin" yaml:"plugin"` + Queries []Query `json:"queries,omitempty" yaml:"queries,omitempty"` + Links []Link `json:"links,omitempty" yaml:"links,omitempty"` + Annotations *PanelAnnotations `json:"annotations,omitempty" yaml:"annotations,omitempty"` +} + +// PanelAnnotations controls the annotations displayed on a single panel. +type PanelAnnotations struct { + // Enabled toggles the display of dashboard-level annotations on this panel. + // A nil value is treated as enabled, matching the "on by default" behavior of the + // two-level annotation design. + Enabled *bool `json:"enabled,omitempty" yaml:"enabled,omitempty"` + // Definitions holds annotations defined locally on this panel. They use the same shape + // as dashboard-level annotations (AnnotationSpec). + Definitions []AnnotationSpec `json:"definitions,omitempty" yaml:"definitions,omitempty"` } type Panel struct { diff --git a/go/dashboard/panel_test.go b/go/dashboard/panel_test.go new file mode 100644 index 0000000..d840555 --- /dev/null +++ b/go/dashboard/panel_test.go @@ -0,0 +1,107 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package dashboard + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +// A panel with an annotations block: the dashboard-level toggle turned off plus one panel-local +// definition. This exercises the new PanelSpec.Annotations field end to end. +const panelWithAnnotationsJSON = `{ + "plugin": {"kind": "TimeSeriesChart", "spec": {}}, + "annotations": { + "enabled": false, + "definitions": [ + { + "display": {"name": "Deploys"}, + "plugin": {"kind": "PrometheusPromQLAnnotation", "spec": {}} + } + ] + } +}` + +func TestPanelSpecAnnotationsRoundTripJSON(t *testing.T) { + var spec PanelSpec + require.NoError(t, json.Unmarshal([]byte(panelWithAnnotationsJSON), &spec)) + + require.NotNil(t, spec.Annotations) + require.NotNil(t, spec.Annotations.Enabled) + assert.False(t, *spec.Annotations.Enabled) + require.Len(t, spec.Annotations.Definitions, 1) + assert.Equal(t, "Deploys", spec.Annotations.Definitions[0].Display.Name) + assert.Equal(t, "PrometheusPromQLAnnotation", spec.Annotations.Definitions[0].Plugin.Kind) + + // Marshal back and unmarshal again; the annotations block must survive the round trip. + data, err := json.Marshal(spec) + require.NoError(t, err) + + var reparsed PanelSpec + require.NoError(t, json.Unmarshal(data, &reparsed)) + assert.Equal(t, spec, reparsed) +} + +func TestPanelSpecAnnotationsDefaultOnWhenOmitted(t *testing.T) { + // A panel without an annotations block leaves the field nil, which the consumer treats as + // "dashboard annotations enabled" (default on). + var spec PanelSpec + require.NoError(t, json.Unmarshal([]byte(`{"plugin": {"kind": "TimeSeriesChart", "spec": {}}}`), &spec)) + assert.Nil(t, spec.Annotations) +} + +func TestPanelSpecAnnotationsRejectsEmptyDefinitionName(t *testing.T) { + // The panel-local definitions reuse AnnotationSpec, whose UnmarshalJSON validates a non-empty + // name. That validation fires automatically for the nested definition, so no separate plumbing + // is needed on PanelSpec. + const invalid = `{ + "plugin": {"kind": "TimeSeriesChart", "spec": {}}, + "annotations": { + "definitions": [ + {"display": {"name": ""}, "plugin": {"kind": "PrometheusPromQLAnnotation", "spec": {}}} + ] + } + }` + var spec PanelSpec + err := json.Unmarshal([]byte(invalid), &spec) + require.Error(t, err) + assert.Contains(t, err.Error(), "annotation name cannot be empty") +} + +func TestPanelSpecAnnotationsRoundTripYAML(t *testing.T) { + const in = ` +plugin: + kind: TimeSeriesChart + spec: {} +annotations: + enabled: true + definitions: + - display: + name: Incidents + plugin: + kind: PrometheusPromQLAnnotation + spec: {} +` + var spec PanelSpec + require.NoError(t, yaml.Unmarshal([]byte(in), &spec)) + require.NotNil(t, spec.Annotations) + require.NotNil(t, spec.Annotations.Enabled) + assert.True(t, *spec.Annotations.Enabled) + require.Len(t, spec.Annotations.Definitions, 1) + assert.Equal(t, "Incidents", spec.Annotations.Definitions[0].Display.Name) +} diff --git a/java/src/main/java/dev/perses/spec/dashboard/panel/PanelAnnotations.java b/java/src/main/java/dev/perses/spec/dashboard/panel/PanelAnnotations.java new file mode 100644 index 0000000..7f691d7 --- /dev/null +++ b/java/src/main/java/dev/perses/spec/dashboard/panel/PanelAnnotations.java @@ -0,0 +1,32 @@ +// Copyright The Perses Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package dev.perses.spec.dashboard.panel; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import dev.perses.spec.dashboard.annotation.AnnotationSpec; + +import java.util.List; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public class PanelAnnotations { + @JsonProperty("enabled") + public Boolean enabled; + + @JsonProperty("definitions") + public List definitions; + + public PanelAnnotations() { + } +} diff --git a/java/src/main/java/dev/perses/spec/dashboard/panel/PanelSpec.java b/java/src/main/java/dev/perses/spec/dashboard/panel/PanelSpec.java index 49f843f..b79ab2f 100644 --- a/java/src/main/java/dev/perses/spec/dashboard/panel/PanelSpec.java +++ b/java/src/main/java/dev/perses/spec/dashboard/panel/PanelSpec.java @@ -33,6 +33,9 @@ public class PanelSpec { @JsonProperty("links") public List links; + @JsonProperty("annotations") + public PanelAnnotations annotations; + public PanelSpec() { } } diff --git a/ts/src/dashboard/panel.ts b/ts/src/dashboard/panel.ts index 333229d..b5e713e 100644 --- a/ts/src/dashboard/panel.ts +++ b/ts/src/dashboard/panel.ts @@ -12,6 +12,7 @@ // limitations under the License. import { Definition, UnknownSpec } from '../common'; +import { AnnotationSpec } from './annotation'; import { Link } from './link'; import { QueryDefinition } from './query-type'; @@ -20,6 +21,13 @@ export interface PanelDisplay { description?: string; } +export interface PanelAnnotations { + // Toggles the display of dashboard-level annotations on this panel. Undefined is treated as enabled. + enabled?: boolean; + // Annotations defined locally on this panel, using the same shape as dashboard-level annotations. + definitions?: AnnotationSpec[]; +} + export interface PanelDefinition extends Definition> { kind: 'Panel'; } @@ -29,6 +37,7 @@ export interface PanelSpec { plugin: Definition; queries?: QueryDefinition[]; links?: Link[]; + annotations?: PanelAnnotations; } /**