Compare commits

..

5 Commits

Author SHA1 Message Date
Naman Verma
dbf57cf28c chore: change panel background to a hexcode string 2026-09-05 01:58:16 +05:30
Naman Verma
8f5f7fe23b Merge branch 'main' into nv/text-panel 2026-09-05 01:28:09 +05:30
Naman Verma
edf7c74097 feat: add header options to text panel 2026-08-31 14:55:27 +05:30
Naman Verma
40c7794d0d Merge branch 'main' into nv/text-panel 2026-08-31 14:40:39 +05:30
Naman Verma
ca618573cb feat: add spec for text panel 2026-08-27 17:59:47 +05:30
8 changed files with 804 additions and 213 deletions

View File

@@ -3064,6 +3064,11 @@ components:
- tags
- spec
type: object
DashboardtypesHeaderOptions:
properties:
hide:
type: boolean
type: object
DashboardtypesHistogramBuckets:
properties:
bucketCount:
@@ -3421,6 +3426,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:
@@ -3431,6 +3437,7 @@ components:
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
type: object
DashboardtypesPanelPluginKind:
enum:
@@ -3441,6 +3448,7 @@ components:
- signoz/TablePanel
- signoz/HistogramPanel
- signoz/ListPanel
- signoz/TextPanel
type: string
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec:
properties:
@@ -3514,6 +3522,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:
@@ -3805,6 +3825,37 @@ 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:
nullable: true
type: string
textAlign:
$ref: '#/components/schemas/DashboardtypesTextAlign'
verticalAlign:
$ref: '#/components/schemas/DashboardtypesVerticalAlign'
type: object
DashboardtypesTextVariableSpec:
properties:
constant:
@@ -4014,6 +4065,12 @@ components:
- kind
- spec
type: object
DashboardtypesVerticalAlign:
enum:
- top
- center
- bottom
type: string
ErrorsJSON:
properties:
code:

View File

@@ -1,119 +0,0 @@
import {
panelTypeDataSourceFormValuesMap,
type PartialPanelTypes,
} from 'lib/query/panelQuery';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { DataSource } from 'types/common/queryBuilder';
/**
* The map is composed from a few shape rules rather than spelled out per panel type
* and data source. These specs pin the rules themselves — each one fails only when a
* rule changes, which is the moment to stop and decide, rather than whenever any
* field moves.
*
* The composition it replaced was checked cell by cell against the previous literal
* table, which is in git history at `main:frontend/src/lib/query/panelQuery.ts`.
*/
function fieldsFor(
panelType: keyof PartialPanelTypes,
dataSource: DataSource,
): string[] {
return panelTypeDataSourceFormValuesMap[panelType][dataSource].builder
.queryData;
}
/** Fields present in `to` but not in `from`. */
function added(from: string[], to: string[]): string[] {
return to.filter((field) => !from.includes(field)).sort();
}
/** Panel types built on the aggregating field list. */
const AGGREGATING_TYPES: (keyof PartialPanelTypes)[] = [
PANEL_TYPES.BAR,
PANEL_TYPES.HISTOGRAM,
PANEL_TYPES.TABLE,
PANEL_TYPES.PIE,
];
/** Panel types that reduce each series to one cell or slice. */
const SCALAR_TYPES: (keyof PartialPanelTypes)[] = [
PANEL_TYPES.TABLE,
PANEL_TYPES.PIE,
];
describe('panelTypeDataSourceFormValuesMap', () => {
const seriesLogs = fieldsFor(PANEL_TYPES.TIME_SERIES, DataSource.LOGS);
const seriesMetrics = fieldsFor(PANEL_TYPES.TIME_SERIES, DataSource.METRICS);
it('shares one builder surface between logs and traces', () => {
Object.values(panelTypeDataSourceFormValuesMap).forEach((sources) => {
expect(sources[DataSource.LOGS].builder.queryData).toStrictEqual(
sources[DataSource.TRACES].builder.queryData,
);
});
});
// The provider pushes onto the list it reads from this map, so two cells backed by
// one instance would leak fields into each other.
it('gives every cell its own array instance', () => {
const arrays = Object.values(panelTypeDataSourceFormValuesMap).flatMap(
(sources) =>
Object.values(sources).map((source) => source.builder.queryData),
);
expect(new Set(arrays).size).toBe(arrays.length);
});
// One consequence of composing: the aggregating types share a single field list, so
// an edit meant for charts reaches table and pie too.
it.each(AGGREGATING_TYPES)(
'gives %s the same non-metrics fields as a time series',
(panelType) => {
expect(fieldsFor(panelType, DataSource.LOGS)).toStrictEqual(seriesLogs);
},
);
it('adds both metrics aggregation steps for metrics', () => {
expect(added(seriesLogs, seriesMetrics)).toStrictEqual([
'spaceAggregation',
'timeAggregation',
]);
});
it.each(SCALAR_TYPES)('offers reduceTo to %s on metrics only', (panelType) => {
expect(
added(seriesMetrics, fieldsFor(panelType, DataSource.METRICS)),
).toStrictEqual(['reduceTo']);
expect(fieldsFor(panelType, DataSource.LOGS)).not.toContain('reduceTo');
});
it('drops grouping, paging and ordering for a single value', () => {
const value = fieldsFor(PANEL_TYPES.VALUE, DataSource.LOGS);
expect(added(value, seriesLogs)).toStrictEqual([
'groupBy',
'limit',
'orderBy',
]);
expect(value).toContain('reduceTo');
});
it('offers no aggregation fields to raw rows', () => {
const rows = fieldsFor(PANEL_TYPES.LIST, DataSource.LOGS);
expect(rows).not.toContain('aggregateAttribute');
expect(rows).not.toContain('aggregateOperator');
expect(rows).not.toContain('groupBy');
expect(rows).not.toContain('having');
expect(rows).not.toContain('stepInterval');
});
it('drops paging and ordering for metrics rows', () => {
expect(
added(
fieldsFor(PANEL_TYPES.LIST, DataSource.METRICS),
fieldsFor(PANEL_TYPES.LIST, DataSource.LOGS),
),
).toStrictEqual(['functions', 'limit', 'orderBy']);
});
});

View File

@@ -101,99 +101,441 @@ export type PartialPanelTypes = {
[PANEL_TYPES.HISTOGRAM]: 'histogram';
};
/**
* Builder fields carried across a panel-type switch, per panel type and data source.
*
* The 21 combinations reduce to a handful of rules, so they are composed rather than
* spelled out: logs and traces carry the same fields in every case, metrics splits its
* aggregation in two, and each panel type is one of four query shapes. Order is
* irrelevant — `handleQueryChange` copies each field independently.
*
* `panelTypeFormValues` in `__tests__/__fixtures__` pins the previous literal table so
* the composition can be shown to reproduce it exactly.
*/
/** Every field an aggregating query carries — shared by charts, table and pie. */
const AGGREGATING_FIELDS = [
'aggregateAttribute',
'aggregateOperator',
'filters',
'filter',
'groupBy',
'limit',
'having',
'orderBy',
'functions',
'stepInterval',
'disabled',
'queryName',
'legend',
'expression',
'aggregations',
] as const;
/** Metrics aggregates over time and then over space, so it carries both steps. */
const METRICS_AGGREGATION = ['timeAggregation', 'spaceAggregation'] as const;
function omit(fields: readonly string[], ...omitted: string[]): string[] {
return fields.filter((field) => !omitted.includes(field));
}
const SERIES = [...AGGREGATING_FIELDS];
const SERIES_METRICS = [...SERIES, ...METRICS_AGGREGATION];
// Table and pie reduce each series to a single cell/slice. Note the asymmetry, carried
// over from the previous table: `reduceTo` is offered for metrics only.
const SCALAR_METRICS = [...SERIES_METRICS, 'reduceTo'];
/** A single value has no series to group, limit or order. */
const SINGLE_VALUE = [
...omit(AGGREGATING_FIELDS, 'groupBy', 'limit', 'orderBy'),
'reduceTo',
];
const SINGLE_VALUE_METRICS = [...SINGLE_VALUE, ...METRICS_AGGREGATION];
/** Raw rows carry no aggregation at all. */
const RAW_ROWS = [
'queryName',
'filters',
'filter',
'limit',
'orderBy',
'functions',
'aggregations',
];
// Metrics rows drop paging and ordering too, as before.
const RAW_ROWS_METRICS = ['queryName', 'filters', 'filter', 'aggregations'];
/**
* Logs and traces share a builder surface; metrics is the one that differs.
*
* Each cell gets its own copy. `QueryBuilder`'s provider pushes onto the list it reads
* from this map, so cells sharing one array instance would contaminate each other.
*/
function bySource(
logsAndTraces: readonly string[],
metrics: readonly string[],
): Record<DataSource, any> {
return {
[DataSource.LOGS]: { builder: { queryData: [...logsAndTraces] } },
[DataSource.TRACES]: { builder: { queryData: [...logsAndTraces] } },
[DataSource.METRICS]: { builder: { queryData: [...metrics] } },
};
}
export const panelTypeDataSourceFormValuesMap: Record<
keyof PartialPanelTypes,
Record<DataSource, any>
> = {
[PANEL_TYPES.TIME_SERIES]: bySource(SERIES, SERIES_METRICS),
[PANEL_TYPES.BAR]: bySource(SERIES, SERIES_METRICS),
[PANEL_TYPES.HISTOGRAM]: bySource(SERIES, SERIES_METRICS),
[PANEL_TYPES.TABLE]: bySource(SERIES, SCALAR_METRICS),
[PANEL_TYPES.PIE]: bySource(SERIES, SCALAR_METRICS),
[PANEL_TYPES.VALUE]: bySource(SINGLE_VALUE, SINGLE_VALUE_METRICS),
[PANEL_TYPES.LIST]: bySource(RAW_ROWS, RAW_ROWS_METRICS),
[PANEL_TYPES.BAR]: {
[DataSource.LOGS]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'filters',
'filter',
'groupBy',
'limit',
'having',
'orderBy',
'functions',
'stepInterval',
'disabled',
'queryName',
'legend',
'expression',
'aggregations',
],
},
},
[DataSource.METRICS]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'timeAggregation',
'filters',
'filter',
'spaceAggregation',
'groupBy',
'limit',
'having',
'orderBy',
'stepInterval',
'legend',
'queryName',
'disabled',
'functions',
'expression',
'aggregations',
],
},
},
[DataSource.TRACES]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'filters',
'filter',
'groupBy',
'limit',
'having',
'orderBy',
'functions',
'stepInterval',
'disabled',
'queryName',
'legend',
'expression',
'aggregations',
],
},
},
},
[PANEL_TYPES.TIME_SERIES]: {
[DataSource.LOGS]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'filters',
'filter',
'groupBy',
'limit',
'having',
'orderBy',
'functions',
'stepInterval',
'disabled',
'queryName',
'legend',
'expression',
'aggregations',
],
},
},
[DataSource.METRICS]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'timeAggregation',
'filters',
'filter',
'spaceAggregation',
'groupBy',
'limit',
'having',
'orderBy',
'stepInterval',
'legend',
'queryName',
'disabled',
'functions',
'expression',
'aggregations',
],
},
},
[DataSource.TRACES]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'filters',
'filter',
'groupBy',
'limit',
'having',
'orderBy',
'functions',
'stepInterval',
'disabled',
'queryName',
'legend',
'expression',
'aggregations',
],
},
},
},
[PANEL_TYPES.HISTOGRAM]: {
[DataSource.LOGS]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'filters',
'filter',
'groupBy',
'limit',
'having',
'orderBy',
'functions',
'stepInterval',
'disabled',
'queryName',
'legend',
'expression',
'aggregations',
],
},
},
[DataSource.METRICS]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'timeAggregation',
'filters',
'filter',
'spaceAggregation',
'groupBy',
'limit',
'having',
'orderBy',
'stepInterval',
'legend',
'queryName',
'disabled',
'functions',
'expression',
'aggregations',
],
},
},
[DataSource.TRACES]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'filters',
'filter',
'groupBy',
'limit',
'having',
'orderBy',
'functions',
'stepInterval',
'disabled',
'queryName',
'legend',
'expression',
'aggregations',
],
},
},
},
[PANEL_TYPES.TABLE]: {
[DataSource.LOGS]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'filters',
'filter',
'groupBy',
'limit',
'having',
'orderBy',
'functions',
'stepInterval',
'disabled',
'queryName',
'expression',
'legend',
'aggregations',
],
},
},
[DataSource.METRICS]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'timeAggregation',
'filters',
'filter',
'spaceAggregation',
'groupBy',
'reduceTo',
'limit',
'having',
'orderBy',
'stepInterval',
'legend',
'queryName',
'expression',
'disabled',
'functions',
'aggregations',
],
},
},
[DataSource.TRACES]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'filters',
'filter',
'groupBy',
'limit',
'having',
'orderBy',
'functions',
'stepInterval',
'disabled',
'queryName',
'expression',
'legend',
'aggregations',
],
},
},
},
[PANEL_TYPES.PIE]: {
[DataSource.LOGS]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'filters',
'filter',
'groupBy',
'limit',
'having',
'orderBy',
'functions',
'stepInterval',
'disabled',
'queryName',
'expression',
'legend',
'aggregations',
],
},
},
[DataSource.METRICS]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'timeAggregation',
'filters',
'filter',
'spaceAggregation',
'groupBy',
'reduceTo',
'limit',
'having',
'orderBy',
'stepInterval',
'legend',
'queryName',
'expression',
'disabled',
'functions',
'aggregations',
],
},
},
[DataSource.TRACES]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'filters',
'filter',
'groupBy',
'limit',
'having',
'orderBy',
'functions',
'stepInterval',
'disabled',
'queryName',
'expression',
'legend',
'aggregations',
],
},
},
},
[PANEL_TYPES.LIST]: {
[DataSource.LOGS]: {
builder: {
queryData: [
'queryName',
'filters',
'filter',
'limit',
'orderBy',
'functions',
'aggregations',
],
},
},
[DataSource.METRICS]: {
builder: {
queryData: ['queryName', 'filters', 'filter', 'aggregations'],
},
},
[DataSource.TRACES]: {
builder: {
queryData: [
'queryName',
'filters',
'filter',
'limit',
'orderBy',
'functions',
'aggregations',
],
},
},
},
[PANEL_TYPES.VALUE]: {
[DataSource.LOGS]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'filters',
'filter',
'reduceTo',
'having',
'functions',
'stepInterval',
'queryName',
'expression',
'disabled',
'legend',
'aggregations',
],
},
},
[DataSource.METRICS]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'timeAggregation',
'filters',
'filter',
'spaceAggregation',
'having',
'reduceTo',
'stepInterval',
'legend',
'queryName',
'expression',
'disabled',
'functions',
'aggregations',
],
},
},
[DataSource.TRACES]: {
builder: {
queryData: [
'aggregateAttribute',
'aggregateOperator',
'filters',
'filter',
'reduceTo',
'having',
'functions',
'stepInterval',
'queryName',
'expression',
'disabled',
'legend',
'aggregations',
],
},
},
},
};
export function handleQueryChange(

View File

@@ -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 {

View File

@@ -1086,7 +1086,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) {
@@ -1136,6 +1136,155 @@ 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": "#1A2b3C"},
"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)
require.NotNil(t, spec.Presentation.Background, "expected background to be set")
assert.Equal(t, "#1A2b3C", *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. Background has no default: omitted stays omitted.
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")
spec, ok := d.Panels["p1"].Spec.Plugin.Spec.(*TextPanelSpec)
require.True(t, ok, "expected the panel spec to decode as *TextPanelSpec")
assert.Nil(t, spec.Presentation.Background, "expected an omitted background to stay unset")
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"},
"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("hex background colours validate", func(t *testing.T) {
for _, background := range []string{"#abc", "#abcd", "#aabbcc", "#aabbccdd", "#AABBCC"} {
d, err := unmarshalDashboard(wrapPanel(`{"presentation": {"background": "` + background + `"}}`))
require.NoError(t, err, "expected background %q to validate", background)
spec, ok := d.Panels["p1"].Spec.Plugin.Spec.(*TextPanelSpec)
require.True(t, ok, "expected the panel spec to decode as *TextPanelSpec")
require.NotNil(t, spec.Presentation.Background)
assert.Equal(t, background, *spec.Presentation.Background)
}
})
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"}}`,
} {
_, err := unmarshalDashboard(wrapPanel(spec))
assert.Error(t, err, "expected an unknown %s value to be rejected", field)
}
})
t.Run("invalid background colours are rejected", func(t *testing.T) {
for name, spec := range map[string]string{
"empty string": `{"presentation": {"background": ""}}`,
"missing hash": `{"presentation": {"background": "aabbcc"}}`,
"named colour": `{"presentation": {"background": "red"}}`,
"wrong length": `{"presentation": {"background": "#abcde"}}`,
"non hex digits": `{"presentation": {"background": "#gggggg"}}`,
} {
_, err := unmarshalDashboard(wrapPanel(spec))
assert.Error(t, err, "expected %s background to be rejected", name)
}
})
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 `{

View File

@@ -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: {},
}
)

View File

@@ -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)
}

View File

@@ -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 *string `json:"background,omitempty" validate:"omitempty,hexcolor"`
}
// ══════════════════════════════════════════════
// 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,118 @@ 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)
}
}
type PrecisionOption struct{ valuer.String }
var (