Compare commits

...

5 Commits

Author SHA1 Message Date
Naman Verma
ecc3ef5463 fix: allow all as value for signal 2026-07-22 02:02:46 +05:30
Naman Verma
eb59f57fc9 fix: validate links on read from db as well 2026-07-22 01:38:03 +05:30
Ashwin Bhatkal
1581bfd5f3 refactor(dashboards-v2): make dynamic-variable signal a required field
Addresses review on the empty-signal change. The dynamic-variable signal
already always serializes ('' = all telemetry, no omitempty), so mark it
required + non-nullable in the contract (regenerated OpenAPI + client). The
read adapter now uses plugin.spec.signal directly instead of the
DYNAMIC_SIGNALS.includes(...) → DYNAMIC_SIGNAL_ALL fallback.

Scoped to the dynamic-variable signal only; the query signal
(QueryBuilderQuery.Signal) is unchanged.
2026-07-21 12:46:49 +05:30
Ashwin Bhatkal
603bf45b3c fix(dashboards-v2): make panel/dashboard links required and non-nullable
Beta hasn't shipped, so tighten the contract instead of leaving links
nullable. PanelSpec.Links and DashboardSpec.Links are now required and
non-nullable, and create/update/patch reject a missing links value (spec-
or panel-level) rather than silently defaulting it, so a typed client's
value round-trips faithfully.

Enforced at the request boundary (Postable/UpdatableDashboardV2.Validate),
not the shared spec Validate, so reads and clones of already-stored specs
are unaffected. Regenerated the OpenAPI spec + frontend client (links is now
a non-optional Link[]), set links: [] at the panel/dashboard builders,
dropped the now-dead null guards, and updated the marshal round-trip + v2
integration tests.
2026-07-21 10:43:54 +05:30
Ashwin Bhatkal
bd2ccb538d fix(dashboard-v2): use empty-signal enum value for "all telemetry"
The wire now carries an empty signal ('') to mean "any", so drop the
UI-only 'all' sentinel. TelemetrySignal is the full generated enum again
(no narrowing union), '' round-trips as the "all telemetry" value instead
of being persisted as an omitted/undefined signal, and signalForApi is now
just the field-keys/values transport mapper ('' -> omitted, since that
endpoint only accepts a concrete signal).
2026-07-20 23:20:16 +05:30
24 changed files with 276 additions and 83 deletions

View File

@@ -2747,7 +2747,6 @@ components:
links:
items:
$ref: '#/components/schemas/DashboardtypesLink'
nullable: true
type: array
panels:
additionalProperties:
@@ -2764,6 +2763,7 @@ components:
- variables
- panels
- layouts
- links
type: object
DashboardtypesDashboardView:
properties:
@@ -2842,14 +2842,22 @@ components:
required:
- name
type: object
DashboardtypesDynamicVariableSignal:
enum:
- traces
- logs
- metrics
- all
type: string
DashboardtypesDynamicVariableSpec:
properties:
name:
type: string
signal:
$ref: '#/components/schemas/TelemetrytypesSignal'
$ref: '#/components/schemas/DashboardtypesDynamicVariableSignal'
required:
- name
- signal
type: object
DashboardtypesFillMode:
enum:
@@ -3392,7 +3400,6 @@ components:
links:
items:
$ref: '#/components/schemas/DashboardtypesLink'
nullable: true
type: array
plugin:
$ref: '#/components/schemas/DashboardtypesPanelPlugin'
@@ -3404,6 +3411,7 @@ components:
- display
- plugin
- queries
- links
type: object
DashboardtypesPatchOp:
enum:

View File

@@ -4625,9 +4625,9 @@ export interface DashboardtypesQueryDTO {
export interface DashboardtypesPanelSpecDTO {
display: DashboardtypesDisplayDTO;
/**
* @type array,null
* @type array
*/
links?: DashboardtypesLinkDTO[] | null;
links: DashboardtypesLinkDTO[];
plugin: DashboardtypesPanelPluginDTO;
/**
* @type array
@@ -4672,7 +4672,7 @@ export interface DashboardtypesDynamicVariableSpecDTO {
* @type string
*/
name: string;
signal?: TelemetrytypesSignalDTO;
signal: TelemetrytypesSignalDTO;
}
export interface DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDynamicVariableSpecDTO {
@@ -4814,9 +4814,9 @@ export interface DashboardtypesDashboardSpecDTO {
*/
layouts: DashboardtypesLayoutDTO[];
/**
* @type array,null
* @type array
*/
links?: DashboardtypesLinkDTO[] | null;
links: DashboardtypesLinkDTO[];
/**
* @type object
*/

View File

@@ -55,6 +55,7 @@ export function useCreateExportDashboard({
layouts: [],
panels: {},
variables: [],
links: [],
},
}),
{

View File

@@ -14,17 +14,17 @@ import { isRetryableError } from 'utils/errorUtils';
import {
DYNAMIC_SIGNAL_LABEL,
DYNAMIC_SIGNALS,
type DynamicSignalOption,
signalForApi,
type TelemetrySignal,
} from '../variableFormModel';
import styles from './VariableForm.module.scss';
interface DynamicVariableFieldsProps {
attribute: string;
signal: DynamicSignalOption;
signal: TelemetrySignal;
onChange: (patch: {
dynamicAttribute?: string;
dynamicSignal?: DynamicSignalOption;
dynamicSignal?: TelemetrySignal;
}) => void;
onPreview: (values: (string | number)[]) => void;
/** Inline error shown under the attribute field (e.g. duplicate attribute). */
@@ -117,7 +117,7 @@ function DynamicVariableFields({
value: s,
}))}
onChange={(value): void =>
onChange({ dynamicSignal: value as DynamicSignalOption })
onChange({ dynamicSignal: value as TelemetrySignal })
}
data-testid="variable-signal-select"
/>

View File

@@ -13,11 +13,7 @@ import type {
} from 'api/generated/services/sigNoz.schemas';
import {
DYNAMIC_SIGNAL_ALL,
DYNAMIC_SIGNALS,
type DynamicSignalOption,
emptyVariableFormModel,
signalForApi,
VARIABLE_SORT_DISABLED,
type VariableFormModel,
} from './variableFormModel';
@@ -69,12 +65,8 @@ export function dtoToFormModel(
...listCommon,
type: 'DYNAMIC',
dynamicAttribute: plugin.spec.name ?? '',
// Unrecognized/empty signal → "all telemetry", so the source always shows.
dynamicSignal: DYNAMIC_SIGNALS.includes(
plugin.spec.signal as DynamicSignalOption,
)
? (plugin.spec.signal as DynamicSignalOption)
: DYNAMIC_SIGNAL_ALL,
// signal is a required wire field ('' = all telemetry), so it's used as-is.
dynamicSignal: plugin.spec.signal,
};
}
// Default to Query (also covers a query plugin or a missing/unknown plugin).
@@ -102,7 +94,7 @@ function buildPlugin(
kind: DynamicPluginKind['signoz/DynamicVariable'],
spec: {
name: model.dynamicAttribute,
signal: signalForApi(model.dynamicSignal),
signal: model.dynamicSignal,
},
};
case 'QUERY':

View File

@@ -15,22 +15,14 @@ import { sortBy } from 'lodash-es';
*/
export type VariableType = 'QUERY' | 'CUSTOM' | 'TEXT' | 'DYNAMIC';
/** Telemetry signal — the generated enum (traces / logs / metrics). */
// A query/variable signal is only logs/traces/metrics. TelemetrytypesSignalDTO
// also carries the empty "any" value used on field keys, which is not a valid
// query/variable signal, so exclude it here.
export type TelemetrySignal =
| TelemetrytypesSignalDTO.logs
| TelemetrytypesSignalDTO.traces
| TelemetrytypesSignalDTO.metrics;
/** Telemetry signal — the generated enum (traces / logs / metrics / '' = any). */
export type TelemetrySignal = TelemetrytypesSignalDTO;
/**
* Signal selected in the dynamic-variable editor. `'all'` is UI-only (the
* generated `TelemetrytypesSignalDTO` has no "all") — it searches across every
* signal and maps to an omitted `signal` on the wire (see {@link signalForApi}).
* The empty signal (`''`) is the "all telemetry" value — it searches across every
* signal and round-trips to the wire as-is (no longer omitted).
*/
export const DYNAMIC_SIGNAL_ALL = 'all' as const;
export type DynamicSignalOption = TelemetrySignal | typeof DYNAMIC_SIGNAL_ALL;
export const DYNAMIC_SIGNAL_ALL = TelemetrytypesSignalDTO[''];
/**
* Sort order for list-variable values, keyed by the generated wire enum so the
@@ -73,24 +65,31 @@ export const VARIABLE_SORT_LABEL: Record<VariableSort, string> = {
[VARIABLE_SORT.CI_DESC]: 'Alphabetical, case-insensitive (descending)',
};
export const DYNAMIC_SIGNALS: DynamicSignalOption[] = [
export const DYNAMIC_SIGNALS: TelemetrySignal[] = [
DYNAMIC_SIGNAL_ALL,
TelemetrytypesSignalDTO.traces,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.metrics,
];
export const DYNAMIC_SIGNAL_LABEL: Record<DynamicSignalOption, string> = {
export const DYNAMIC_SIGNAL_LABEL: Record<TelemetrySignal, string> = {
[DYNAMIC_SIGNAL_ALL]: 'All telemetry',
[TelemetrytypesSignalDTO.traces]: 'Traces',
[TelemetrytypesSignalDTO.logs]: 'Logs',
[TelemetrytypesSignalDTO.metrics]: 'Metrics',
};
/** Maps the editor's signal selection to the wire value (`'all'` → omitted). */
/**
* Field-keys/values API param. The empty "any" signal is omitted (that endpoint
* only accepts a concrete signal), everything else passes through.
*/
export function signalForApi(
signal: DynamicSignalOption,
): TelemetrySignal | undefined {
signal: TelemetrySignal,
):
| TelemetrytypesSignalDTO.traces
| TelemetrytypesSignalDTO.logs
| TelemetrytypesSignalDTO.metrics
| undefined {
return signal === DYNAMIC_SIGNAL_ALL ? undefined : signal;
}
@@ -136,7 +135,7 @@ export interface VariableFormModel {
textValue: string; // TEXT
textConstant: boolean; // TEXT
dynamicAttribute: string; // DYNAMIC — the telemetry field name
dynamicSignal: DynamicSignalOption; // DYNAMIC — the telemetry signal
dynamicSignal: TelemetrySignal; // DYNAMIC — the telemetry signal (`''` = any)
/**
* Runtime-selected default, not editable in the management tab yet; carried

View File

@@ -17,8 +17,6 @@ const SIGNAL_LABEL: Record<TelemetrytypesSignalDTO, string> = {
[TelemetrytypesSignalDTO.logs]: 'logs',
[TelemetrytypesSignalDTO.traces]: 'traces',
[TelemetrytypesSignalDTO.metrics]: 'metrics',
// The empty "any" signal only appears on field keys, never on a panel query;
// mapped for exhaustiveness.
[TelemetrytypesSignalDTO['']]: '',
};

View File

@@ -120,7 +120,7 @@ export const SECTION_REGISTRY: {
[SectionKind.ContextLinks]: {
Component: ContextLinksSection,
// Panel-level slice (spec.links), not under the plugin spec — no cast needed.
get: (spec): DashboardtypesLinkDTO[] | undefined => spec.links ?? undefined,
get: (spec): DashboardtypesLinkDTO[] => spec.links,
update: (spec, links): PanelSpec => ({ ...spec, links }),
},
// One editor for every threshold variant (label / comparison / table); the kind's

View File

@@ -63,7 +63,7 @@ jest.mock(
'pages/DashboardPageV2/DashboardContainer/DashboardSettings/Variables/variableFormModel',
() => ({
emptyVariableFormModel: (): unknown => ({}),
DYNAMIC_SIGNAL_ALL: 'all',
DYNAMIC_SIGNAL_ALL: '',
}),
);
jest.mock('components/OverlayScrollbar/OverlayScrollbar', () => ({

View File

@@ -218,7 +218,7 @@ export function useDrilldown(
context={context}
query={v1Query}
isResolving={isResolving}
links={panel.spec.links ?? undefined}
links={panel.spec.links}
canSetDashboardVariables={dashboardVariables.hasFieldVariables}
onViewLogs={(): void => navigate('view_logs')}
onViewTraces={(): void => navigate('view_traces')}

View File

@@ -105,8 +105,7 @@ export function useDrilldownDashboardVariables({
type: 'DYNAMIC',
multiSelect: true,
dynamicAttribute: fieldName,
// `||` (not `??`): an empty "any" signal maps to All, same as unset.
dynamicSignal: signal || DYNAMIC_SIGNAL_ALL,
dynamicSignal: signal ?? DYNAMIC_SIGNAL_ALL,
};
try {
await patchAsync(

View File

@@ -49,6 +49,7 @@ export function createDefaultPanel(
spec: pluginSpec,
} as DashboardtypesPanelPluginDTO,
queries,
links: [],
},
};
}

View File

@@ -61,6 +61,7 @@ function BlankDashboardPanel({ onClose }: Props): JSX.Element {
layouts: [],
panels: {},
variables: [],
links: [],
},
});
onClose();

View File

@@ -54,10 +54,12 @@ func newTestDashboardV2(t *testing.T, orgID valuer.UUID, source Source) *Dashboa
},
},
},
Links: []Link{},
},
},
},
Layouts: []Layout{},
Links: []Link{},
}
return &DashboardV2{

View File

@@ -26,7 +26,7 @@ type DashboardSpec struct {
Layouts []Layout `json:"layouts" required:"true" nullable:"false"`
Duration common.DurationString `json:"duration"`
RefreshInterval common.DurationString `json:"refreshInterval"`
Links []Link `json:"links,omitzero"`
Links []Link `json:"links" required:"true" nullable:"false"`
}
// ══════════════════════════════════════════════
@@ -45,6 +45,16 @@ func (d *DashboardSpec) UnmarshalJSON(data []byte) error {
return d.Validate()
}
// validateLinks rejects a missing/null spec.links value: a typed client must
// send [] rather than omitting links, so its value round-trips faithfully.
// Panel links are the panel spec's concern, validated in validatePanels.
func (d *DashboardSpec) validateLinks() error {
if d.Links == nil {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.links is required; send [] when there are no links")
}
return nil
}
// ══════════════════════════════════════════════
// Cross-field validation
// ══════════════════════════════════════════════
@@ -53,6 +63,9 @@ func (d *DashboardSpec) Validate() error {
if err := d.Display.Validate("dashboard", "spec.display.name"); err != nil {
return err
}
if err := d.validateLinks(); err != nil {
return err
}
if err := d.validateVariables(); err != nil {
return err
}
@@ -104,6 +117,9 @@ func (d *DashboardSpec) validatePanels() error {
if err := panel.Spec.Display.Validate("panel", path+".spec.display.name"); err != nil {
return err
}
if err := panel.Spec.validateLinks(path); err != nil {
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", path)

View File

@@ -23,6 +23,7 @@ const basePostableJSON = `{
"tags": [{"key": "team", "value": "alpha"}, {"key": "env", "value": "prod"}],
"spec": {
"display": {"name": "Service overview"},
"links": [],
"variables": [
{
"kind": "ListVariable",
@@ -41,6 +42,7 @@ const basePostableJSON = `{
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [
{
@@ -64,6 +66,7 @@ const basePostableJSON = `{
"p2": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "signoz/NumberPanel", "spec": {}},
"queries": [
{
@@ -182,6 +185,7 @@ func TestPatchableDashboardV2_Apply(t *testing.T) {
"value": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "signoz/TablePanel", "spec": {}},
"queries": [{
"kind": "time_series",
@@ -216,6 +220,7 @@ func TestPatchableDashboardV2_Apply(t *testing.T) {
"value": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "signoz/BarChartPanel", "spec": {}},
"queries": [{
"kind": "time_series",
@@ -327,7 +332,7 @@ func TestPatchableDashboardV2_Apply(t *testing.T) {
// Appending needs a not-yet-placed panel, so add one in the same patch;
// re-placing p1 or p2 would be a duplicate reference.
out, err := decode(t, `[
{"op": "add", "path": "/spec/panels/p3", "value": {"kind": "Panel", "spec": {"plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}}}]}}},
{"op": "add", "path": "/spec/panels/p3", "value": {"kind": "Panel", "spec": {"links": [], "plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}}}]}}},
{"op": "add", "path": "/spec/layouts/0/spec/items/-", "value": {"x": 0, "y": 6, "width": 12, "height": 6, "content": {"$ref": "#/spec/panels/p3"}}}
]`).Apply(base)
require.NoError(t, err)
@@ -346,6 +351,7 @@ func TestPatchableDashboardV2_Apply(t *testing.T) {
"value": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "signoz/TablePanel", "spec": {}},
"queries": [{
"kind": "time_series",
@@ -496,7 +502,7 @@ func TestPatchableDashboardV2_Apply(t *testing.T) {
"path": "/spec/panels/p1",
"value": {
"kind": "Panel",
"spec": {"plugin": {"kind": "signoz/NotAPanel", "spec": {}}}
"spec": {"links": [], "plugin": {"kind": "signoz/NotAPanel", "spec": {}}}
}
}]`).Apply(base)
require.Error(t, err)
@@ -512,6 +518,7 @@ func TestPatchableDashboardV2_Apply(t *testing.T) {
"value": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "signoz/ListPanel", "spec": {}},
"queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/PromQLQuery", "spec": {"name": "A", "query": "up"}}}}]
}
@@ -537,6 +544,7 @@ func TestPatchableDashboardV2_Apply(t *testing.T) {
"value": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [
{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {

View File

@@ -51,10 +51,12 @@ func TestUnmarshalErrorPreservesNestedMessage(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "NonExistentPanel", "spec": {}}
}
}
},
"links": [],
"layouts": []
}`)
@@ -76,7 +78,7 @@ func TestUnmarshalErrorPreservesNestedMessage(t *testing.T) {
func TestValidateEmptySpec(t *testing.T) {
// no variables no panels
data := []byte(`{}`)
data := []byte(`{"links": []}`)
_, err := unmarshalDashboard(data)
assert.NoError(t, err, "expected valid")
}
@@ -107,6 +109,7 @@ func TestValidateOnlyVariables(t *testing.T) {
}
}
],
"links": [],
"layouts": []
}`)
_, err := unmarshalDashboard(data)
@@ -133,6 +136,7 @@ func TestInvalidateDuplicateVariableNames(t *testing.T) {
}
}
],
"links": [],
"layouts": []
}`)
_, err := unmarshalDashboard(data)
@@ -157,6 +161,7 @@ func TestInvalidateVariableNameWithInvalidChars(t *testing.T) {
}
}
],
"links": [],
"layouts": []
}`)
}
@@ -186,6 +191,7 @@ func TestInvalidatePanelKey(t *testing.T) {
"bad key!": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "signoz/TablePanel", "spec": {}},
"queries": [{
"kind": "time_series",
@@ -196,6 +202,7 @@ func TestInvalidatePanelKey(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`)
_, err := unmarshalDashboard(data)
@@ -219,6 +226,7 @@ func TestInvalidateListVariableCrossFields(t *testing.T) {
}
}
],
"links": [],
"layouts": []
}`)
}
@@ -257,6 +265,7 @@ func TestInvalidateEmptyVariableName(t *testing.T) {
cases := map[string][]byte{
"text variable": []byte(`{
"variables": [{"kind": "TextVariable", "spec": {"name": "", "value": "x"}}],
"links": [],
"layouts": []
}`),
"list variable": []byte(`{
@@ -269,6 +278,7 @@ func TestInvalidateEmptyVariableName(t *testing.T) {
"plugin": {"kind": "signoz/DynamicVariable", "spec": {"name": "service.name", "signal": "metrics"}}
}
}],
"links": [],
"layouts": []
}`),
}
@@ -294,10 +304,12 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "NonExistentPanel", "spec": {}}
}
}
},
"links": [],
"layouts": []
}`,
wantContain: "NonExistentPanel",
@@ -313,6 +325,7 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`,
wantContain: "unknown panel kind",
@@ -324,6 +337,7 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [{
"kind": "time_series",
@@ -334,6 +348,7 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`,
wantContain: "FakeQueryPlugin",
@@ -345,6 +360,7 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [{
"kind": "TimeSeriesQuery",
@@ -355,6 +371,7 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`,
wantContain: "unknown request type",
@@ -366,6 +383,7 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [{
"kind": "",
@@ -376,6 +394,7 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`,
wantContain: "unknown request type",
@@ -392,6 +411,7 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
"plugin": {"kind": "FakeVariable", "spec": {}}
}
}],
"links": [],
"layouts": []
}`,
wantContain: "FakeVariable",
@@ -405,6 +425,7 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
"plugin": {"kind": "FakeDatasource", "spec": {}}
}
},
"links": [],
"layouts": []
}`,
wantContain: "FakeDatasource",
@@ -425,13 +446,14 @@ func TestInvalidateOneInvalidPanel(t *testing.T) {
"panels": {
"good": {
"kind": "Panel",
"spec": {"plugin": {"kind": "signoz/NumberPanel", "spec": {}}}
"spec": {"links": [],"plugin": {"kind": "signoz/NumberPanel", "spec": {}}}
},
"bad": {
"kind": "Panel",
"spec": {"plugin": {"kind": "FakePanel", "spec": {}}}
"spec": {"links": [],"plugin": {"kind": "FakePanel", "spec": {}}}
}
},
"links": [],
"layouts": []
}`)
_, err := unmarshalDashboard(data)
@@ -444,6 +466,7 @@ func TestInvalidateLayoutPanelReferences(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "signoz/TablePanel", "spec": {}},
"queries": [{
"kind": "time_series",
@@ -455,7 +478,7 @@ func TestInvalidateLayoutPanelReferences(t *testing.T) {
}
}`
layout := func(items string) []byte {
return []byte(`{` + validPanels + `, "layouts": [{"kind": "Grid", "spec": {"items": [` + items + `]}}]}`)
return []byte(`{` + validPanels + `, "links": [], "layouts": [{"kind": "Grid", "spec": {"items": [` + items + `]}}]}`)
}
tests := []struct {
@@ -511,6 +534,7 @@ func TestRejectUnknownFieldsInPluginSpec(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {"bogusField": true}
@@ -518,6 +542,7 @@ func TestRejectUnknownFieldsInPluginSpec(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`,
wantContain: "bogusField",
@@ -529,6 +554,7 @@ func TestRejectUnknownFieldsInPluginSpec(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [{
"kind": "time_series",
@@ -542,6 +568,7 @@ func TestRejectUnknownFieldsInPluginSpec(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`,
wantContain: "unknownThing",
@@ -561,6 +588,7 @@ func TestRejectUnknownFieldsInPluginSpec(t *testing.T) {
}
}
}],
"links": [],
"layouts": []
}`,
wantContain: "extraField",
@@ -589,6 +617,7 @@ func TestInvalidateWrongFieldTypeInPluginSpec(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {"visualization": {"fillSpans": "notabool"}}
@@ -596,6 +625,7 @@ func TestInvalidateWrongFieldTypeInPluginSpec(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`,
wantContain: "fillSpans",
@@ -607,6 +637,7 @@ func TestInvalidateWrongFieldTypeInPluginSpec(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [{
"kind": "time_series",
@@ -620,6 +651,7 @@ func TestInvalidateWrongFieldTypeInPluginSpec(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`,
wantContain: "",
@@ -639,6 +671,7 @@ func TestInvalidateWrongFieldTypeInPluginSpec(t *testing.T) {
}
}
}],
"links": [],
"layouts": []
}`,
wantContain: "",
@@ -669,6 +702,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {}
@@ -685,6 +719,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`,
wantContain: "signal",
@@ -696,6 +731,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {"chartAppearance": {"lineInterpolation": "cubic"}}
@@ -703,6 +739,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`,
wantContain: "line interpolation",
@@ -714,6 +751,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {"chartAppearance": {"lineStyle": "dotted"}}
@@ -721,6 +759,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`,
wantContain: "line style",
@@ -732,6 +771,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {"chartAppearance": {"fillMode": "striped"}}
@@ -739,6 +779,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`,
wantContain: "fill mode",
@@ -750,6 +791,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {"chartAppearance": {"spanGaps": {"fillOnlyBelow": true, "fillLessThan": "notaduration"}}}
@@ -757,6 +799,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`,
wantContain: "duration",
@@ -768,6 +811,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {"visualization": {"timePreference": "last2Hr"}}
@@ -775,6 +819,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`,
wantContain: "timePreference",
@@ -786,6 +831,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {
"kind": "signoz/BarChartPanel",
"spec": {"legend": {"position": "top"}}
@@ -793,6 +839,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`,
wantContain: "legend position",
@@ -804,6 +851,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {
"kind": "signoz/BarChartPanel",
"spec": {"legend": {"mode": "grid"}}
@@ -811,6 +859,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`,
wantContain: "legend mode",
@@ -822,6 +871,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {
"kind": "signoz/NumberPanel",
"spec": {"thresholds": [{"value": 100, "operator": "above", "color": "Red", "format": "Color"}]}
@@ -829,6 +879,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`,
wantContain: "threshold format",
@@ -840,6 +891,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {
"kind": "signoz/NumberPanel",
"spec": {"thresholds": [{"value": 100, "operator": "!=", "color": "Red", "format": "text"}]}
@@ -847,6 +899,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`,
wantContain: "comparison operator",
@@ -858,6 +911,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {"formatting": {"decimalPrecision": "9"}}
@@ -865,6 +919,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`,
wantContain: "precision",
@@ -896,11 +951,13 @@ func TestThresholdLabelOptional(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {"thresholds": [` + tt.threshold + `]}},
"queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/PromQLQuery", "spec": {"name": "A", "query": "up"}}}}]
}
}
},
"links": [],
"layouts": []
}`)
d, err := unmarshalDashboard(data)
@@ -918,9 +975,10 @@ func TestInvalidatePanelWithoutQueries(t *testing.T) {
"panels": {
"p1": {
"kind": "Panel",
"spec": {"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}}}
"spec": {"links": [],"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}}}
}
},
"links": [],
"layouts": []
}`)
_, err := unmarshalDashboard(data)
@@ -934,11 +992,13 @@ func TestInvalidatePanelWithEmptyQueriesArray(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": []
}
}
},
"links": [],
"layouts": []
}`)
_, err := unmarshalDashboard(data)
@@ -954,6 +1014,7 @@ func TestInvalidatePanelWithMultipleDirectQueries(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [
{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "metrics"}}}},
@@ -962,6 +1023,7 @@ func TestInvalidatePanelWithMultipleDirectQueries(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`)
_, err := unmarshalDashboard(data)
@@ -981,6 +1043,7 @@ func TestValidateRequiredFields(t *testing.T) {
"plugin": {"kind": "` + pluginKind + `", "spec": ` + pluginSpec + `}
}
}],
"links": [],
"layouts": []
}`
}
@@ -990,10 +1053,12 @@ func TestValidateRequiredFields(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "` + panelKind + `", "spec": ` + panelSpec + `}
}
}
},
"links": [],
"layouts": []
}`
}
@@ -1058,6 +1123,7 @@ func TestTimeSeriesPanelDefaults(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {}
@@ -1066,6 +1132,7 @@ func TestTimeSeriesPanelDefaults(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`)
d, err := unmarshalDashboard(data)
@@ -1108,6 +1175,7 @@ func TestNumberPanelDefaults(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {
"kind": "signoz/NumberPanel",
"spec": {"thresholds": [{"value": 100, "color": "Red"}]}
@@ -1116,6 +1184,7 @@ func TestNumberPanelDefaults(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`)
d, err := unmarshalDashboard(data)
@@ -1169,6 +1238,7 @@ func TestStorageRoundTrip(t *testing.T) {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {}
@@ -1179,6 +1249,7 @@ func TestStorageRoundTrip(t *testing.T) {
"p2": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {
"kind": "signoz/NumberPanel",
"spec": {"thresholds": [{"value": 100, "color": "Red"}]}
@@ -1187,6 +1258,7 @@ func TestStorageRoundTrip(t *testing.T) {
}
}
},
"links": [],
"layouts": []
}`)
@@ -1247,7 +1319,7 @@ func TestStorageRoundTrip(t *testing.T) {
}
func TestPostableDashboardV2GenerateNameFlag(t *testing.T) {
const validSpec = `"spec": {"panels": {}, "layouts": []}`
const validSpec = `"spec": {"panels": {}, "layouts": [], "links": []}`
tests := []struct {
scenario string
@@ -1259,13 +1331,13 @@ func TestPostableDashboardV2GenerateNameFlag(t *testing.T) {
}{
{
scenario: "flag true with display.name derives name on conversion",
body: `{"schemaVersion":"` + SchemaVersion + `","generateName":true,"spec":{"display":{"name":"My Dashboard!"},"panels":{},"layouts":[]}}`,
body: `{"schemaVersion":"` + SchemaVersion + `","generateName":true,"spec":{"display":{"name":"My Dashboard!"},"panels":{},"layouts":[],"links":[]}}`,
wantName: "",
wantDisplay: "My Dashboard!",
},
{
scenario: "flag true with non-empty name is rejected",
body: `{"schemaVersion":"` + SchemaVersion + `","name":"already-set","generateName":true,"spec":{"display":{"name":"My Dashboard"},"panels":{},"layouts":[]}}`,
body: `{"schemaVersion":"` + SchemaVersion + `","name":"already-set","generateName":true,"spec":{"display":{"name":"My Dashboard"},"panels":{},"layouts":[],"links":[]}}`,
wantErr: true,
wantErrMatch: "name must be empty when generateName is true",
},
@@ -1425,20 +1497,24 @@ func TestPanelTypeQueryTypeCompatibility(t *testing.T) {
mkQuery := func(panelKind, queryKind, querySpec string) []byte {
return []byte(`{
"panels": {"p1": {"kind": "Panel", "spec": {
"links": [],
"plugin": {"kind": "` + panelKind + `", "spec": {}},
"queries": [{"kind": "` + requestKind(panelKind) + `", "spec": {"plugin": {"kind": "` + queryKind + `", "spec": ` + querySpec + `}}}]
}}},
"links": [],
"layouts": []
}`)
}
mkComposite := func(panelKind, subType, subSpec string) []byte {
return []byte(`{
"panels": {"p1": {"kind": "Panel", "spec": {
"links": [],
"plugin": {"kind": "` + panelKind + `", "spec": {}},
"queries": [{"kind": "` + requestKind(panelKind) + `", "spec": {"plugin": {"kind": "signoz/CompositeQuery", "spec": {
"queries": [{"type": "` + subType + `", "spec": ` + subSpec + `}]
}}}}]
}}},
"links": [],
"layouts": []
}`)
}
@@ -1482,11 +1558,13 @@ func TestCommaSeparatedAggregationRejectedOnWrite(t *testing.T) {
buildDashboardWithLogsAggregation := func(aggregationsJSON string) []byte {
return []byte(`{
"panels": {"p1": {"kind": "Panel", "spec": {
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
"name": "A", "signal": "logs", "aggregations": ` + aggregationsJSON + `
}}}}]
}}},
"links": [],
"layouts": []
}`)
}
@@ -1600,9 +1678,10 @@ func TestValidateGridItemLimit(t *testing.T) {
func TestInvalidateLayoutOverlapViaUnmarshal(t *testing.T) {
data := []byte(`{
"panels": {
"p1": {"kind": "Panel", "spec": {"plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}}}]}},
"p2": {"kind": "Panel", "spec": {"plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}}}]}}
"p1": {"kind": "Panel", "spec": {"links": [],"plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}}}]}},
"p2": {"kind": "Panel", "spec": {"links": [],"plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}}}]}}
},
"links": [],
"layouts": [{"kind": "Grid", "spec": {"items": [
{"x": 0, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p1"}},
{"x": 3, "y": 3, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p2"}}
@@ -1619,8 +1698,9 @@ func TestInvalidateLayoutOverlapViaUnmarshal(t *testing.T) {
func TestInvalidateDuplicatePanelReference(t *testing.T) {
data := []byte(`{
"panels": {
"p1": {"kind": "Panel", "spec": {"plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}}}]}}
"p1": {"kind": "Panel", "spec": {"links": [],"plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}}}]}}
},
"links": [],
"layouts": [{"kind": "Grid", "spec": {"items": [
{"x": 0, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p1"}},
{"x": 6, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p1"}}
@@ -1650,31 +1730,31 @@ func TestInvalidateDisplayNameTooLong(t *testing.T) {
}{
{
scenario: "dashboard display name",
dashboardJSON: `{"display": {"name": "` + tooLong + `"}, "layouts": []}`,
dashboardJSON: `{"display": {"name": "` + tooLong + `"}, "links": [], "layouts": []}`,
expectedLabel: "dashboard",
expectedPath: "spec.display.name",
},
{
scenario: "panel display name",
dashboardJSON: `{"panels": {"p1": {"kind": "Panel", "spec": {"display": {"name": "` + tooLong + `"}, "plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": []}}}, "layouts": []}`,
dashboardJSON: `{"panels": {"p1": {"kind": "Panel", "spec": {"links": [],"display": {"name": "` + tooLong + `"}, "plugin": {"kind": "signoz/TablePanel", "spec": {}}, "queries": []}}}, "links": [], "layouts": []}`,
expectedLabel: "panel",
expectedPath: "spec.panels.p1.spec.display.name",
},
{
scenario: "list variable display name",
dashboardJSON: `{"variables": [{"kind": "ListVariable", "spec": {"name": "svc", "display": {"name": "` + tooLong + `"}, "plugin": {"kind": "signoz/DynamicVariable", "spec": {"name": "service.name", "signal": "metrics"}}}}], "layouts": []}`,
dashboardJSON: `{"variables": [{"kind": "ListVariable", "spec": {"name": "svc", "display": {"name": "` + tooLong + `"}, "plugin": {"kind": "signoz/DynamicVariable", "spec": {"name": "service.name", "signal": "metrics"}}}}], "links": [], "layouts": []}`,
expectedLabel: "variable",
expectedPath: "spec.variables[0].spec.display.name",
},
{
scenario: "text variable display name",
dashboardJSON: `{"variables": [{"kind": "TextVariable", "spec": {"name": "mytext", "value": "v", "display": {"name": "` + tooLong + `"}}}], "layouts": []}`,
dashboardJSON: `{"variables": [{"kind": "TextVariable", "spec": {"name": "mytext", "value": "v", "display": {"name": "` + tooLong + `"}}}], "links": [], "layouts": []}`,
expectedLabel: "variable",
expectedPath: "spec.variables[0].spec.display.name",
},
{
scenario: "layout title",
dashboardJSON: `{"layouts": [{"kind": "Grid", "spec": {"display": {"title": "` + tooLong + `"}, "items": []}}]}`,
dashboardJSON: `{"links": [], "layouts": [{"kind": "Grid", "spec": {"display": {"title": "` + tooLong + `"}, "items": []}}]}`,
expectedLabel: "layout",
expectedPath: "spec.layouts[0].spec.display.title",
},
@@ -1694,7 +1774,7 @@ func TestInvalidateDisplayNameTooLong(t *testing.T) {
// A display name at exactly the limit is accepted.
func TestValidateDisplayNameAtMaxLength(t *testing.T) {
atLimit := strings.Repeat("x", MaxDisplayNameLen)
_, err := unmarshalDashboard([]byte(`{"display": {"name": "` + atLimit + `"}, "layouts": []}`))
_, err := unmarshalDashboard([]byte(`{"display": {"name": "` + atLimit + `"}, "links": [], "layouts": []}`))
assert.NoError(t, err)
}

View File

@@ -228,7 +228,7 @@ func TestRedactVariableQueries(t *testing.T) {
t.Run("leaves non-query variables untouched", func(t *testing.T) {
spec := DashboardSpec{Variables: []Variable{
{Kind: variable.KindList, Spec: &ListVariableSpec{Name: "signal", Plugin: VariablePlugin{Kind: VariableKindDynamic, Spec: &DynamicVariableSpec{Name: "service.name", Signal: telemetrytypes.SignalTraces}}}},
{Kind: variable.KindList, Spec: &ListVariableSpec{Name: "signal", Plugin: VariablePlugin{Kind: VariableKindDynamic, Spec: &DynamicVariableSpec{Name: "service.name", Signal: DynamicVariableSignalTraces}}}},
{Kind: variable.KindList, Spec: &ListVariableSpec{Name: "env", Plugin: VariablePlugin{Kind: VariableKindCustom, Spec: &CustomVariableSpec{CustomValue: "prod,staging"}}}},
}}

View File

@@ -78,7 +78,17 @@ type PanelSpec struct {
Display Display `json:"display" required:"true"`
Plugin PanelPlugin `json:"plugin" required:"true"`
Queries []Query `json:"queries" required:"true" nullable:"false"`
Links []Link `json:"links,omitzero"`
Links []Link `json:"links" required:"true" nullable:"false"`
}
// validateLinks rejects a missing/null links field, where path is the panel's
// location (e.g. "spec.panels.<key>"). A typed client must send [] rather than
// omitting links, so its value round-trips faithfully.
func (s *PanelSpec) validateLinks(path string) error {
if s.Links == nil {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s.spec.links is required; send [] when there are no links", path)
}
return nil
}
// Link replicates dashboard.Link (Perses) so its zero-valued fields survive the

View File

@@ -32,7 +32,53 @@ type DynamicVariableSpec struct {
// Name is the name of the attribute being fetched dynamically from the
// signal. This could be extended to a richer selector in the future.
Name string `json:"name" validate:"required" required:"true"`
Signal telemetrytypes.Signal `json:"signal"`
Signal DynamicVariableSignal `json:"signal" required:"true" nullable:"false"`
}
// DynamicVariableSignal is the telemetry signal a dynamic variable draws its
// values from. Separate from telemetrytypes.Signal because it carries "all"
// (values span every signal) rather than "" for an unpinned query signal.
type DynamicVariableSignal struct{ valuer.String }
var (
DynamicVariableSignalTraces = DynamicVariableSignal{valuer.NewString("traces")}
DynamicVariableSignalLogs = DynamicVariableSignal{valuer.NewString("logs")}
DynamicVariableSignalMetrics = DynamicVariableSignal{valuer.NewString("metrics")}
DynamicVariableSignalAll = DynamicVariableSignal{valuer.NewString("all")} // default
)
func (DynamicVariableSignal) Enum() []any {
return []any{DynamicVariableSignalTraces, DynamicVariableSignalLogs, DynamicVariableSignalMetrics, DynamicVariableSignalAll}
}
func (s DynamicVariableSignal) ValueOrDefault() string {
if s.IsZero() {
return DynamicVariableSignalAll.StringValue()
}
return s.StringValue()
}
func (s DynamicVariableSignal) MarshalJSON() ([]byte, error) {
return json.Marshal(s.ValueOrDefault())
}
func (s *DynamicVariableSignal) UnmarshalJSON(data []byte) error {
var v string
if err := json.Unmarshal(data, &v); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid signal: must be a string, one of `traces`, `logs`, `metrics`, or `all`")
}
if v == "" {
*s = DynamicVariableSignalAll
return nil
}
sig := DynamicVariableSignal{valuer.NewString(v)}
switch sig {
case DynamicVariableSignalTraces, DynamicVariableSignalLogs, DynamicVariableSignalMetrics, DynamicVariableSignalAll:
*s = sig
return nil
default:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid signal %q: must be `traces`, `logs`, `metrics`, or `all`", v)
}
}
type QueryVariableSpec struct {

View File

@@ -80,6 +80,7 @@
}
}
],
"links": [],
"panels": {
"24e2697b": {
"kind": "Panel",
@@ -167,6 +168,7 @@
"ff2f72f1": {
"kind": "Panel",
"spec": {
"links": [],
"display": {
"name": "fraction of calls",
"description": ""
@@ -253,6 +255,7 @@
"011605e7": {
"kind": "Panel",
"spec": {
"links": [],
"display": {
"name": "total resp size"
},
@@ -304,6 +307,7 @@
"e23516fc": {
"kind": "Panel",
"spec": {
"links": [],
"display": {
"name": "num traces for service"
},
@@ -359,6 +363,7 @@
"130c8d6b": {
"kind": "Panel",
"spec": {
"links": [],
"display": {
"name": "num logs for service"
},
@@ -398,6 +403,7 @@
"246f7c6d": {
"kind": "Panel",
"spec": {
"links": [],
"display": {
"name": "num traces for service per resp code"
},
@@ -451,6 +457,7 @@
"21f7d4d0": {
"kind": "Panel",
"spec": {
"links": [],
"display": {
"name": "average latency per service"
},
@@ -493,6 +500,7 @@
"ad5fd556": {
"kind": "Panel",
"spec": {
"links": [],
"display": {
"name": "logs from service"
},
@@ -557,6 +565,7 @@
"f07b59ee": {
"kind": "Panel",
"spec": {
"links": [],
"display": {
"name": "response size buckets"
},
@@ -596,6 +605,7 @@
"e1a41831": {
"kind": "Panel",
"spec": {
"links": [],
"display": {
"name": "trace operator",
"description": ""
@@ -672,6 +682,7 @@
"f0d70491": {
"kind": "Panel",
"spec": {
"links": [],
"display": {
"name": "no results in this promql",
"description": ""
@@ -713,6 +724,7 @@
"0e6eb4ca": {
"kind": "Panel",
"spec": {
"links": [],
"display": {
"name": "no results in this promql",
"description": ""

View File

@@ -12,10 +12,12 @@
}
}
},
"links": [],
"panels": {
"b424e23b": {
"kind": "Panel",
"spec": {
"links": [],
"display": {
"name": ""
},
@@ -58,6 +60,7 @@
"251df4d5": {
"kind": "Panel",
"spec": {
"links": [],
"display": {
"name": ""
},

View File

@@ -209,6 +209,7 @@ def test_create_rejects_invalid_grid_layout(
"kind": "Panel",
"spec": {
"display": {"name": name},
"links": [],
"plugin": {"kind": "signoz/TablePanel", "spec": {}},
"queries": [
{
@@ -410,7 +411,7 @@ def test_update_missing_dashboard_returns_not_found(
json={
"schemaVersion": "v6",
"name": "missing-dashboard",
"spec": {"display": {"name": "Missing Dashboard"}},
"spec": {"display": {"name": "Missing Dashboard"}, "links": []},
"tags": [],
},
headers={"Authorization": f"Bearer {token}"},
@@ -535,7 +536,7 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
json={
"schemaVersion": "v6",
"name": name,
"spec": {"display": {"name": display}},
"spec": {"display": {"name": display}, "links": []},
"tags": tags,
},
headers={"Authorization": f"Bearer {token}"},
@@ -886,7 +887,7 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
update_body = {
"schemaVersion": "v6",
"name": "lc-alpha",
"spec": {"display": {"name": "Alpha Overview"}},
"spec": {"display": {"name": "Alpha Overview"}, "links": []},
"tags": [
{"key": "team", "value": "pulse"},
{"key": "env", "value": "prod"},
@@ -930,7 +931,7 @@ def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-sta
beta_body = {
"schemaVersion": "v6",
"name": "lc-beta",
"spec": {"display": {"name": "Beta Overview"}},
"spec": {"display": {"name": "Beta Overview"}, "links": []},
"tags": [{"key": "team", "value": "pulse"}, {"key": "env", "value": "dev"}],
}
response = requests.put(
@@ -1033,7 +1034,7 @@ def test_dashboard_v2_pin_limit(
json={
"schemaVersion": "v6",
"name": f"pl-{i}",
"spec": {"display": {"name": f"Pin Limit {i}"}},
"spec": {"display": {"name": f"Pin Limit {i}"}, "links": []},
"tags": [],
},
headers={"Authorization": f"Bearer {token}"},
@@ -1131,7 +1132,7 @@ def test_dashboard_v2_like_escaping(
json={
"schemaVersion": "v6",
"name": name,
"spec": {"display": {"name": display}},
"spec": {"display": {"name": display}, "links": []},
"tags": [],
},
headers={"Authorization": f"Bearer {token}"},
@@ -1222,11 +1223,13 @@ def test_dashboard_v2_get_by_metric_name(
"name": "by-metric-builder",
"spec": {
"display": {"name": "by-metric-builder"},
"links": [],
"panels": {
"p-builder": {
"kind": "Panel",
"spec": {
"display": {"name": "D1 builder target"},
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [
{
@@ -1271,11 +1274,13 @@ def test_dashboard_v2_get_by_metric_name(
"name": "by-metric-ch-promql",
"spec": {
"display": {"name": "by-metric-ch-promql"},
"links": [],
"panels": {
"p-ch": {
"kind": "Panel",
"spec": {
"display": {"name": "D2 clickhouse target"},
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [
{
@@ -1297,6 +1302,7 @@ def test_dashboard_v2_get_by_metric_name(
"kind": "Panel",
"spec": {
"display": {"name": "D2 promql target"},
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [
{
@@ -1333,11 +1339,13 @@ def test_dashboard_v2_get_by_metric_name(
"name": "by-metric-promql",
"spec": {
"display": {"name": "by-metric-promql"},
"links": [],
"panels": {
"p-promql": {
"kind": "Panel",
"spec": {
"display": {"name": "D3 promql target"},
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [
{
@@ -1376,11 +1384,13 @@ def test_dashboard_v2_get_by_metric_name(
"name": "by-metric-false-positive",
"spec": {
"display": {"name": "by-metric-false-positive"},
"links": [],
"panels": {
"p-builder": {
"kind": "Panel",
"spec": {
"display": {"name": f"{target_metric} builder"},
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [
{
@@ -1409,6 +1419,7 @@ def test_dashboard_v2_get_by_metric_name(
"kind": "Panel",
"spec": {
"display": {"name": f"{target_metric} clickhouse"},
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [
{
@@ -1430,6 +1441,7 @@ def test_dashboard_v2_get_by_metric_name(
"kind": "Panel",
"spec": {
"display": {"name": f"{target_metric} promql"},
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [
{
@@ -1503,11 +1515,13 @@ def test_dashboard_v2_rejects_comma_separated_aggregation(
"tags": [],
"spec": {
"display": {"name": "Aggregation"},
"links": [],
"panels": {
"p-agg": {
"kind": "Panel",
"spec": {
"display": {"name": "agg"},
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [
{
@@ -1659,6 +1673,7 @@ def test_dashboard_v2_roundtrip_preserves_zero_values(
"kind": "Panel",
"spec": {
"display": {"name": "timeseries"},
"links": [],
"plugin": {
"kind": "signoz/TimeSeriesPanel",
"spec": {"thresholds": [{"value": 0, "color": "#c2780b"}]},
@@ -1681,6 +1696,7 @@ def test_dashboard_v2_roundtrip_preserves_zero_values(
"kind": "Panel",
"spec": {
"display": {"name": "promql"},
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [
{
@@ -1700,6 +1716,7 @@ def test_dashboard_v2_roundtrip_preserves_zero_values(
"kind": "Panel",
"spec": {
"display": {"name": "clickhouse"},
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [
{
@@ -1795,11 +1812,9 @@ def test_dashboard_v2_roundtrip_preserves_zero_values(
for description, spec, key in absent_cases:
assert key not in spec, description
# A panel with no links comes back with no links value (null or absent);
# either is fine for a typed client (an unset optional attribute stays
# unset), so this is not a drift. The explicit [] case above is what the
# fix guarantees round-trips.
assert panels["timeseries"]["spec"].get("links") is None, "unset panel links stays unset"
# links is a required, non-nullable field: an explicit [] round-trips as [],
# so a typed client always reads a concrete array (never null or absent).
assert panels["timeseries"]["spec"]["links"] == [], "panel links round-trip as []"
finally:
requests.delete(
signoz.self.host_configs["8080"].get(f"{BASE_URL}/{dashboard_id}"),

View File

@@ -59,6 +59,7 @@ def test_public_dashboard_v2(
"spec": {
"display": {"name": "Sample Dashboard", "description": "Used for integration tests"},
"duration": "1h",
"links": [],
"variables": [
{
"kind": "ListVariable",
@@ -77,6 +78,7 @@ def test_public_dashboard_v2(
"kind": "Panel",
"spec": {
"display": {"name": "total"},
"links": [],
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {"visualization": {"fillSpans": True}}},
"queries": [
{