Compare commits

..

2 Commits

Author SHA1 Message Date
Abhi kumar
9cba7e88ec Merge branch 'main' into e2e/dashboard-create-flow 2026-05-18 00:19:17 +05:30
Abhi Kumar
e4949379e2 test: added e2e tests for dashboard create flow 2026-05-18 00:11:35 +05:30
12 changed files with 715 additions and 539 deletions

2
go.mod
View File

@@ -23,7 +23,6 @@ require (
github.com/go-playground/validator/v10 v10.27.0
github.com/go-redis/redismock/v9 v9.2.0
github.com/go-viper/mapstructure/v2 v2.5.0
github.com/goccy/go-yaml v1.19.2
github.com/gojek/heimdall/v7 v7.0.3
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
@@ -132,6 +131,7 @@ require (
github.com/go-openapi/swag/yamlutils v0.25.5 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/hashicorp/go-metrics v0.5.4 // indirect
github.com/huandu/go-clone v1.7.3 // indirect

View File

@@ -2,17 +2,12 @@ package implspanmapper
import (
"context"
"encoding/json"
"github.com/SigNoz/signoz/pkg/modules/spanmapper"
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
"github.com/SigNoz/signoz/pkg/types/opamptypes"
"github.com/SigNoz/signoz/pkg/types/spantypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
var _ spanmapper.Module = (*module)(nil)
type module struct {
store spantypes.SpanMapperStore
}
@@ -39,22 +34,11 @@ func (module *module) UpdateGroup(ctx context.Context, orgID, id valuer.UUID, na
return err
}
group.Update(name, condition, enabled, updatedBy)
err = module.store.UpdateGroup(ctx, group)
if err != nil {
return err
}
agentConf.NotifyConfigUpdate(ctx)
return nil
return module.store.UpdateGroup(ctx, group)
}
func (module *module) DeleteGroup(ctx context.Context, orgID, id valuer.UUID) error {
err := module.store.DeleteGroup(ctx, orgID, id)
if err != nil {
return err
}
agentConf.NotifyConfigUpdate(ctx)
return nil
return module.store.DeleteGroup(ctx, orgID, id)
}
func (module *module) ListMappers(ctx context.Context, orgID, groupID valuer.UUID) ([]*spantypes.SpanMapper, error) {
@@ -70,12 +54,7 @@ func (module *module) CreateMapper(ctx context.Context, orgID, groupID valuer.UU
if _, err := module.store.GetGroup(ctx, orgID, groupID); err != nil {
return err
}
err := module.store.CreateMapper(ctx, mapper)
if err != nil {
return err
}
agentConf.NotifyConfigUpdate(ctx)
return nil
return module.store.CreateMapper(ctx, mapper)
}
func (module *module) UpdateMapper(ctx context.Context, orgID, groupID, id valuer.UUID, fieldContext spantypes.FieldContext, config *spantypes.SpanMapperConfig, enabled *bool, updatedBy string) error {
@@ -87,72 +66,9 @@ func (module *module) UpdateMapper(ctx context.Context, orgID, groupID, id value
return err
}
mapper.Update(fieldContext, config, enabled, updatedBy)
err = module.store.UpdateMapper(ctx, mapper)
if err != nil {
return err
}
agentConf.NotifyConfigUpdate(ctx)
return nil
return module.store.UpdateMapper(ctx, mapper)
}
func (module *module) DeleteMapper(ctx context.Context, orgID, groupID, id valuer.UUID) error {
err := module.store.DeleteMapper(ctx, orgID, groupID, id)
if err != nil {
return err
}
agentConf.NotifyConfigUpdate(ctx)
return nil
}
func (module *module) AgentFeatureType() agentConf.AgentFeatureType {
return spantypes.SpanAttrMappingFeatureType
}
func (module *module) RecommendAgentConfig(orgID valuer.UUID, currentConfYaml []byte, configVersion *opamptypes.AgentConfigVersion) ([]byte, string, error) {
ctx := context.Background()
enabled, err := module.listEnabledGroupsWithMappers(ctx, orgID)
if err != nil {
return nil, "", err
}
updatedConf, err := spantypes.GenerateCollectorConfigWithSpanMapperProcessor(currentConfYaml, enabled)
if err != nil {
return nil, "", err
}
serialized, err := json.Marshal(enabled)
if err != nil {
return nil, "", err
}
return updatedConf, string(serialized), nil
}
// listEnabledGroupsWithMappers returns groups with their mappers.
func (module *module) listEnabledGroupsWithMappers(ctx context.Context, orgID valuer.UUID) ([]*spantypes.SpanMapperGroupWithMappers, error) {
enabled := true
groups, err := module.store.ListGroups(ctx, orgID, &spantypes.ListSpanMapperGroupsQuery{Enabled: &enabled})
if err != nil {
return nil, err
}
out := make([]*spantypes.SpanMapperGroupWithMappers, 0, len(groups))
for _, g := range groups {
mappers, err := module.store.ListMappers(ctx, orgID, g.ID)
if err != nil {
return nil, err
}
if len(mappers) == 0 {
continue
}
enabledMappers := make([]*spantypes.SpanMapper, 0, len(mappers))
for _, m := range mappers {
if m.Enabled {
enabledMappers = append(enabledMappers, m)
}
}
out = append(out, &spantypes.SpanMapperGroupWithMappers{Group: g, Mappers: enabledMappers})
}
return out, nil
return module.store.DeleteMapper(ctx, orgID, groupID, id)
}

View File

@@ -4,16 +4,12 @@ import (
"context"
"net/http"
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
"github.com/SigNoz/signoz/pkg/types/spantypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
// Module defines the business logic for span attribute mapping groups and mappers.
type Module interface {
// Since this module interacts with OpAMP, it must implement the AgentFeature interface.
agentConf.AgentFeature
// Group operations
ListGroups(ctx context.Context, orgID valuer.UUID, q *spantypes.ListSpanMapperGroupsQuery) ([]*spantypes.SpanMapperGroup, error)
GetGroup(ctx context.Context, orgID, id valuer.UUID) (*spantypes.SpanMapperGroup, error)

View File

@@ -13,9 +13,7 @@ var (
ErrCodeMappingGroupAlreadyExists = errors.MustNewCode("span_attribute_mapping_group_already_exists")
)
// SpanMapperGroupCondition gates whether a group's rules run for a given span.
// A group runs when any attribute or resource key on the span CONTAINS one of
// the listed substrings (plain substring match — no glob syntax).
// A group runs when any of the listed attribute/resource key patterns match.
type SpanMapperGroupCondition struct {
Attributes []string `json:"attributes" required:"true" nullable:"true"`
Resource []string `json:"resource" required:"true" nullable:"true"`

View File

@@ -1,144 +0,0 @@
package spantypes
import (
"sort"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
"github.com/goccy/go-yaml"
)
const (
SpanAttrMappingFeatureType agentConf.AgentFeatureType = "span_attr_mapping"
ProcessorName = "signozspanmapper"
)
var (
ErrCodeInvalidCollectorConfig = errors.MustNewCode("invalid_collector_config")
ErrCodeBuildMappingProcessorConfig = errors.MustNewCode("build_mapping_processor_config")
)
type SpanMapperGroupWithMappers struct {
Group *SpanMapperGroup `json:"group"`
Mappers []*SpanMapper `json:"mappers"`
}
// spanMapperProcessorConfig is the collector config for signozspanmapper.
type spanMapperProcessorConfig struct {
Groups []spanMapperProcessorGroup `yaml:"groups" json:"groups"`
}
type spanMapperProcessorGroup struct {
ID string `yaml:"id" json:"id"`
ExistsAny spanMapperProcessorExistsAny `yaml:"exists_any" json:"exists_any"`
Attributes []spanMapperProcessorAttribute `yaml:"attributes" json:"attributes"`
}
type spanMapperProcessorExistsAny struct {
Attributes []string `yaml:"attributes,omitempty" json:"attributes,omitempty"`
Resource []string `yaml:"resource,omitempty" json:"resource,omitempty"`
}
type spanMapperProcessorAttribute struct {
Target string `yaml:"target" json:"target"`
Context string `yaml:"context,omitempty" json:"context,omitempty"`
Action string `yaml:"action,omitempty" json:"action,omitempty"`
Sources []string `yaml:"sources" json:"sources"`
}
func GenerateCollectorConfigWithSpanMapperProcessor(currentConfYaml []byte, groups []*SpanMapperGroupWithMappers) ([]byte, error) {
var collectorConf map[string]any
if err := yaml.Unmarshal(currentConfYaml, &collectorConf); err != nil {
return nil, errors.Wrapf(err, errors.TypeInvalidInput, ErrCodeInvalidCollectorConfig, "failed to unmarshal collector config")
}
// rare but don't do anything in this case, also means it's just comments.
if collectorConf == nil {
collectorConf = map[string]any{}
}
processors := map[string]any{}
if existing, ok := collectorConf["processors"]; ok && existing != nil {
p, ok := existing.(map[string]any)
if !ok {
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeInvalidCollectorConfig, "collector config 'processors' must be a mapping, got %T", existing)
}
processors = p
}
procConfig := buildProcessorConfig(groups)
configBytes, err := yaml.Marshal(procConfig)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInternal, ErrCodeBuildMappingProcessorConfig, "failed to marshal span mapper processor config")
}
var configMap any
if err := yaml.Unmarshal(configBytes, &configMap); err != nil {
return nil, errors.Wrapf(err, errors.TypeInternal, ErrCodeBuildMappingProcessorConfig, "failed to re-unmarshal span mapper processor config")
}
processors[ProcessorName] = configMap
collectorConf["processors"] = processors
out, err := yaml.Marshal(collectorConf)
if err != nil {
return nil, errors.Wrapf(err, errors.TypeInternal, ErrCodeBuildMappingProcessorConfig, "failed to marshal collector config")
}
return out, nil
}
func buildProcessorConfig(groups []*SpanMapperGroupWithMappers) *spanMapperProcessorConfig {
out := make([]spanMapperProcessorGroup, 0, len(groups))
for _, gm := range groups {
rules := make([]spanMapperProcessorAttribute, 0, len(gm.Mappers))
for _, m := range gm.Mappers {
rules = append(rules, buildAttributeRule(m))
}
out = append(out, spanMapperProcessorGroup{
ID: gm.Group.Name,
ExistsAny: spanMapperProcessorExistsAny{
Attributes: gm.Group.Condition.Attributes,
Resource: gm.Group.Condition.Resource,
},
Attributes: rules,
})
}
return &spanMapperProcessorConfig{Groups: out}
}
// buildAttributeRule maps a single SpanMapper to a collector attribute rule.
// Sources are sorted by Priority DESC (highest-priority first), and read-from-
// resource sources are encoded via the "resource." prefix.
func buildAttributeRule(m *SpanMapper) spanMapperProcessorAttribute {
sources := make([]SpanMapperSource, len(m.Config.Sources))
copy(sources, m.Config.Sources)
sort.SliceStable(sources, func(i, j int) bool { return sources[i].Priority > sources[j].Priority })
keys := make([]string, 0, len(sources))
for _, s := range sources {
if s.Context == FieldContextResource {
keys = append(keys, FieldContextResource.StringValue()+"."+s.Key)
} else {
keys = append(keys, s.Key)
}
}
action := SpanMapperOperationCopy
if len(sources) > 0 && sources[0].Operation == SpanMapperOperationMove {
action = SpanMapperOperationMove
}
ctx := FieldContextSpanAttribute
if m.FieldContext == FieldContextResource {
ctx = FieldContextResource
}
return spanMapperProcessorAttribute{
Target: m.Name,
Context: ctx.StringValue(),
Action: action.StringValue(),
Sources: keys,
}
}

View File

@@ -1,193 +0,0 @@
package spantypes
import (
"os"
"path/filepath"
"testing"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
)
func TestGenerateCollectorConfigWithSpanMapperProcessor(t *testing.T) {
t.Parallel()
baseline := loadFixture(t, "collector_baseline.yaml")
tests := []struct {
name string
groups []*SpanMapperGroupWithMappers
want string
}{
{
name: "no_groups",
want: "collector_no_groups.yaml",
},
{
name: "with_groups",
groups: []*SpanMapperGroupWithMappers{
{
Group: newGroup("llm", []string{"model"}, []string{"service.name"}),
Mappers: []*SpanMapper{
newMapper("gen_ai.request.model", FieldContextResource,
attrSrc("gen_ai.llm.model", SpanMapperOperationCopy, 3),
attrSrc("llm.model", SpanMapperOperationCopy, 2),
resSrc("service.name", SpanMapperOperationCopy, 1),
),
newMapper("gen_ai.request.tokens", FieldContextSpanAttribute,
attrSrc("gen_ai.request_tokens", SpanMapperOperationCopy, 2),
attrSrc("llm.tokens", SpanMapperOperationCopy, 1),
),
newMapper("gen_ai.request.input", FieldContextSpanAttribute,
attrSrc("gen_ai.input", SpanMapperOperationMove, 2),
attrSrc("llm.input", SpanMapperOperationMove, 1),
),
},
},
{
Group: newGroup("agent", []string{"agent."}, nil),
Mappers: []*SpanMapper{
newMapper("gen_ai.agent.name", FieldContextSpanAttribute,
attrSrc("agent.name", SpanMapperOperationCopy, 2),
attrSrc("llm.agent.name", SpanMapperOperationCopy, 1),
),
newMapper("gen_ai.agent.id", FieldContextSpanAttribute,
attrSrc("gen_ai.agent.id", SpanMapperOperationCopy, 2),
attrSrc("llm.agent.id", SpanMapperOperationCopy, 1),
),
},
},
{
Group: newGroup("tool", []string{"agent."}, nil),
Mappers: []*SpanMapper{
newMapper("gen_ai.tool.name", FieldContextSpanAttribute,
attrSrc("ai.tool.name", SpanMapperOperationCopy, 2),
attrSrc("llm.tool.name", SpanMapperOperationCopy, 1),
),
},
},
},
want: "collector_with_groups.yaml",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got, err := GenerateCollectorConfigWithSpanMapperProcessor(baseline, tc.groups)
require.NoError(t, err)
assertYAMLEqual(t, loadFixture(t, tc.want), got)
})
}
}
func TestGenerateCollectorConfigWithSpanMapperProcessor_Errors(t *testing.T) {
t.Parallel()
tests := []struct {
name string
in []byte
}{
{"processors_not_a_map", []byte("processors: not-a-map\n")},
{"malformed_yaml", []byte(": :")},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
_, err := GenerateCollectorConfigWithSpanMapperProcessor(tc.in, nil)
require.Error(t, err)
assert.True(t, errors.Ast(err, errors.TypeInvalidInput), "want TypeInvalidInput, got %v", err)
assert.True(t, errors.Asc(err, ErrCodeInvalidCollectorConfig), "want ErrCodeInvalidCollectorConfig, got %v", err)
})
}
}
func TestBuildAttributeRule(t *testing.T) {
t.Parallel()
tests := []struct {
name string
mapper *SpanMapper
want spanMapperProcessorAttribute
}{
{
name: "priority_sort_and_resource_prefix",
mapper: newMapper("gen_ai.request.model", FieldContextResource,
attrSrc("llm.model", SpanMapperOperationCopy, 20),
resSrc("service.name", SpanMapperOperationCopy, 10),
attrSrc("gen_ai.llm.model", SpanMapperOperationCopy, 30),
),
want: spanMapperProcessorAttribute{
Target: "gen_ai.request.model",
Context: FieldContextResource.StringValue(),
Action: SpanMapperOperationCopy.StringValue(),
Sources: []string{"gen_ai.llm.model", "llm.model", "resource.service.name"},
},
},
{
name: "move_action_follows_highest_priority_source",
mapper: newMapper("gen_ai.request.input", FieldContextSpanAttribute,
attrSrc("gen_ai.input", SpanMapperOperationMove, 20),
attrSrc("llm.input", SpanMapperOperationMove, 10),
),
want: spanMapperProcessorAttribute{
Target: "gen_ai.request.input",
Context: FieldContextSpanAttribute.StringValue(),
Action: SpanMapperOperationMove.StringValue(),
Sources: []string{"gen_ai.input", "llm.input"},
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tc.want, buildAttributeRule(tc.mapper))
})
}
}
func loadFixture(t *testing.T, name string) []byte {
t.Helper()
b, err := os.ReadFile(filepath.Join("testdata", name))
require.NoError(t, err)
return b
}
// assertYAMLEqual compares two YAML documents structurally so key order and
// slice formatting do not matter.
func assertYAMLEqual(t *testing.T, want, got []byte) {
t.Helper()
var w, g any
require.NoError(t, yaml.Unmarshal(want, &w))
require.NoError(t, yaml.Unmarshal(got, &g))
assert.Equal(t, w, g)
}
func newGroup(name string, attrs, res []string) *SpanMapperGroup {
return &SpanMapperGroup{
Name: name,
Condition: SpanMapperGroupCondition{Attributes: attrs, Resource: res},
Enabled: true,
}
}
func newMapper(name string, target FieldContext, sources ...SpanMapperSource) *SpanMapper {
return &SpanMapper{
Name: name,
FieldContext: target,
Config: SpanMapperConfig{Sources: sources},
Enabled: true,
}
}
func attrSrc(key string, op SpanMapperOperation, priority int) SpanMapperSource {
return SpanMapperSource{Key: key, Context: FieldContextSpanAttribute, Operation: op, Priority: priority}
}
func resSrc(key string, op SpanMapperOperation, priority int) SpanMapperSource {
return SpanMapperSource{Key: key, Context: FieldContextResource, Operation: op, Priority: priority}
}

View File

@@ -1,17 +0,0 @@
receivers:
otlp:
protocols:
grpc:
processors:
signozspanmapper:
groups: []
batch: {}
exporters:
otlp:
endpoint: localhost:4317
service:
pipelines:
traces:
receivers: [otlp]
processors: [signozspanmapper, batch]
exporters: [otlp]

View File

@@ -1,17 +0,0 @@
receivers:
otlp:
protocols:
grpc:
processors:
signozspanmapper:
groups: []
batch: {}
exporters:
otlp:
endpoint: localhost:4317
service:
pipelines:
traces:
receivers: [otlp]
processors: [signozspanmapper, batch]
exporters: [otlp]

View File

@@ -1,71 +0,0 @@
receivers:
otlp:
protocols:
grpc:
processors:
signozspanmapper:
groups:
- id: llm
exists_any:
attributes:
- model
resource:
- service.name
attributes:
- target: gen_ai.request.model
context: resource
action: copy
sources:
- gen_ai.llm.model
- llm.model
- resource.service.name
- target: gen_ai.request.tokens
context: attribute
action: copy
sources:
- gen_ai.request_tokens
- llm.tokens
- target: gen_ai.request.input
context: attribute
action: move
sources:
- gen_ai.input
- llm.input
- id: agent
exists_any:
attributes:
- agent.
attributes:
- target: gen_ai.agent.name
context: attribute
action: copy
sources:
- agent.name
- llm.agent.name
- target: gen_ai.agent.id
context: attribute
action: copy
sources:
- gen_ai.agent.id
- llm.agent.id
- id: tool
exists_any:
attributes:
- agent.
attributes:
- target: gen_ai.tool.name
context: attribute
action: copy
sources:
- ai.tool.name
- llm.tool.name
batch: {}
exporters:
otlp:
endpoint: localhost:4317
service:
pipelines:
traces:
receivers: [otlp]
processors: [signozspanmapper, batch]
exporters: [otlp]

View File

@@ -3,6 +3,10 @@ import path from 'path';
import type { APIRequestContext, Locator, Page } from '@playwright/test';
import apmMetricsTemplate from '../testdata/apm-metrics.json';
import queriesData from '../testdata/queries.json';
export type SignalType = 'metrics' | 'logs' | 'traces';
export type QueriesData = typeof queriesData;
// ─── Constants ───────────────────────────────────────────────────────────
//
@@ -177,3 +181,145 @@ export async function openDashboardActionMenu(
await icon.click();
return page.getByRole('tooltip');
}
// ─── Dashboard detail page helpers ──────────────────────────────────────────
/**
* Click the Configure button (`data-testid="show-drawer"`) on a dashboard
* detail page and wait for the settings drawer (`.settings-container-root`) to
* be visible. Works from both the empty-state view and the populated toolbar —
* both render the same testid.
*
* Returns the drawer locator so callers can scope further assertions to it.
*/
export async function openDashboardSettingsDrawer(page: Page): Promise<Locator> {
await page.getByTestId('show-drawer').first().click();
const drawer = page.locator('.settings-container-root');
await drawer.waitFor({ state: 'visible' });
return drawer;
}
/**
* Click `data-testid="save-dashboard-config"` and wait for the resulting
* `PUT /api/v1/dashboards/<id>` response. The Save button is only rendered
* when there is at least one unsaved change — callers must ensure the drawer
* has been dirtied before calling this.
*/
export async function saveDashboardSettings(page: Page): Promise<void> {
const patchResponse = page.waitForResponse(
(r) =>
r.request().method() === 'PUT' && /\/api\/v1\/dashboards\//.test(r.url()),
);
await page.getByTestId('save-dashboard-config').click();
await patchResponse;
}
/**
* Rename a dashboard via the toolbar options popover:
* opens the popover (`data-testid="options"`), clicks "Rename", fills the
* input, clicks "Rename Dashboard", and waits for the PUT response.
*
* Pre-condition: the caller must be on the dashboard detail page.
*/
export async function renameDashboardViaToolbar(
page: Page,
newTitle: string,
): Promise<void> {
await page.getByTestId('options').click();
await page.getByRole('button', { name: 'Rename' }).click();
const modal = page.getByRole('dialog');
await modal.waitFor({ state: 'visible' });
const input = modal.getByTestId('dashboard-name');
await input.clear();
await input.fill(newTitle);
const patchResponse = page.waitForResponse(
(r) =>
r.request().method() === 'PUT' && /\/api\/v1\/dashboards\//.test(r.url()),
);
await page.getByRole('button', { name: 'Rename Dashboard' }).click();
await patchResponse;
await modal.waitFor({ state: 'hidden' });
}
// ─── Add panel flow ─────────────────────────────────────────────────────────
/**
* From the dashboard detail page (must already be loaded), drive the full
* "Add Panel" flow for the given signal type:
* 1. Click the empty-state `add-panel` CTA to open the New Panel modal.
* 2. Pick the Time Series panel type.
* 3. Fill the panel name in the right pane (drives the post-save assertion).
* 4. For metrics: type the metric name from `queries.json` into the metric
* AutoComplete and select it from the dropdown. For logs/traces: switch
* the data-source selector to LOGS / TRACES; default Query Builder state
* is sufficient (queries.json query strings are empty by design).
* 5. Click Save Changes, confirm the modal, and wait for the
* PUT /api/v1/dashboards/<id> response.
*
* Throws if the PUT response is not 2xx. After return, the page is back on
* the dashboard detail page; the caller asserts the panel rendered.
*/
export async function configureAndSavePanel(
page: Page,
signal: SignalType,
panelTitle: string,
): Promise<void> {
await page.getByTestId('add-panel').click();
const newPanelModal = page
.getByRole('dialog')
.filter({ hasText: 'New Panel' });
await newPanelModal.waitFor({ state: 'visible' });
await newPanelModal.getByTestId('panel-type-graph').click();
await page.getByTestId('new-widget-save').waitFor({ state: 'visible' });
await page.getByTestId('panel-name-input').fill(panelTitle);
if (signal === 'metrics') {
const metricName = queriesData.metrics.metricName;
// The testid is on the Ant Select wrapper <div>; the editable input
// lives inside it. Target the descendant input for fill().
const metricInput = page.getByTestId('metric-name-selector-0').locator('input');
await metricInput.click();
await metricInput.fill(metricName);
// AutoComplete debounces and fetches; wait for the option then click.
await page
.locator('.ant-select-item-option-content', { hasText: metricName })
.first()
.click();
} else {
// logs / traces — switch the data source. Default query is sufficient.
await page.getByTestId('query-data-source-selector-0').click();
await page
.locator('.ant-select-item-option-content', {
hasText: signal.toUpperCase(),
})
.click();
}
const putResponse = page.waitForResponse(
(r) =>
r.request().method() === 'PUT' && /\/api\/v1\/dashboards\//.test(r.url()),
);
await page.getByTestId('new-widget-save').click();
// Confirmation modal (title varies: "Save Widget" vs "Unsaved Changes" —
// don't assert title, just click OK on the topmost dialog).
const confirmModal = page.getByRole('dialog').last();
await confirmModal.waitFor({ state: 'visible' });
await confirmModal.getByRole('button', { name: /^OK$/i }).click();
const res = await putResponse;
if (!res.ok()) {
throw new Error(
`PUT /api/v1/dashboards failed ${res.status()}: ${await res.text()}`,
);
}
// Save navigates back to /dashboard/<id> (no /new suffix).
await page.waitForURL(/\/dashboard\/[0-9a-f-]+(?:\?|$)/);
}

12
tests/e2e/testdata/queries.json vendored Normal file
View File

@@ -0,0 +1,12 @@
{
"logs": {
"query": ""
},
"metrics": {
"metricName": "signoz_calls_total",
"query": ""
},
"traces": {
"query": ""
}
}

View File

@@ -0,0 +1,550 @@
import path from 'path';
import type { Page } from '@playwright/test';
import { expect, test } from '../../fixtures/auth';
import { newAdminContext } from '../../helpers/auth';
import {
APM_METRICS_TITLE,
authToken,
configureAndSavePanel,
createDashboardViaApi,
deleteDashboardViaApi,
gotoDashboardsList,
openDashboardSettingsDrawer,
renameDashboardViaToolbar,
saveDashboardSettings,
SEARCH_PLACEHOLDER,
} from '../../helpers/dashboards';
// All tests mutate dashboard state (create / rename / delete). Run serially to
// prevent cross-test interference on the list and detail pages.
test.describe.configure({ mode: 'serial' });
// ─── Suite-level seed registry ────────────────────────────────────────────────
//
// Every dashboard created by any test is registered here; one afterAll tears
// them all down. Tests that don't create anything (TC-10, TC-11, TC-13) need
// no cleanup entry.
const seedIds = new Set<string>();
const BASE_FIXTURE_TITLE = 'create-flow-base-fixture';
const APM_METRICS_TESTDATA_PATH = path.resolve(
__dirname,
'../../testdata/apm-metrics.json',
);
async function seed(page: Page, title: string): Promise<string> {
const id = await createDashboardViaApi(page, title);
seedIds.add(id);
return id;
}
test.beforeAll(async ({ browser }) => {
// Seed one base dashboard so the list is non-empty and the
// `new-dashboard-cta` header button is rendered for all tests that
// drive the "New dashboard" dropdown from the list page.
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
seedIds.add(await createDashboardViaApi(page, BASE_FIXTURE_TITLE));
} finally {
await ctx.close();
}
});
test.afterAll(async ({ browser }) => {
if (seedIds.size === 0) return;
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const token = await authToken(page);
for (const id of [...seedIds]) {
await deleteDashboardViaApi(ctx.request, id, token);
seedIds.delete(id);
}
} finally {
await ctx.close();
}
});
test.describe('Dashboard Create Flow', () => {
// ─── 1. Create Dashboard (blank) ─────────────────────────────────────────
test('TC-01 blank create lands on onboarding state with correct default title', async ({
authedPage: page,
}) => {
await gotoDashboardsList(page);
const postResponse = page.waitForResponse(
(r) =>
r.request().method() === 'POST' && /\/api\/v1\/dashboards/.test(r.url()),
);
await page.getByTestId('new-dashboard-cta').click();
await page.getByTestId('create-dashboard-menu-cta').click();
const res = await postResponse;
await page.waitForURL(/\/dashboard\/[0-9a-f-]+/);
expect(res.status()).toBeGreaterThanOrEqual(200);
expect(res.status()).toBeLessThan(300);
const body = (await res.json()) as {
data: { data: { title: string }; id: string };
};
expect(body.data.data.title).toBe('Sample Title');
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
// DashboardDescription always renders dashboard-title even on blank dashboards.
await expect(page.getByTestId('dashboard-title')).toBeVisible();
await expect(page.getByText('Welcome to your new dashboard')).toBeVisible();
await expect(page.getByText('Configure your new dashboard')).toBeVisible();
await expect(page.getByTestId('show-drawer').first()).toBeVisible();
await expect(page.getByTestId('add-panel')).toBeVisible();
// Register the UI-created dashboard for cleanup.
const id = body.data.id;
expect(id, 'POST response must include a dashboard id').toBeTruthy();
seedIds.add(id);
});
test('TC-02 configure drawer opens with Overview tab and pre-fills existing title', async ({
authedPage: page,
}) => {
const id = await seed(page, 'create-flow-tc02');
await page.goto(`/dashboard/${id}`);
const drawer = await openDashboardSettingsDrawer(page);
// Overview tab is the default active tab.
await expect(drawer.getByRole('button', { name: 'Overview' })).toBeVisible();
const nameInput = drawer.getByTestId('dashboard-name');
await expect(nameInput).toHaveValue('create-flow-tc02');
const descInput = drawer.getByTestId('dashboard-desc');
await expect(descInput).toBeVisible();
await expect(descInput).toHaveValue('');
await expect(
drawer.getByPlaceholder('Start typing your tag name'),
).toBeVisible();
// Ant Drawer does not close on Escape — use the X close button in the header.
await drawer.getByRole('button', { name: 'Close' }).click();
await expect(drawer).not.toHaveClass(/ant-drawer-open/);
});
test('TC-03 rename title, add description and tags, save persists to list', async ({
authedPage: page,
}) => {
const id = await seed(page, 'create-flow-tc03-original');
await page.goto(`/dashboard/${id}`);
const drawer = await openDashboardSettingsDrawer(page);
const nameInput = drawer.getByTestId('dashboard-name');
await nameInput.clear();
await nameInput.fill('create-flow-tc03-renamed');
await expect(drawer.getByText(/1 unsaved change/)).toBeVisible();
await drawer.getByTestId('dashboard-desc').fill('A test description');
await expect(drawer.getByText(/2 unsaved changes/)).toBeVisible();
const tagInput = drawer.getByPlaceholder('Start typing your tag name');
await tagInput.click();
await tagInput.fill('e2e-tag');
await page.keyboard.press('Enter');
await expect(drawer.getByText(/3 unsaved changes/)).toBeVisible();
// Click save and wait for the unsaved-changes footer to disappear — the
// footer only clears after the PUT success callback re-syncs local state.
await page.getByTestId('save-dashboard-config').click();
await expect(drawer.getByText(/unsaved change/)).not.toBeVisible();
await drawer.getByRole('button', { name: 'Close' }).click();
// Renamed dashboard appears in the list.
await gotoDashboardsList(page);
const searchInput = page.getByPlaceholder(SEARCH_PLACEHOLDER);
await searchInput.fill('create-flow-tc03-renamed');
await expect(page.getByText('create-flow-tc03-renamed').first()).toBeVisible();
// Tag search also surfaces the renamed dashboard.
await searchInput.fill('e2e-tag');
await expect(page.getByText('create-flow-tc03-renamed').first()).toBeVisible();
});
test('TC-04 discard reverts unsaved changes without API call', async ({
authedPage: page,
}) => {
const id = await seed(page, 'create-flow-tc04');
await page.goto(`/dashboard/${id}`);
const drawer = await openDashboardSettingsDrawer(page);
const nameInput = drawer.getByTestId('dashboard-name');
await nameInput.clear();
await nameInput.fill('create-flow-tc04-discarded');
await drawer.getByTestId('dashboard-desc').fill('discarded desc');
await expect(drawer.getByText(/unsaved change/)).toBeVisible();
// Intercept any PUT to detect an unwanted save.
let patchFired = false;
await page.route(/\/api\/v1\/dashboards\//, (route) => {
if (route.request().method() === 'PUT') {
patchFired = true;
}
route.continue();
});
await drawer.getByRole('button', { name: 'Discard' }).click();
await expect(drawer.getByText(/unsaved change/)).not.toBeVisible();
await expect(nameInput).toHaveValue('create-flow-tc04');
await expect(drawer.getByTestId('dashboard-desc')).toHaveValue('');
expect(patchFired).toBe(false);
});
test('TC-05 rename via toolbar options popover persists to the toolbar title', async ({
authedPage: page,
}) => {
const id = await seed(page, 'create-flow-tc05');
await page.goto(`/dashboard/${id}`);
// DashboardDescription toolbar always renders — even on blank dashboards.
await expect(page.getByTestId('options')).toBeVisible();
await renameDashboardViaToolbar(page, 'create-flow-tc05-renamed');
await expect(page.getByTestId('dashboard-title')).toHaveText(
'create-flow-tc05-renamed',
);
});
// ─── 2. Variables ─────────────────────────────────────────────────────────
test('TC-06 add a Custom variable, verify it appears in the variables bar', async ({
authedPage: page,
}) => {
const id = await seed(page, 'create-flow-tc06');
await page.goto(`/dashboard/${id}`);
const drawer = await openDashboardSettingsDrawer(page);
await drawer.getByRole('button', { name: 'Variables' }).click();
await drawer.getByTestId('add-new-variable').click();
await expect(drawer.getByRole('button', { name: 'All variables' })).toBeVisible();
await drawer
.getByPlaceholder('Unique name of the variable')
.fill('env');
await drawer.getByRole('button', { name: 'Custom' }).click();
// After selecting "Custom" type, the Options collapse panel contains a
// textarea with placeholder "Enter options separated by commas."
const customInput = drawer.getByPlaceholder(
'Enter options separated by commas.',
);
await customInput.fill('prod,staging,dev');
const patchResponse = page.waitForResponse(
(r) =>
r.request().method() === 'PUT' && /\/api\/v1\/dashboards\//.test(r.url()),
);
await drawer.getByRole('button', { name: 'Save Variable' }).click();
const res = await patchResponse;
expect(res.status()).toBeGreaterThanOrEqual(200);
expect(res.status()).toBeLessThan(300);
// After saving, the variable form disappears and the table row is visible.
await expect(drawer.getByRole('button', { name: 'All variables' })).not.toBeVisible();
await expect(drawer.getByText('env')).toBeVisible();
// Close the drawer via its X button and check the variables bar.
await drawer.getByRole('button', { name: 'Close' }).click();
await expect(page.locator('.dashboard-variables')).toBeVisible();
});
test('TC-07 duplicate variable name is rejected inline', async ({
authedPage: page,
}) => {
// Seed a dashboard that already has a variable named 'env'.
const id = await seed(page, 'create-flow-tc07');
await page.goto(`/dashboard/${id}`);
// Use the UI to add the first variable so the state is real.
const drawer = await openDashboardSettingsDrawer(page);
await drawer.getByRole('button', { name: 'Variables' }).click();
await drawer.getByTestId('add-new-variable').click();
await drawer.getByPlaceholder('Unique name of the variable').fill('env');
await drawer.getByRole('button', { name: 'Custom' }).click();
await drawer
.getByPlaceholder('Enter options separated by commas.')
.fill('prod');
const firstSave = page.waitForResponse(
(r) =>
r.request().method() === 'PUT' && /\/api\/v1\/dashboards\//.test(r.url()),
);
await drawer.getByRole('button', { name: 'Save Variable' }).click();
await firstSave;
// Now try to add a second variable with the same name.
await drawer.getByTestId('add-new-variable').click();
const nameInput = drawer.getByPlaceholder('Unique name of the variable');
await nameInput.fill('env');
await expect(
drawer.getByText('Variable name already exists'),
).toBeVisible();
await expect(
drawer.getByRole('button', { name: 'Save Variable' }),
).toBeDisabled();
});
// ─── 3. Import JSON ───────────────────────────────────────────────────────
//
// TC-08 and TC-12 are merged: TC-08 covers the POST contract and navigation;
// the merged test also navigates back to the list and verifies metadata
// surfacing (the TC-12 concern). This avoids two identical import flows.
test('TC-08 import via file upload creates dashboard, navigates to detail, and surfaces metadata in list', async ({
authedPage: page,
}) => {
await gotoDashboardsList(page);
await page.getByTestId('new-dashboard-cta').click();
await page.getByTestId('import-json-menu-cta').click();
const dialog = page.getByRole('dialog').filter({ hasText: 'Import Dashboard JSON' });
await expect(dialog).toBeVisible();
const postResponse = page.waitForResponse(
(r) =>
r.request().method() === 'POST' && /\/api\/v1\/dashboards/.test(r.url()),
);
await dialog.locator('input[type="file"]').setInputFiles(APM_METRICS_TESTDATA_PATH);
await dialog.getByRole('button', { name: 'Import and Next' }).click();
const res = await postResponse;
expect(res.status()).toBeGreaterThanOrEqual(200);
expect(res.status()).toBeLessThan(300);
await page.waitForURL(/\/dashboard\/[0-9a-f-]+/);
// Register for cleanup.
const urlMatch = page.url().match(/\/dashboard\/([0-9a-f-]+)/);
expect(urlMatch, 'URL must contain dashboard ID').not.toBeNull();
seedIds.add(urlMatch![1]);
await expect(page.getByTestId('dashboard-title')).toHaveText(APM_METRICS_TITLE);
// Navigate back and confirm the imported dashboard surfaces in the list
// with at least one tag chip (TC-12 coverage).
await gotoDashboardsList(page);
await page.getByPlaceholder(SEARCH_PLACEHOLDER).fill(APM_METRICS_TITLE);
await expect(page.getByText(APM_METRICS_TITLE).first()).toBeVisible();
// The apm-metrics fixture has tags ['apm', 'latency', 'error rate', 'throughput'].
await expect(page.getByText('apm').first()).toBeVisible();
});
// TC-09 (Monaco paste path) is intentionally dropped — the file-upload
// path (TC-08) exercises the same populate-editor-then-import code path.
// Keyboard-typing large JSON into Monaco is unreliable in headless CI.
test('TC-10 invalid JSON via file upload shows "Invalid JSON" error', async ({
authedPage: page,
}) => {
// No dashboard is created by this test — no cleanup entry needed.
await gotoDashboardsList(page);
await page.getByTestId('new-dashboard-cta').click();
await page.getByTestId('import-json-menu-cta').click();
const dialog = page.getByRole('dialog').filter({ hasText: 'Import Dashboard JSON' });
await expect(dialog).toBeVisible();
await dialog.locator('input[type="file"]').setInputFiles({
name: 'bad.json',
mimeType: 'application/json',
buffer: Buffer.from('not valid json {'),
});
await expect(dialog.getByText('Invalid JSON')).toBeVisible();
await expect(dialog).toBeVisible();
// Clicking "Import and Next" with invalid content should surface an error
// and keep the dialog open.
await dialog.getByRole('button', { name: 'Import and Next' }).click();
await expect(page).not.toHaveURL(/\/dashboard\/[0-9a-f-]+/);
await expect(dialog).toBeVisible();
});
test('TC-11 import with empty editor clicking Import and Next shows error', async ({
authedPage: page,
}) => {
// No dashboard is created — no cleanup entry needed.
await gotoDashboardsList(page);
await page.getByTestId('new-dashboard-cta').click();
await page.getByTestId('import-json-menu-cta').click();
const dialog = page.getByRole('dialog').filter({ hasText: 'Import Dashboard JSON' });
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: 'Import and Next' }).click();
await expect(dialog.getByText('Error loading JSON file')).toBeVisible();
await expect(dialog).toBeVisible();
await expect(page).not.toHaveURL(/\/dashboard\/[0-9a-f-]+/);
});
// ─── 4. View Templates ────────────────────────────────────────────────────
test('TC-13 View templates menu item is an external link targeting a new tab', async ({
authedPage: page,
}) => {
// No dashboard is created — no cleanup entry needed.
// The assertion guards against the link being silently changed to an
// in-app modal or a different URL (the DashboardTemplatesModal exists in
// source but is never triggered from this menu item).
await gotoDashboardsList(page);
await page.getByTestId('new-dashboard-cta').click();
const link = page.getByTestId('view-templates-menu-cta');
await expect(link).toBeVisible();
await expect(link).toHaveAttribute(
'href',
/signoz\.io\/docs\/dashboards\/dashboard-templates/,
);
await expect(link).toHaveAttribute('target', '_blank');
await expect(link).toHaveAttribute('rel', /noopener/);
});
// ─── 5. Post-Create Dashboard Detail — Panel Addition ────────────────────
test('TC-14 New Panel modal opens and selecting Time Series navigates to widget editor', async ({
authedPage: page,
}) => {
const id = await seed(page, 'create-flow-tc14');
await page.goto(`/dashboard/${id}`);
await expect(page.getByText('Welcome to your new dashboard')).toBeVisible();
await page.getByTestId('add-panel').click();
// PANEL_TYPES enum: TIME_SERIES='graph', VALUE='value', TABLE='table'
// — the testid is panel-type-<enum-value>, not panel-type-<enum-name>.
const modal = page.getByRole('dialog').filter({ hasText: 'New Panel' });
await expect(modal).toBeVisible();
await expect(modal.getByTestId('panel-type-graph')).toBeVisible();
await expect(modal.getByTestId('panel-type-value')).toBeVisible();
await expect(modal.getByTestId('panel-type-table')).toBeVisible();
await modal.getByTestId('panel-type-graph').click();
await expect(page).toHaveURL(/graphType=graph/);
});
test('TC-15 New Panel button from toolbar header opens the same panel type modal', async ({
authedPage: page,
}) => {
const id = await seed(page, 'create-flow-tc15');
await page.goto(`/dashboard/${id}`);
// The toolbar "New Panel" button (add-panel-header) is present even on
// a blank dashboard, alongside the empty-state "add-panel" button.
await expect(page.getByTestId('add-panel-header')).toBeVisible();
await page.getByTestId('add-panel-header').click();
const modal = page.getByRole('dialog').filter({ hasText: 'New Panel' });
await expect(modal).toBeVisible();
await expect(modal.getByTestId('panel-type-graph')).toBeVisible();
// Click the modal X button to close (Escape also works but may conflict
// with the Enterprise modal in the background; explicit click is more reliable).
await modal.getByRole('button', { name: 'Close' }).click();
await expect(modal).not.toBeVisible();
});
// ─── 6. Cancellation and Navigation Away ─────────────────────────────────
test('TC-16 browser Back from dashboard detail returns to list with URL preserved', async ({
authedPage: page,
}) => {
const id = await seed(page, 'create-flow-tc16');
await page.goto(`/dashboard?search=create-flow-tc16`);
await page
.getByRole('heading', { name: 'Dashboards', level: 1 })
.waitFor({ state: 'visible' });
await page.getByAltText('dashboard-image').first().click();
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
await page.goBack();
await expect(page).toHaveURL(/search=create-flow-tc16/);
await expect(
page.getByPlaceholder(SEARCH_PLACEHOLDER),
).toHaveValue('create-flow-tc16');
});
test('TC-17 navigating away with the settings drawer open does not crash', async ({
authedPage: page,
}) => {
const id = await seed(page, 'create-flow-tc17');
await page.goto(`/dashboard/${id}`);
await openDashboardSettingsDrawer(page);
// Navigate away without closing the drawer.
await page.goto('/dashboard');
await expect(page).toHaveURL(/\/dashboard($|\?)/);
await expect(
page.getByRole('heading', { name: 'Dashboards', level: 1 }),
).toBeVisible();
// No error overlay should be present.
await expect(
page.getByRole('alert').filter({ hasText: /error/i }),
).toHaveCount(0);
});
// ─── 7. Add Panel — end-to-end per signal ────────────────────────────────
//
// TC-14/TC-15 verify the New Panel modal opens and routes to the widget
// editor. The TCs below go further: configure a query for each signal
// using values from testdata/queries.json, save the panel, return to the
// dashboard, and verify the panel card renders.
test('TC-18 add metrics Time Series panel using signoz_calls_total from queries.json', async ({
authedPage: page,
}) => {
const id = await seed(page, 'add-panel-metrics');
await page.goto(`/dashboard/${id}`);
await expect(page.getByTestId('add-panel')).toBeVisible();
await configureAndSavePanel(page, 'metrics', 'metrics-timeseries');
await expect(page.getByTestId('metrics-timeseries')).toBeVisible();
});
test('TC-19 add logs Time Series panel with default query from queries.json', async ({
authedPage: page,
}) => {
const id = await seed(page, 'add-panel-logs');
await page.goto(`/dashboard/${id}`);
await expect(page.getByTestId('add-panel')).toBeVisible();
await configureAndSavePanel(page, 'logs', 'logs-timeseries');
await expect(page.getByTestId('logs-timeseries')).toBeVisible();
});
test('TC-20 add traces Time Series panel with default query from queries.json', async ({
authedPage: page,
}) => {
const id = await seed(page, 'add-panel-traces');
await page.goto(`/dashboard/${id}`);
await expect(page.getByTestId('add-panel')).toBeVisible();
await configureAndSavePanel(page, 'traces', 'traces-timeseries');
await expect(page.getByTestId('traces-timeseries')).toBeVisible();
});
});