mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-01 00:50:42 +01:00
Compare commits
4 Commits
qf-values-
...
nv/text-pa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edf7c74097 | ||
|
|
40c7794d0d | ||
|
|
da9b4644df | ||
|
|
ca618573cb |
@@ -2944,6 +2944,11 @@ components:
|
||||
publicDashboard:
|
||||
$ref: '#/components/schemas/DashboardtypesGettablePublicDasbhboard'
|
||||
type: object
|
||||
DashboardtypesHeaderOptions:
|
||||
properties:
|
||||
hide:
|
||||
type: boolean
|
||||
type: object
|
||||
DashboardtypesHistogramBuckets:
|
||||
properties:
|
||||
bucketCount:
|
||||
@@ -3281,6 +3286,11 @@ components:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelBackground:
|
||||
enum:
|
||||
- solid
|
||||
- transparent
|
||||
type: string
|
||||
DashboardtypesPanelFormatting:
|
||||
properties:
|
||||
decimalPrecision:
|
||||
@@ -3301,6 +3311,7 @@ components:
|
||||
signoz/NumberPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec'
|
||||
signoz/PieChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec'
|
||||
signoz/TablePanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
|
||||
signoz/TextPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
|
||||
signoz/TimeSeriesPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec'
|
||||
propertyName: kind
|
||||
oneOf:
|
||||
@@ -3311,6 +3322,7 @@ components:
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
|
||||
type: object
|
||||
DashboardtypesPanelPluginKind:
|
||||
enum:
|
||||
@@ -3321,6 +3333,7 @@ components:
|
||||
- signoz/TablePanel
|
||||
- signoz/HistogramPanel
|
||||
- signoz/ListPanel
|
||||
- signoz/TextPanel
|
||||
type: string
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec:
|
||||
properties:
|
||||
@@ -3394,6 +3407,18 @@ components:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- signoz/TextPanel
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/DashboardtypesTextPanelSpec'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
@@ -3685,6 +3710,36 @@ components:
|
||||
- color
|
||||
- columnName
|
||||
type: object
|
||||
DashboardtypesTextAlign:
|
||||
enum:
|
||||
- left
|
||||
- center
|
||||
- right
|
||||
type: string
|
||||
DashboardtypesTextMode:
|
||||
enum:
|
||||
- markdown
|
||||
type: string
|
||||
DashboardtypesTextPanelSpec:
|
||||
properties:
|
||||
headerOptions:
|
||||
$ref: '#/components/schemas/DashboardtypesHeaderOptions'
|
||||
mode:
|
||||
$ref: '#/components/schemas/DashboardtypesTextMode'
|
||||
presentation:
|
||||
$ref: '#/components/schemas/DashboardtypesTextPresentation'
|
||||
text:
|
||||
type: string
|
||||
type: object
|
||||
DashboardtypesTextPresentation:
|
||||
properties:
|
||||
background:
|
||||
$ref: '#/components/schemas/DashboardtypesPanelBackground'
|
||||
textAlign:
|
||||
$ref: '#/components/schemas/DashboardtypesTextAlign'
|
||||
verticalAlign:
|
||||
$ref: '#/components/schemas/DashboardtypesVerticalAlign'
|
||||
type: object
|
||||
DashboardtypesTextVariableSpec:
|
||||
properties:
|
||||
constant:
|
||||
@@ -3894,6 +3949,12 @@ components:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesVerticalAlign:
|
||||
enum:
|
||||
- top
|
||||
- center
|
||||
- bottom
|
||||
type: string
|
||||
ErrorsJSON:
|
||||
properties:
|
||||
code:
|
||||
|
||||
@@ -2,6 +2,7 @@ package prometheus
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/prometheus/promql"
|
||||
)
|
||||
@@ -23,5 +24,11 @@ func NewEngine(logger *slog.Logger, cfg Config) *Engine {
|
||||
Timeout: cfg.Timeout,
|
||||
ActiveQueryTracker: activeQueryTracker,
|
||||
LookbackDelta: cfg.LookbackDelta,
|
||||
// The engine calls this for subqueries that do not set a step, such as
|
||||
// `metric[5m:]`, and segfaults if it is nil. 1m matches the default
|
||||
// global evaluation_interval that Prometheus wires here.
|
||||
NoStepSubqueryIntervalFn: func(int64) int64 {
|
||||
return time.Minute.Milliseconds()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
33
pkg/prometheus/engine_test.go
Normal file
33
pkg/prometheus/engine_test.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package prometheus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/prometheus/storage"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNoStepSubqueryDoesNotPanic(t *testing.T) {
|
||||
engine := NewEngine(slog.New(slog.DiscardHandler), Config{Timeout: time.Minute})
|
||||
queryable := storage.QueryableFunc(func(int64, int64) (storage.Querier, error) {
|
||||
return storage.NoopQuerier(), nil
|
||||
})
|
||||
|
||||
qry, err := engine.NewRangeQuery(
|
||||
context.Background(),
|
||||
queryable,
|
||||
nil,
|
||||
"max_over_time(some_metric[5m:])",
|
||||
time.Now().Add(-time.Hour),
|
||||
time.Now(),
|
||||
time.Minute,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
defer qry.Close()
|
||||
|
||||
res := qry.Exec(context.Background())
|
||||
require.NoError(t, res.Err)
|
||||
}
|
||||
@@ -114,8 +114,8 @@ func (d *DashboardSpec) validatePanels() error {
|
||||
return err
|
||||
}
|
||||
panelKind := panel.Spec.Plugin.Kind
|
||||
if len(panel.Spec.Queries) != 1 {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s.spec.queries: panel must have one query, found %d", path, len(panel.Spec.Queries))
|
||||
if err := validatePanelQueryCount(panel.Spec.Queries, panelKind, path); err != nil {
|
||||
return err
|
||||
}
|
||||
allowed := allowedQueryKinds[panelKind]
|
||||
for qi, q := range panel.Spec.Queries {
|
||||
@@ -127,6 +127,22 @@ func (d *DashboardSpec) validatePanels() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePanelQueryCount(queries []Query, panelKind PanelPluginKind, path string) error {
|
||||
if queries == nil {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s.spec.queries: is required and must not be null; use [] for a panel that renders without a query", path)
|
||||
}
|
||||
if panelKind.rendersWithoutQuery() {
|
||||
if len(queries) != 0 {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s.spec.queries: panel kind %q renders without a query and must have queries: [], found %d", path, panelKind, len(queries))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if len(queries) != 1 {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s.spec.queries: panel must have one query, found %d", path, len(queries))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DashboardSpec) validateQuery(qi int, q Query, panelKind PanelPluginKind, path string, allowed []QueryPluginKind) error {
|
||||
queryPath := fmt.Sprintf("%s.spec.queries[%d].spec.plugin", path, qi)
|
||||
if err := validateQueryAllowedForPanel(q.Spec.Plugin, allowed, panelKind, queryPath); err != nil {
|
||||
|
||||
@@ -1085,7 +1085,7 @@ func TestInvalidatePanelWithoutQueries(t *testing.T) {
|
||||
}`)
|
||||
_, err := unmarshalDashboard(data)
|
||||
require.Error(t, err, "expected panel-without-queries to be rejected")
|
||||
assert.Contains(t, err.Error(), "panel must have one query")
|
||||
assert.Contains(t, err.Error(), "spec.queries: is required and must not be null")
|
||||
}
|
||||
|
||||
func TestInvalidatePanelWithEmptyQueriesArray(t *testing.T) {
|
||||
@@ -1135,6 +1135,126 @@ func TestInvalidatePanelWithMultipleDirectQueries(t *testing.T) {
|
||||
assert.Contains(t, err.Error(), "panel must have one query")
|
||||
}
|
||||
|
||||
func TestValidateTextPanel(t *testing.T) {
|
||||
wrapPanel := func(panelSpec string) []byte {
|
||||
return []byte(`{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {"kind": "signoz/TextPanel", "spec": ` + panelSpec + `},
|
||||
"queries": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`)
|
||||
}
|
||||
|
||||
t.Run("fully specified text panel validates", func(t *testing.T) {
|
||||
d, err := unmarshalDashboard(wrapPanel(`{
|
||||
"mode": "markdown",
|
||||
"text": "# Runbook\n\nSee the [oncall doc](https://example.com).",
|
||||
"presentation": {"textAlign": "center", "verticalAlign": "bottom", "background": "transparent"},
|
||||
"headerOptions": {"hide": true}
|
||||
}`))
|
||||
require.NoError(t, err, "expected a fully specified text panel to validate")
|
||||
|
||||
spec, ok := d.Panels["p1"].Spec.Plugin.Spec.(*TextPanelSpec)
|
||||
require.True(t, ok, "expected the panel spec to decode as *TextPanelSpec")
|
||||
assert.Equal(t, TextModeMarkdown, spec.Mode)
|
||||
assert.Equal(t, "# Runbook\n\nSee the [oncall doc](https://example.com).", spec.Text)
|
||||
assert.Equal(t, TextAlignCenter, spec.Presentation.TextAlign)
|
||||
assert.Equal(t, VerticalAlignBottom, spec.Presentation.VerticalAlign)
|
||||
assert.Equal(t, PanelBackgroundTransparent, spec.Presentation.Background)
|
||||
assert.True(t, spec.HeaderOptions.Hide)
|
||||
})
|
||||
|
||||
// The header shows unless explicitly hidden, so the zero value must round-trip
|
||||
// as a shown header.
|
||||
t.Run("omitted fields marshal back as their defaults", func(t *testing.T) {
|
||||
d, err := unmarshalDashboard(wrapPanel(`{}`))
|
||||
require.NoError(t, err, "expected an empty text panel spec to validate")
|
||||
|
||||
out, err := json.Marshal(d.Panels["p1"].Spec.Plugin.Spec)
|
||||
require.NoError(t, err, "marshalling the decoded text panel spec")
|
||||
assert.JSONEq(t, `{
|
||||
"mode": "markdown",
|
||||
"text": "",
|
||||
"presentation": {"textAlign": "left", "verticalAlign": "top", "background": "solid"},
|
||||
"headerOptions": {"hide": false}
|
||||
}`, string(out))
|
||||
})
|
||||
|
||||
t.Run("a text panel carrying a query is rejected", func(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {"kind": "signoz/TextPanel", "spec": {"text": "hi"}},
|
||||
"queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "metrics"}}}}]
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`)
|
||||
_, err := unmarshalDashboard(data)
|
||||
require.Error(t, err, "expected a text panel with a query to be rejected")
|
||||
assert.Contains(t, err.Error(), "renders without a query and must have queries: [], found 1")
|
||||
})
|
||||
|
||||
t.Run("a text panel with null queries is rejected", func(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {"kind": "signoz/TextPanel", "spec": {"text": "hi"}},
|
||||
"queries": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`)
|
||||
_, err := unmarshalDashboard(data)
|
||||
require.Error(t, err, "expected a text panel with null queries to be rejected")
|
||||
assert.Contains(t, err.Error(), "spec.queries: is required and must not be null")
|
||||
})
|
||||
|
||||
t.Run("unknown enum values are rejected", func(t *testing.T) {
|
||||
for field, spec := range map[string]string{
|
||||
"mode": `{"mode": "html"}`,
|
||||
"textAlign": `{"presentation": {"textAlign": "justify"}}`,
|
||||
"verticalAlign": `{"presentation": {"verticalAlign": "middle"}}`,
|
||||
"background": `{"presentation": {"background": "blurred"}}`,
|
||||
} {
|
||||
_, err := unmarshalDashboard(wrapPanel(spec))
|
||||
assert.Error(t, err, "expected an unknown %s value to be rejected", field)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown spec fields are rejected", func(t *testing.T) {
|
||||
for field, spec := range map[string]string{
|
||||
"top level": `{"markdown": "hi"}`,
|
||||
"presentation": `{"presentation": {"horizontalAlign": "left"}}`,
|
||||
"headerOptions": `{"headerOptions": {"show": true}}`,
|
||||
} {
|
||||
_, err := unmarshalDashboard(wrapPanel(spec))
|
||||
assert.Error(t, err, "expected an unknown %s field to be rejected", field)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateRequiredFields(t *testing.T) {
|
||||
wrapVariable := func(pluginKind, pluginSpec string) string {
|
||||
return `{
|
||||
|
||||
@@ -35,6 +35,7 @@ func (PanelPlugin) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
string(PanelKindTable): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec"),
|
||||
string(PanelKindHistogram): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec"),
|
||||
string(PanelKindList): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec"),
|
||||
string(PanelKindText): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -65,6 +66,7 @@ func (PanelPlugin) JSONSchemaOneOf() []any {
|
||||
PanelPluginVariant[TablePanelSpec]{Kind: string(PanelKindTable)},
|
||||
PanelPluginVariant[HistogramPanelSpec]{Kind: string(PanelKindHistogram)},
|
||||
PanelPluginVariant[ListPanelSpec]{Kind: string(PanelKindList)},
|
||||
PanelPluginVariant[TextPanelSpec]{Kind: string(PanelKindText)},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,6 +230,7 @@ var (
|
||||
PanelKindTable: func() any { return new(TablePanelSpec) },
|
||||
PanelKindHistogram: func() any { return new(HistogramPanelSpec) },
|
||||
PanelKindList: func() any { return new(ListPanelSpec) },
|
||||
PanelKindText: func() any { return new(TextPanelSpec) },
|
||||
}
|
||||
queryPluginSpecs = map[QueryPluginKind]func() any{
|
||||
QueryKindBuilder: func() any { return new(BuilderQuerySpec) },
|
||||
@@ -250,6 +253,7 @@ var (
|
||||
PanelKindPieChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
PanelKindTable: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
PanelKindList: {QueryKindBuilder},
|
||||
PanelKindText: {},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -172,7 +172,12 @@ func (d *DashboardV2) GetPanelQuery(startTime, endTime uint64, panelKey string)
|
||||
if !ok || panel == nil {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardInvalidInput, "panel with key %q doesn't exist", panelKey)
|
||||
}
|
||||
// Validator guarantees exactly one query per panel.
|
||||
// A panel kind that renders from its own plugin spec has no query to execute;
|
||||
// asking for its query range is a client mistake.
|
||||
if panel.Spec.Plugin.Kind.rendersWithoutQuery() {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardInvalidWidgetQuery, "panel %q is a %q and has no query to execute", panelKey, panel.Spec.Plugin.Kind)
|
||||
}
|
||||
// Validator guarantees exactly one query for every other panel kind.
|
||||
if len(panel.Spec.Queries) != 1 {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardInvalidWidgetQuery, "panel %q must have exactly one query", panelKey)
|
||||
}
|
||||
|
||||
@@ -173,10 +173,15 @@ const (
|
||||
PanelKindTable PanelPluginKind = "signoz/TablePanel"
|
||||
PanelKindHistogram PanelPluginKind = "signoz/HistogramPanel"
|
||||
PanelKindList PanelPluginKind = "signoz/ListPanel"
|
||||
PanelKindText PanelPluginKind = "signoz/TextPanel"
|
||||
)
|
||||
|
||||
func (PanelPluginKind) Enum() []any {
|
||||
return []any{PanelKindTimeSeries, PanelKindBarChart, PanelKindNumber, PanelKindPieChart, PanelKindTable, PanelKindHistogram, PanelKindList}
|
||||
return []any{PanelKindTimeSeries, PanelKindBarChart, PanelKindNumber, PanelKindPieChart, PanelKindTable, PanelKindHistogram, PanelKindList, PanelKindText}
|
||||
}
|
||||
|
||||
func (k PanelPluginKind) rendersWithoutQuery() bool {
|
||||
return k == PanelKindText
|
||||
}
|
||||
|
||||
type TimeSeriesPanelSpec struct {
|
||||
@@ -237,6 +242,19 @@ type ListPanelSpec struct {
|
||||
SelectFields []telemetrytypes.TelemetryFieldKey `json:"selectFields,omitzero" validate:"dive"`
|
||||
}
|
||||
|
||||
type TextPanelSpec struct {
|
||||
Mode TextMode `json:"mode"`
|
||||
Text string `json:"text"`
|
||||
Presentation TextPresentation `json:"presentation"`
|
||||
HeaderOptions HeaderOptions `json:"headerOptions"`
|
||||
}
|
||||
|
||||
type TextPresentation struct {
|
||||
TextAlign TextAlign `json:"textAlign"`
|
||||
VerticalAlign VerticalAlign `json:"verticalAlign"`
|
||||
Background PanelBackground `json:"background"`
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Panel common types
|
||||
// ══════════════════════════════════════════════
|
||||
@@ -247,6 +265,13 @@ type Axes struct {
|
||||
IsLogScale bool `json:"isLogScale"`
|
||||
}
|
||||
|
||||
// HeaderOptions controls the panel card's header strip — the title/description
|
||||
// row above the panel content. Phrased as hide so the zero value shows the
|
||||
// header, matching every other panel kind.
|
||||
type HeaderOptions struct {
|
||||
Hide bool `json:"hide"`
|
||||
}
|
||||
|
||||
type BasicVisualization struct {
|
||||
TimePreference TimePreference `json:"timePreference"`
|
||||
}
|
||||
@@ -658,6 +683,157 @@ func (sg SpanGaps) validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// TextMode is how a text panel interprets its `text`. Only markdown is
|
||||
// rendered today; further modes (e.g. plain text, HTML) are expected.
|
||||
type TextMode struct{ valuer.String }
|
||||
|
||||
var TextModeMarkdown = TextMode{valuer.NewString("markdown")} // default
|
||||
|
||||
func (TextMode) Enum() []any {
|
||||
return []any{TextModeMarkdown}
|
||||
}
|
||||
|
||||
func (m TextMode) ValueOrDefault() string {
|
||||
if m.IsZero() {
|
||||
return TextModeMarkdown.StringValue()
|
||||
}
|
||||
return m.StringValue()
|
||||
}
|
||||
|
||||
func (m TextMode) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(m.ValueOrDefault())
|
||||
}
|
||||
|
||||
func (m *TextMode) UnmarshalJSON(data []byte) error {
|
||||
var v string
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid text mode: must be the string `markdown`")
|
||||
}
|
||||
tm := TextMode{valuer.NewString(v)}
|
||||
switch tm {
|
||||
case TextModeMarkdown:
|
||||
*m = tm
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid text mode %q: must be `markdown`", v)
|
||||
}
|
||||
}
|
||||
|
||||
type TextAlign struct{ valuer.String }
|
||||
|
||||
var (
|
||||
TextAlignLeft = TextAlign{valuer.NewString("left")} // default
|
||||
TextAlignCenter = TextAlign{valuer.NewString("center")}
|
||||
TextAlignRight = TextAlign{valuer.NewString("right")}
|
||||
)
|
||||
|
||||
func (TextAlign) Enum() []any {
|
||||
return []any{TextAlignLeft, TextAlignCenter, TextAlignRight}
|
||||
}
|
||||
|
||||
func (a TextAlign) ValueOrDefault() string {
|
||||
if a.IsZero() {
|
||||
return TextAlignLeft.StringValue()
|
||||
}
|
||||
return a.StringValue()
|
||||
}
|
||||
|
||||
func (a TextAlign) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(a.ValueOrDefault())
|
||||
}
|
||||
|
||||
func (a *TextAlign) UnmarshalJSON(data []byte) error {
|
||||
var v string
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid text align: must be a string, one of `left`, `center`, or `right`")
|
||||
}
|
||||
val := TextAlign{valuer.NewString(v)}
|
||||
switch val {
|
||||
case TextAlignLeft, TextAlignCenter, TextAlignRight:
|
||||
*a = val
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid text align %q: must be `left`, `center`, or `right`", v)
|
||||
}
|
||||
}
|
||||
|
||||
type VerticalAlign struct{ valuer.String }
|
||||
|
||||
var (
|
||||
VerticalAlignTop = VerticalAlign{valuer.NewString("top")} // default
|
||||
VerticalAlignCenter = VerticalAlign{valuer.NewString("center")}
|
||||
VerticalAlignBottom = VerticalAlign{valuer.NewString("bottom")}
|
||||
)
|
||||
|
||||
func (VerticalAlign) Enum() []any {
|
||||
return []any{VerticalAlignTop, VerticalAlignCenter, VerticalAlignBottom}
|
||||
}
|
||||
|
||||
func (a VerticalAlign) ValueOrDefault() string {
|
||||
if a.IsZero() {
|
||||
return VerticalAlignTop.StringValue()
|
||||
}
|
||||
return a.StringValue()
|
||||
}
|
||||
|
||||
func (a VerticalAlign) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(a.ValueOrDefault())
|
||||
}
|
||||
|
||||
func (a *VerticalAlign) UnmarshalJSON(data []byte) error {
|
||||
var v string
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid vertical align: must be a string, one of `top`, `center`, or `bottom`")
|
||||
}
|
||||
val := VerticalAlign{valuer.NewString(v)}
|
||||
switch val {
|
||||
case VerticalAlignTop, VerticalAlignCenter, VerticalAlignBottom:
|
||||
*a = val
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid vertical align %q: must be `top`, `center`, or `bottom`", v)
|
||||
}
|
||||
}
|
||||
|
||||
// PanelBackground selects the panel's surface: `solid` draws the standard panel
|
||||
// card, `transparent` drops the card so only the content shows.
|
||||
type PanelBackground struct{ valuer.String }
|
||||
|
||||
var (
|
||||
PanelBackgroundSolid = PanelBackground{valuer.NewString("solid")} // default
|
||||
PanelBackgroundTransparent = PanelBackground{valuer.NewString("transparent")}
|
||||
)
|
||||
|
||||
func (PanelBackground) Enum() []any {
|
||||
return []any{PanelBackgroundSolid, PanelBackgroundTransparent}
|
||||
}
|
||||
|
||||
func (b PanelBackground) ValueOrDefault() string {
|
||||
if b.IsZero() {
|
||||
return PanelBackgroundSolid.StringValue()
|
||||
}
|
||||
return b.StringValue()
|
||||
}
|
||||
|
||||
func (b PanelBackground) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(b.ValueOrDefault())
|
||||
}
|
||||
|
||||
func (b *PanelBackground) UnmarshalJSON(data []byte) error {
|
||||
var v string
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid background: must be a string, one of `solid` or `transparent`")
|
||||
}
|
||||
val := PanelBackground{valuer.NewString(v)}
|
||||
switch val {
|
||||
case PanelBackgroundSolid, PanelBackgroundTransparent:
|
||||
*b = val
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid background %q: must be `solid` or `transparent`", v)
|
||||
}
|
||||
}
|
||||
|
||||
type PrecisionOption struct{ valuer.String }
|
||||
|
||||
var (
|
||||
|
||||
5
tests/integration/testdata/alerts/test_scenarios/promql_subquery_no_step/alert_data.jsonl
vendored
Normal file
5
tests/integration/testdata/alerts/test_scenarios/promql_subquery_no_step/alert_data.jsonl
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
{"metric_name":"cpu_percent_promql_subquery_no_step","labels":{"host":"server-01","cpu":"cpu0"},"timestamp":"2026-01-29T10:01:00+00:00","value":15,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false,"flags":0,"description":"","unit":"","env":"default","resource_attrs":{},"scope_attrs":{}}
|
||||
{"metric_name":"cpu_percent_promql_subquery_no_step","labels":{"host":"server-01","cpu":"cpu0"},"timestamp":"2026-01-29T10:02:00+00:00","value":15,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false,"flags":0,"description":"","unit":"","env":"default","resource_attrs":{},"scope_attrs":{}}
|
||||
{"metric_name":"cpu_percent_promql_subquery_no_step","labels":{"host":"server-01","cpu":"cpu0"},"timestamp":"2026-01-29T10:03:00+00:00","value":15,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false,"flags":0,"description":"","unit":"","env":"default","resource_attrs":{},"scope_attrs":{}}
|
||||
{"metric_name":"cpu_percent_promql_subquery_no_step","labels":{"host":"server-01","cpu":"cpu0"},"timestamp":"2026-01-29T10:04:00+00:00","value":15,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false,"flags":0,"description":"","unit":"","env":"default","resource_attrs":{},"scope_attrs":{}}
|
||||
{"metric_name":"cpu_percent_promql_subquery_no_step","labels":{"host":"server-01","cpu":"cpu0"},"timestamp":"2026-01-29T10:05:00+00:00","value":15,"temporality":"Unspecified","type_":"Gauge","is_monotonic":false,"flags":0,"description":"","unit":"","env":"default","resource_attrs":{},"scope_attrs":{}}
|
||||
58
tests/integration/testdata/alerts/test_scenarios/promql_subquery_no_step/rule.json
vendored
Normal file
58
tests/integration/testdata/alerts/test_scenarios/promql_subquery_no_step/rule.json
vendored
Normal file
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"alert": "promql_subquery_no_step",
|
||||
"ruleType": "promql_rule",
|
||||
"alertType": "METRIC_BASED_ALERT",
|
||||
"condition": {
|
||||
"thresholds": {
|
||||
"kind": "basic",
|
||||
"spec": [
|
||||
{
|
||||
"name": "critical",
|
||||
"target": 10,
|
||||
"matchType": "at_least_once",
|
||||
"op": "above",
|
||||
"channels": [
|
||||
"test channel"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"compositeQuery": {
|
||||
"queryType": "promql",
|
||||
"panelType": "graph",
|
||||
"queries": [
|
||||
{
|
||||
"type": "promql",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"query": "max_over_time({\"cpu_percent_promql_subquery_no_step\"}[2m:])"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"selectedQueryName": "A"
|
||||
},
|
||||
"evaluation": {
|
||||
"kind": "rolling",
|
||||
"spec": {
|
||||
"evalWindow": "5m0s",
|
||||
"frequency": "15s"
|
||||
}
|
||||
},
|
||||
"labels": {},
|
||||
"annotations": {
|
||||
"description": "This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})",
|
||||
"summary": "This alert is fired when the defined metric (current value: {{$value}}) crosses the threshold ({{$threshold}})"
|
||||
},
|
||||
"notificationSettings": {
|
||||
"groupBy": [],
|
||||
"usePolicy": false,
|
||||
"renotify": {
|
||||
"enabled": false,
|
||||
"interval": "30m",
|
||||
"alertStates": []
|
||||
}
|
||||
},
|
||||
"version": "v5",
|
||||
"schemaVersion": "v2alpha1"
|
||||
}
|
||||
93
tests/integration/tests/alerts/04_promql_subquery_no_step.py
Normal file
93
tests/integration/tests/alerts/04_promql_subquery_no_step.py
Normal file
@@ -0,0 +1,93 @@
|
||||
import json
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from wiremock.client import HttpMethods, Mapping, MappingRequest, MappingResponse
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.alerts import (
|
||||
update_rule_channel_name,
|
||||
verify_webhook_alert_expectation,
|
||||
)
|
||||
from fixtures.fs import get_testdata_file_path
|
||||
|
||||
TEST_CASE = types.AlertTestCase(
|
||||
name="promql_subquery_no_step",
|
||||
rule_path="alerts/test_scenarios/promql_subquery_no_step/rule.json",
|
||||
alert_data=[
|
||||
types.AlertData(
|
||||
type="metrics",
|
||||
data_path="alerts/test_scenarios/promql_subquery_no_step/alert_data.jsonl",
|
||||
),
|
||||
],
|
||||
alert_expectation=types.AlertExpectation(
|
||||
should_alert=True,
|
||||
wait_time_seconds=30,
|
||||
expected_alerts=[
|
||||
types.FiringAlert(
|
||||
labels={
|
||||
"alertname": "promql_subquery_no_step",
|
||||
"threshold.name": "critical",
|
||||
}
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_promql_rule_subquery_without_step(
|
||||
notification_channel: types.TestContainerDocker,
|
||||
make_http_mocks: Callable[[types.TestContainerDocker, list[Mapping]], None],
|
||||
create_webhook_notification_channel: Callable[[str, str, dict, bool], str],
|
||||
create_alert_rule: Callable[[dict], str],
|
||||
insert_alert_data: Callable[[list[types.AlertData], datetime], None],
|
||||
):
|
||||
"""
|
||||
A promql rule with a step-less subquery ([2m:]) must evaluate and fire.
|
||||
A nil NoStepSubqueryIntervalFn segfaults the process on first evaluation.
|
||||
"""
|
||||
notification_channel_name = str(uuid.uuid4())
|
||||
webhook_endpoint_path = f"/alert/{notification_channel_name}"
|
||||
notification_url = notification_channel.container_configs["8080"].get(webhook_endpoint_path)
|
||||
|
||||
make_http_mocks(
|
||||
notification_channel,
|
||||
[
|
||||
Mapping(
|
||||
request=MappingRequest(
|
||||
method=HttpMethods.POST,
|
||||
url=webhook_endpoint_path,
|
||||
),
|
||||
response=MappingResponse(
|
||||
status=200,
|
||||
json_body={},
|
||||
),
|
||||
persistent=False,
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
create_webhook_notification_channel(
|
||||
channel_name=notification_channel_name,
|
||||
webhook_url=notification_url,
|
||||
http_config={},
|
||||
send_resolved=False,
|
||||
)
|
||||
|
||||
insert_alert_data(
|
||||
TEST_CASE.alert_data,
|
||||
base_time=datetime.now(tz=UTC) - timedelta(minutes=5),
|
||||
)
|
||||
|
||||
rule_path = get_testdata_file_path(TEST_CASE.rule_path)
|
||||
with open(rule_path, encoding="utf-8") as f:
|
||||
rule_data = json.loads(f.read())
|
||||
update_rule_channel_name(rule_data, notification_channel_name)
|
||||
create_alert_rule(rule_data)
|
||||
|
||||
verify_webhook_alert_expectation(
|
||||
notification_channel,
|
||||
notification_channel_name,
|
||||
TEST_CASE.alert_expectation,
|
||||
)
|
||||
@@ -0,0 +1,65 @@
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from http import HTTPStatus
|
||||
from uuid import uuid4
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.metrics import Metrics
|
||||
from fixtures.querier import get_all_series, make_query_request
|
||||
|
||||
MINUTE_MS = 60_000
|
||||
|
||||
LEGS: list[tuple[str, dict | None]] = [
|
||||
("default", None),
|
||||
("clickhousev2", {"X-SigNoz-PromQL-Provider": "clickhousev2"}),
|
||||
]
|
||||
|
||||
|
||||
def test_promql_subquery_without_step_evaluates(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_metrics: Callable[[list[Metrics]], None],
|
||||
) -> None:
|
||||
"""
|
||||
A subquery that omits its step, e.g. `metric[5m:]`, is valid PromQL: the
|
||||
engine fills in its default resolution. A nil NoStepSubqueryIntervalFn
|
||||
segfaults the whole process on the first such query.
|
||||
"""
|
||||
end_ms = (int((datetime.now(tz=UTC) - timedelta(minutes=5)).timestamp() * 1000) // MINUTE_MS) * MINUTE_MS
|
||||
start_ms = end_ms - 30 * MINUTE_MS
|
||||
|
||||
metric = f"no_step_subquery_gauge_{uuid4().hex[:8]}"
|
||||
insert_metrics(
|
||||
[
|
||||
Metrics(
|
||||
metric_name=metric,
|
||||
labels={"host": "server-01"},
|
||||
timestamp=datetime.fromtimestamp(ts_ms / 1000, tz=UTC),
|
||||
value=42.0,
|
||||
)
|
||||
for ts_ms in range(start_ms, end_ms + 1, MINUTE_MS)
|
||||
]
|
||||
)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
for leg, headers in LEGS:
|
||||
query = {"type": "promql", "spec": {"name": "A", "query": f"max_over_time({metric}[5m:])"}}
|
||||
response = make_query_request(signoz, token, start_ms, end_ms, [query], headers=headers)
|
||||
assert response.status_code == HTTPStatus.OK, f"{leg}: {response.text[:300]}"
|
||||
series = get_all_series(response.json(), "A")
|
||||
assert series, f"{leg}: the subquery must return the inserted series"
|
||||
values = {point["value"] for entry in series for point in entry.get("values") or []}
|
||||
assert values == {42.0}, f"{leg}: {sorted(values)[:5]}"
|
||||
|
||||
# A plain follow-up query proves the process survived the subquery legs.
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms,
|
||||
end_ms,
|
||||
[{"type": "promql", "spec": {"name": "A", "query": metric}}],
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text[:300]
|
||||
Reference in New Issue
Block a user