Compare commits

..

5 Commits

Author SHA1 Message Date
Piyush Singariya
bcaccff2eb Merge branch 'main' into postprocess-json-logs 2026-05-05 17:50:54 +05:30
Piyush Singariya
71d27b7022 chore: update in e2e tests 2026-05-05 17:35:19 +05:30
Piyush Singariya
7ed9627ae5 fix: message postprocessing 2026-05-05 17:32:06 +05:30
Nikhil Soni
ac46cd8e80 fix: return span start time similar to waterfall v2 (#11183)
* fix: return span start time similar to waterfall v2

* chore: update openapi specs

* chore: rename timestamp field to match style of other fields

* chore: rename the struct field to keep json and field same
2026-05-05 11:50:18 +00:00
Piyush Singariya
2a747df764 fix: backend changes for message key postprocessing 2026-05-05 16:56:32 +05:30
23 changed files with 169 additions and 941 deletions

View File

@@ -5321,6 +5321,9 @@ components:
sub_tree_node_count:
minimum: 0
type: integer
time_unix:
minimum: 0
type: integer
trace_id:
type: string
trace_state:

View File

@@ -7714,6 +7714,11 @@ export interface TracedetailtypesWaterfallSpanDTO {
* @minimum 0
*/
sub_tree_node_count?: number;
/**
* @type integer
* @minimum 0
*/
time_unix?: number;
/**
* @type string
*/

View File

@@ -49,6 +49,7 @@ import {
} from 'lucide-react';
import { useAppContext } from 'providers/App/App';
import { AppState } from 'store/reducers';
import { ILogBody } from 'types/api/logs/log';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
@@ -216,20 +217,17 @@ function LogDetailInner({
const logBody = useMemo(() => {
if (!isBodyJsonQueryEnabled) {
return log?.body || '';
return (log?.body as string) ?? '';
}
try {
const json = JSON.parse(log?.body || '');
if (typeof json?.message === 'string' && json.message !== '') {
return json.message;
}
return log?.body || '';
} catch (error) {
return log?.body || '';
// Feature enabled: body is always a map; message is always a string
const bodyObj = log?.body as ILogBody;
if (!bodyObj) {
return '';
}
if (bodyObj.message) {
return bodyObj.message;
}
return JSON.stringify(bodyObj);
}, [isBodyJsonQueryEnabled, log?.body]);
const htmlBody = useMemo(

View File

@@ -9,7 +9,10 @@ import { Color } from '@signozhq/design-tokens';
import { Tooltip } from 'antd';
import { VIEW_TYPES } from 'components/LogDetail/constants';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { getSanitizedLogBody } from 'container/LogDetailedView/utils';
import {
getBodyDisplayString,
getSanitizedLogBody,
} from 'container/LogDetailedView/utils';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
// hooks
import { useIsDarkMode } from 'hooks/useDarkMode';
@@ -99,7 +102,7 @@ function RawLogView({
// Check if body is selected
const showBody = selectedFields.some((field) => field.name === 'body');
if (showBody) {
parts.push(`${attributesText} ${data.body}`);
parts.push(`${attributesText} ${getBodyDisplayString(data.body)}`);
} else {
parts.push(attributesText);
}

View File

@@ -2,7 +2,10 @@ import type { ReactElement } from 'react';
import { useMemo } from 'react';
import TanStackTable from 'components/TanStackTableView';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { getSanitizedLogBody } from 'container/LogDetailedView/utils';
import {
getBodyDisplayString,
getSanitizedLogBody,
} from 'container/LogDetailedView/utils';
import { FontSize } from 'container/OptionsMenu/types';
import { FlatLogData } from 'lib/logs/flatLogData';
import { useTimezone } from 'providers/Timezone';
@@ -87,7 +90,7 @@ export function useLogsTableColumns({
? {
id: 'body',
header: 'Body',
accessorFn: (log): string => log.body,
accessorFn: (log): string => getBodyDisplayString(log.body),
canBeHidden: false,
width: { default: '100%', min: 300 },
cell: ({ value, isActive }): ReactElement => (

View File

@@ -19,6 +19,7 @@ import {
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { isArray } from 'lodash-es';
import { getBodyDisplayString } from 'container/LogDetailedView/utils';
import { ChevronDown, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
@@ -173,7 +174,7 @@ export default function Events({
(event): EventDataType => ({
timestamp: event.timestamp,
severity: event.data.severity_text,
body: event.data.body,
body: getBodyDisplayString(event.data.body),
id: event.data.id,
key: event.data.id,
resources_string: event.data.resources_string,

View File

@@ -13,7 +13,7 @@ import { ILog } from 'types/api/logs/log';
import { ActionItemProps } from './ActionItem';
import TableView from './TableView';
import { removeEscapeCharacters } from './utils';
import { getBodyDisplayString, removeEscapeCharacters } from './utils';
import './Overview.styles.scss';
@@ -112,7 +112,7 @@ function Overview({
children: (
<div className="logs-body-content">
<MEditor
value={removeEscapeCharacters(logData.body)}
value={removeEscapeCharacters(getBodyDisplayString(logData.body))}
language="json"
options={options}
onChange={(): void => {}}

View File

@@ -10,7 +10,7 @@ const MAX_BODY_BYTES = 100 * 1024; // 100 KB
// Hook for async JSON processing
const useAsyncJSONProcessing = (
value: string,
value: string | Record<string, unknown>,
shouldProcess: boolean,
handleChangeSelectedView?: ChangeViewFunctionType,
): {
@@ -40,11 +40,17 @@ const useAsyncJSONProcessing = (
return (): void => {};
}
// Avoid processing if the json is too large
const byteSize = new Blob([value]).size;
if (byteSize > MAX_BODY_BYTES) {
return (): void => {};
}
// When value is already a parsed object skip the size check and JSON parsing
const parseBody = (): Record<string, unknown> | null => {
if (typeof value === 'object' && value !== null) {
return value as Record<string, unknown>;
}
const byteSize = new Blob([value as string]).size;
if (byteSize > MAX_BODY_BYTES) {
return null;
}
return recursiveParseJSON(value as string);
};
processingRef.current = true;
setJsonState({ isLoading: true, treeData: null, error: null });
@@ -53,8 +59,8 @@ const useAsyncJSONProcessing = (
const processAsync = (): void => {
setTimeout(() => {
try {
const parsedBody = recursiveParseJSON(value);
if (!isEmpty(parsedBody)) {
const parsedBody = parseBody();
if (parsedBody && !isEmpty(parsedBody)) {
const treeData = jsonToDataNodes(parsedBody, {
isBodyJsonQueryEnabled,
handleChangeSelectedView,
@@ -82,8 +88,8 @@ const useAsyncJSONProcessing = (
// eslint-disable-next-line sonarjs/no-identical-functions
(): void => {
try {
const parsedBody = recursiveParseJSON(value);
if (!isEmpty(parsedBody)) {
const parsedBody = parseBody();
if (parsedBody && !isEmpty(parsedBody)) {
const treeData = jsonToDataNodes(parsedBody, {
isBodyJsonQueryEnabled,
handleChangeSelectedView,

View File

@@ -4,7 +4,11 @@ import { ChangeViewFunctionType } from 'container/ExplorerOptions/types';
import { MetricsType } from 'container/MetricsApplication/constant';
import dompurify from 'dompurify';
import { uniqueId } from 'lodash-es';
import { ILog, ILogAggregateAttributesResources } from 'types/api/logs/log';
import {
ILog,
ILogAggregateAttributesResources,
ILogBody,
} from 'types/api/logs/log';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { FORBID_DOM_PURIFY_ATTR, FORBID_DOM_PURIFY_TAGS } from 'utils/app';
@@ -433,3 +437,24 @@ export const getSanitizedLogBody = (
return '{}';
}
};
// Returns a plain string for display contexts (Monaco editor, table cells, raw log row).
export function getBodyDisplayString(body: string | ILogBody): string {
return typeof body === 'string' ? body : JSON.stringify(body as ILogBody);
}
// Returns the primary "message" text for compact log row previews.
export function getBodyMessage(
body: string | ILogBody,
isBodyJsonEnabled: boolean,
): string {
if (!isBodyJsonEnabled) {
return (body as string) ?? '';
}
// Feature enabled: body is always a map; message is always a string
const msg = (body as ILogBody).message;
if (msg) {
return msg;
}
return JSON.stringify(body);
}

View File

@@ -2,6 +2,7 @@ import { ExpandAltOutlined } from '@ant-design/icons';
import LogDetail from 'components/LogDetail';
import { VIEW_TYPES } from 'components/LogDetail/constants';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { getBodyDisplayString } from 'container/LogDetailedView/utils';
import { useActiveLog } from 'hooks/logs/useActiveLog';
import { useTimezone } from 'providers/Timezone';
import { ILog } from 'types/api/logs/log';
@@ -26,7 +27,9 @@ function LogsList({ logs }: LogsListProps): JSX.Element {
DATE_TIME_FORMATS.UTC_MONTH_SHORT,
)}
</div>
<div className="logs-preview-list-item-body">{log.body}</div>
<div className="logs-preview-list-item-body">
{getBodyDisplayString(log.body)}
</div>
<div
className="logs-preview-list-item-expand"
onClick={makeLogDetailsHandler(log)}

View File

@@ -1,3 +1,8 @@
export interface ILogBody {
message?: string | null;
[key: string]: unknown;
}
export interface ILog {
date: string;
timestamp: number | string;
@@ -8,7 +13,7 @@ export interface ILog {
traceFlags: number;
severityText: string;
severityNumber: number;
body: string;
body: string | ILogBody;
resources_string: Record<string, never>;
scope_string: Record<string, never>;
attributesString: Record<string, never>;

2
go.mod
View File

@@ -11,6 +11,7 @@ require (
github.com/SigNoz/signoz-otel-collector v0.144.3
github.com/antlr4-go/antlr/v4 v4.13.1
github.com/antonmedv/expr v1.15.3
github.com/bytedance/sonic v1.14.1
github.com/cespare/xxhash/v2 v2.3.0
github.com/coreos/go-oidc/v3 v3.17.0
github.com/dgraph-io/ristretto/v2 v2.3.0
@@ -112,7 +113,6 @@ require (
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect
github.com/aws/smithy-go v1.24.2 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.14.1 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect

View File

@@ -12,8 +12,10 @@ import (
"time"
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
"github.com/SigNoz/signoz/pkg/errors"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/bytedance/sonic"
)
var (
@@ -22,6 +24,8 @@ var (
// written clickhouse query. The column alias indcate which value is
// to be considered as final result (or target).
legacyReservedColumnTargetAliases = []string{"__result", "__value", "result", "res", "value"}
CodeFailUnmarshalJSONColumn = errors.MustNewCode("fail_unmarshal_json_column")
)
// consume reads every row and shapes it into the payload expected for the
@@ -393,11 +397,16 @@ func readAsRaw(rows driver.Rows, queryName string) (*qbtypes.RawData, error) {
// de-reference the typed pointer to any
val := reflect.ValueOf(cellPtr).Elem().Interface()
// Post-process JSON columns: normalize into String value
// Post-process JSON columns: unmarshal bytes into map[string]any
if strings.HasPrefix(strings.ToUpper(colTypes[i].DatabaseTypeName()), "JSON") {
switch x := val.(type) {
case []byte:
val = string(x)
var m map[string]any
err := sonic.Unmarshal(x, &m)
if err != nil {
return nil, errors.WrapInternalf(err, CodeFailUnmarshalJSONColumn, "failed to unmarshal JSON column %s", name)
}
val = m
default:
// already a structured type (map[string]any, []any, etc.)
}

View File

@@ -12,8 +12,11 @@ import (
"github.com/SigNoz/govaluate"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/types/featuretypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
// queryInfo holds common query properties.
@@ -49,7 +52,7 @@ func getQueryName(spec any) string {
return getqueryInfo(spec).Name
}
func (q *querier) postProcessResults(ctx context.Context, results map[string]any, req *qbtypes.QueryRangeRequest) (map[string]any, error) {
func (q *querier) postProcessResults(ctx context.Context, orgID valuer.UUID, results map[string]any, req *qbtypes.QueryRangeRequest) (map[string]any, error) {
// Convert results to typed format for processing
typedResults := make(map[string]*qbtypes.Result)
for name, result := range results {
@@ -68,6 +71,7 @@ func (q *querier) postProcessResults(ctx context.Context, results map[string]any
case qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]:
if result, ok := typedResults[spec.Name]; ok {
result = postProcessBuilderQuery(q, result, spec, req)
result = q.postProcessLogBody(ctx, orgID, result, req)
typedResults[spec.Name] = result
}
case qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]:
@@ -1026,3 +1030,33 @@ func (q *querier) calculateFormulaStep(expression string, req *qbtypes.QueryRang
return result
}
// postProcessLogBody removes the "message" key from the body map when it is empty.
// Only runs for raw list queries with the use_json_body feature enabled.
func (q *querier) postProcessLogBody(ctx context.Context, orgID valuer.UUID, result *qbtypes.Result, req *qbtypes.QueryRangeRequest) *qbtypes.Result {
if req.RequestType != qbtypes.RequestTypeRaw {
return result
}
if !q.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(orgID)) {
return result
}
rawData, ok := result.Value.(*qbtypes.RawData)
if !ok {
return result
}
for _, row := range rawData.Rows {
bodyMap, ok := row.Data["body"].(map[string]any)
if !ok {
continue
}
if msg, exists := bodyMap["message"]; exists {
switch v := msg.(type) {
case string:
if v == "" {
delete(bodyMap, "message")
}
}
}
}
return result
}

View File

@@ -16,6 +16,7 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/prometheus"
"github.com/SigNoz/signoz/pkg/query-service/utils"
"github.com/SigNoz/signoz/pkg/querybuilder"
@@ -35,6 +36,7 @@ var (
type querier struct {
logger *slog.Logger
fl flagger.Flagger
telemetryStore telemetrystore.TelemetryStore
metadataStore telemetrytypes.MetadataStore
promEngine prometheus.Prometheus
@@ -62,10 +64,12 @@ func New(
meterStmtBuilder qbtypes.StatementBuilder[qbtypes.MetricAggregation],
traceOperatorStmtBuilder qbtypes.TraceOperatorStatementBuilder,
bucketCache BucketCache,
flagger flagger.Flagger,
) *querier {
querierSettings := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/pkg/querier")
return &querier{
logger: querierSettings.Logger(),
fl: flagger,
telemetryStore: telemetryStore,
metadataStore: metadataStore,
promEngine: promEngine,
@@ -684,7 +688,7 @@ func (q *querier) run(
}
gomaps.Copy(results, preseededResults)
processedResults, err := q.postProcessResults(ctx, results, req)
processedResults, err := q.postProcessResults(ctx, orgID, results, req)
if err != nil {
return nil, err
}

View File

@@ -7,6 +7,7 @@ import (
cmock "github.com/srikanthccv/ClickHouse-go-mock"
"github.com/SigNoz/signoz/pkg/flagger/flaggertest"
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
"github.com/SigNoz/signoz/pkg/telemetrystore"
"github.com/SigNoz/signoz/pkg/telemetrystore/telemetrystoretest"
@@ -44,14 +45,15 @@ func TestQueryRange_MetricTypeMissing(t *testing.T) {
providerSettings,
nil, // telemetryStore
metadataStore,
nil, // prometheus
nil, // traceStmtBuilder
nil, // logStmtBuilder
nil, // auditStmtBuilder
nil, // metricStmtBuilder
nil, // meterStmtBuilder
nil, // traceOperatorStmtBuilder
nil, // bucketCache
nil, // prometheus
nil, // traceStmtBuilder
nil, // logStmtBuilder
nil, // auditStmtBuilder
nil, // metricStmtBuilder
nil, // meterStmtBuilder
nil, // traceOperatorStmtBuilder
nil, // bucketCache
flaggertest.New(t), // flagger
)
req := &qbtypes.QueryRangeRequest{
@@ -116,6 +118,7 @@ func TestQueryRange_MetricTypeFromStore(t *testing.T) {
nil, // meterStmtBuilder
nil, // traceOperatorStmtBuilder
nil, // bucketCache
flaggertest.New(t), // flagger
)
req := &qbtypes.QueryRangeRequest{

View File

@@ -186,5 +186,6 @@ func newProvider(
meterStmtBuilder,
traceOperatorStmtBuilder,
bucketCache,
flagger,
), nil
}

View File

@@ -53,6 +53,7 @@ func prepareQuerierForMetrics(t *testing.T, telemetryStore telemetrystore.Teleme
nil, // meterStmtBuilder
nil, // traceOperatorStmtBuilder
nil, // bucketCache
flagger,
), metadataStore
}
@@ -102,6 +103,7 @@ func prepareQuerierForLogs(t *testing.T, telemetryStore telemetrystore.Telemetry
nil, // meterStmtBuilder
nil, // traceOperatorStmtBuilder
nil, // bucketCache
fl,
)
}
@@ -146,5 +148,6 @@ func prepareQuerierForTraces(t *testing.T, telemetryStore telemetrystore.Telemet
nil, // meterStmtBuilder
nil, // traceOperatorStmtBuilder
nil, // bucketCache
fl,
)
}

View File

@@ -13,7 +13,7 @@ func mkASpan(id string, resource map[string]string, attributes map[string]any, s
SpanID: id,
Resource: resource,
Attributes: attributes,
TimeUnixNano: startNs,
TimeUnix: startNs,
DurationNano: durationNs,
Children: make([]*WaterfallSpan, 0),
}
@@ -25,11 +25,11 @@ func buildTraceFromSpans(spans ...*WaterfallSpan) *WaterfallTrace {
initialized := false
for _, s := range spans {
spanMap[s.SpanID] = s
if !initialized || s.TimeUnixNano < startTime {
startTime = s.TimeUnixNano
if !initialized || s.TimeUnix < startTime {
startTime = s.TimeUnix
initialized = true
}
if end := s.TimeUnixNano + s.DurationNano; end > endTime {
if end := s.TimeUnix + s.DurationNano; end > endTime {
endTime = end
}
}

View File

@@ -71,7 +71,7 @@ type WaterfallSpan struct {
ParentSpanID string `json:"parent_span_id"`
Resource map[string]string `json:"resource"`
SpanID string `json:"span_id"`
TimeUnixNano uint64 `json:"-"`
TimeUnix uint64 `json:"time_unix"`
TraceID string `json:"trace_id"`
TraceState string `json:"trace_state"`
@@ -138,7 +138,7 @@ func NewMissingWaterfallSpan(spanID, traceID string, timeUnixNano, durationNano
SpanID: spanID,
TraceID: traceID,
Name: "Missing Span",
TimeUnixNano: timeUnixNano,
TimeUnix: timeUnixNano,
DurationNano: durationNano,
Events: make([]Event, 0),
Children: make([]*WaterfallSpan, 0),
@@ -150,10 +150,10 @@ func NewMissingWaterfallSpan(spanID, traceID string, timeUnixNano, durationNano
// SortChildren recursively sorts children of each span by TimeUnixNano then Name.
func (ws *WaterfallSpan) SortChildren() {
sort.Slice(ws.Children, func(i, j int) bool {
if ws.Children[i].TimeUnixNano == ws.Children[j].TimeUnixNano {
if ws.Children[i].TimeUnix == ws.Children[j].TimeUnix {
return ws.Children[i].Name < ws.Children[j].Name
}
return ws.Children[i].TimeUnixNano < ws.Children[j].TimeUnixNano
return ws.Children[i].TimeUnix < ws.Children[j].TimeUnix
})
for _, child := range ws.Children {
child.SortChildren()
@@ -292,7 +292,7 @@ func (item *StorableSpan) ToWaterfallSpan() *WaterfallSpan {
TraceID: item.TraceID,
TraceState: item.TraceState,
Children: make([]*WaterfallSpan, 0),
TimeUnixNano: uint64(item.StartTime.UnixNano()),
TimeUnix: uint64(item.StartTime.UnixNano()),
ServiceName: item.ServiceName,
}
}

View File

@@ -95,7 +95,7 @@ func NewWaterfallTraceFromSpans(spans []StorableSpan) *WaterfallTrace {
if parentNode, exists := spanIDToSpanNodeMap[spanNode.ParentSpanID]; exists {
parentNode.Children = append(parentNode.Children, spanNode)
} else {
missingSpan := NewMissingWaterfallSpan(spanNode.ParentSpanID, spanNode.TraceID, spanNode.TimeUnixNano, spanNode.DurationNano)
missingSpan := NewMissingWaterfallSpan(spanNode.ParentSpanID, spanNode.TraceID, spanNode.TimeUnix, spanNode.DurationNano)
missingSpan.Children = append(missingSpan.Children, spanNode)
spanIDToSpanNodeMap[missingSpan.SpanID] = missingSpan
traceRoots = append(traceRoots, missingSpan)
@@ -112,10 +112,10 @@ func NewWaterfallTraceFromSpans(spans []StorableSpan) *WaterfallTrace {
}
sort.Slice(traceRoots, func(i, j int) bool {
if traceRoots[i].TimeUnixNano == traceRoots[j].TimeUnixNano {
if traceRoots[i].TimeUnix == traceRoots[j].TimeUnix {
return traceRoots[i].Name < traceRoots[j].Name
}
return traceRoots[i].TimeUnixNano < traceRoots[j].TimeUnixNano
return traceRoots[i].TimeUnix < traceRoots[j].TimeUnix
})
return NewWaterfallTrace(
@@ -264,7 +264,7 @@ func NewGettableWaterfallTrace(
// convert start timestamp to millis because client is expecting it in millis
for _, span := range selectedSpans {
span.TimeUnixNano = span.TimeUnixNano / 1_000_000
span.TimeUnix = span.TimeUnix / 1_000_000
}
// duration values are in nanoseconds; convert in-place to milliseconds.
@@ -332,15 +332,15 @@ func mergeSpanIntervals(spans []*WaterfallSpan) uint64 {
return 0
}
sort.Slice(spans, func(i, j int) bool {
return spans[i].TimeUnixNano < spans[j].TimeUnixNano
return spans[i].TimeUnix < spans[j].TimeUnix
})
currentStart := spans[0].TimeUnixNano
currentStart := spans[0].TimeUnix
currentEnd := currentStart + spans[0].DurationNano
total := uint64(0)
for _, span := range spans[1:] {
startNano := span.TimeUnixNano
startNano := span.TimeUnix
endNano := startNano + span.DurationNano
if currentEnd >= startNano {
if endNano > currentEnd {

View File

@@ -1,878 +0,0 @@
import type { Page } from '@playwright/test';
import { expect, test } from '../../fixtures/auth';
// Tests in this file mutate the dashboard list (create / delete). Run them
// serially within the worker so state from one test does not leak into
// another's assertions. Files still run in parallel via the project-level
// fullyParallel setting.
test.describe.configure({ mode: 'serial' });
const LIST_LABEL = 'All Dashboards';
const SEARCH_PLACEHOLDER = 'Search by name, description, or tags...';
const NAME_PLACEHOLDER = 'Enter dashboard name...';
async function gotoList(page: Page): Promise<void> {
await page.goto('/dashboard');
await page.getByText(LIST_LABEL).first().waitFor({ state: 'visible' });
}
async function createDashboardByName(page: Page, name: string): Promise<void> {
await gotoList(page);
await page.getByRole('textbox', { name: NAME_PLACEHOLDER }).fill(name);
await page.getByRole('button', { name: 'Submit' }).click();
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
}
async function deleteDashboardByName(page: Page, name: string): Promise<void> {
await gotoList(page);
await page.getByRole('textbox', { name: SEARCH_PLACEHOLDER }).fill(name);
const icon = page.getByTestId('dashboard-action-icon').first();
if (await icon.isVisible().catch(() => false)) {
await icon.click();
await page.getByRole('tooltip').getByText('Delete dashboard').click();
await page.getByRole('button', { name: 'Delete' }).click();
}
}
test.describe('Dashboards List Page', () => {
// ─── Page load and layout ────────────────────────────────────────────────
test('TC-01 page chrome and core controls render', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-chrome';
await createDashboardByName(page, name);
try {
await gotoList(page);
// Fresh load should have no query params
await expect(page).toHaveURL('/dashboard');
await expect(page).toHaveTitle('SigNoz | All Dashboards');
// Page identity
await expect(
page.getByRole('heading', { name: 'Dashboards', level: 1 }),
).toBeVisible();
await expect(
page.getByText('Create and manage dashboards for your workspace.'),
).toBeVisible();
// Core controls
await expect(
page.getByRole('textbox', { name: SEARCH_PLACEHOLDER }),
).toBeVisible();
await expect(page.getByText(LIST_LABEL)).toBeVisible();
await expect(page.getByTestId('sort-by')).toBeVisible();
// At least one dashboard row — thumbnail is the most stable row anchor
await expect(page.getByAltText('dashboard-image').first()).toBeVisible();
// Pagination range text confirms rows were fetched (e.g. "1 — 20 of 42")
await expect(page.getByText(/\d+ — \d+ of \d+/)).toBeVisible();
// Global header actions
await expect(
page.getByRole('button', { name: 'Feedback' }),
).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
} finally {
await deleteDashboardByName(page, name);
}
});
test('TC-02 row shows thumbnail, last-updated date, and creator email', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-row-fields';
await createDashboardByName(page, name);
try {
await gotoList(page);
await page
.getByAltText('dashboard-image')
.first()
.waitFor({ state: 'visible' });
// Each row has a thumbnail image identified by the alt text set by the app
await expect(page.getByAltText('dashboard-image').first()).toBeVisible();
// Each row shows a "last updated" timestamp — verify the date format
// exists somewhere in the rendered list (e.g. "Mar 24, 2026")
const pageText = await page.locator('body').textContent();
expect(pageText).toMatch(/\w{3} \d{1,2}, \d{4}/);
// Each row shows the creator's email address
await expect(page.getByText(/@/).first()).toBeVisible();
} finally {
await deleteDashboardByName(page, name);
}
});
// ─── Search functionality ────────────────────────────────────────────────
test('TC-03 search by title returns matching dashboard', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-search-title';
await createDashboardByName(page, name);
try {
await gotoList(page);
const searchInput = page.getByRole('textbox', {
name: SEARCH_PLACEHOLDER,
});
await searchInput.fill(name);
await expect(page).toHaveURL(new RegExp(`search=${name}`));
await expect(searchInput).toHaveValue(name);
await expect(page.getByAltText('dashboard-image').first()).toBeVisible();
await expect(page.getByText(name).first()).toBeVisible();
} finally {
await deleteDashboardByName(page, name);
}
});
test('TC-04 search by description returns matching dashboards', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-search-desc';
const description = 'desc-dashboards-list-search';
await createDashboardByName(page, name);
try {
// Set the description in the Configure dialog
await page.getByRole('button', { name: 'Configure' }).click();
await page.getByRole('dialog').waitFor({ state: 'visible' });
await page
.getByRole('textbox', { name: /description/i })
.fill(description);
await page.getByRole('button', { name: 'Save' }).click();
// Return to the list and search using the description text
await gotoList(page);
const searchInput = page.getByRole('textbox', {
name: SEARCH_PLACEHOLDER,
});
await searchInput.fill(description);
await expect(page.getByAltText('dashboard-image').first()).toBeVisible();
await expect(page.getByText(name).first()).toBeVisible();
} finally {
await deleteDashboardByName(page, name);
}
});
test('TC-05 direct navigation with ?search= pre-fills the input and filters results', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-search-deeplink';
await createDashboardByName(page, name);
try {
await page.goto(`/dashboard?search=${name}`);
await page.getByText(LIST_LABEL).first().waitFor({ state: 'visible' });
await expect(
page.getByRole('textbox', { name: SEARCH_PLACEHOLDER }),
).toHaveValue(name);
await expect(page.getByText(name).first()).toBeVisible();
} finally {
await deleteDashboardByName(page, name);
}
});
test('TC-06 clearing search restores the full list', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-clear-search';
await createDashboardByName(page, name);
try {
await gotoList(page);
const searchInput = page.getByRole('textbox', {
name: SEARCH_PLACEHOLDER,
});
await searchInput.fill(name);
await expect(page).toHaveURL(/search=/);
// Clearing the field removes the param and brings back all dashboards
await searchInput.fill('');
await expect(page).not.toHaveURL(/search=/);
await expect(page.getByAltText('dashboard-image').first()).toBeVisible();
} finally {
await deleteDashboardByName(page, name);
}
});
test('TC-07 search with no matching results shows empty state', async ({
authedPage: page,
}) => {
await gotoList(page);
const searchInput = page.getByRole('textbox', { name: SEARCH_PLACEHOLDER });
// A nonsense term guarantees no matches across title, description, or tags
await searchInput.fill('xyznonexistent999');
// No thumbnails — list is empty, no error or broken layout
await expect(page.getByAltText('dashboard-image')).toHaveCount(0);
await expect(searchInput).toBeVisible();
await expect(searchInput).toHaveValue('xyznonexistent999');
});
test('TC-08 search is case-insensitive', async ({ authedPage: page }) => {
const name = 'Dashboards-List-Case-Insensitive';
await createDashboardByName(page, name);
try {
await gotoList(page);
const searchInput = page.getByRole('textbox', {
name: SEARCH_PLACEHOLDER,
});
// Lowercase version of the mixed-case dashboard name — should still match
await searchInput.fill(name.toLowerCase());
await expect(page.getByAltText('dashboard-image').first()).toBeVisible();
const pageText = await page.locator('body').textContent();
expect(pageText?.toLowerCase()).toContain(name.toLowerCase());
} finally {
await deleteDashboardByName(page, name);
}
});
// ─── Sorting ─────────────────────────────────────────────────────────────
//
// Known behaviour (verified against live app):
// - Fresh load: no sort params in URL; list is already descending (server default)
// - First click: URL gains ?columnKey=updatedAt&order=descend
// - Subsequent clicks: URL stays on order=descend — ascending is not yet implemented
test('TC-09 default load has no sort params', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-sort-default';
await createDashboardByName(page, name);
try {
await gotoList(page);
// On fresh load the URL should be clean — sort params only appear after
// the user interacts with the sort button
await expect(page).toHaveURL('/dashboard');
await expect(page).not.toHaveURL(/columnKey/);
await expect(page).not.toHaveURL(/order/);
await expect(page.getByAltText('dashboard-image').first()).toBeVisible();
} finally {
await deleteDashboardByName(page, name);
}
});
test('TC-10 first sort click adds columnKey=updatedAt&order=descend to URL', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-sort-click';
await createDashboardByName(page, name);
try {
await gotoList(page);
// Before any interaction — no sort params
await expect(page).not.toHaveURL(/columnKey/);
await page.getByTestId('sort-by').click();
// After first click the sort state is written to the URL
await expect(page).toHaveURL(/columnKey=updatedAt/);
await expect(page).toHaveURL(/order=descend/);
await expect(page.getByAltText('dashboard-image').first()).toBeVisible();
} finally {
await deleteDashboardByName(page, name);
}
});
test('TC-11 subsequent sort clicks keep order=descend (ascending not yet implemented)', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-sort-toggle';
await createDashboardByName(page, name);
try {
await gotoList(page);
const sortButton = page.getByTestId('sort-by');
await sortButton.click();
await expect(page).toHaveURL(/order=descend/);
// Second click — known limitation: order remains descend, does not flip to ascend
await sortButton.click();
await expect(page).toHaveURL(/order=descend/);
await expect(page).not.toHaveURL(/order=ascend/);
} finally {
await deleteDashboardByName(page, name);
}
});
// ─── Row actions (context menu) ──────────────────────────────────────────
//
// The three-dot action icon is always visible on every row — no hover required.
// Clicking it opens a tooltip popover scoped via getByRole('tooltip').
test('TC-12 admin sees all five options in the action menu', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-actions-menu';
await createDashboardByName(page, name);
try {
await gotoList(page);
await page
.getByRole('textbox', { name: SEARCH_PLACEHOLDER })
.fill(name);
await page.getByTestId('dashboard-action-icon').first().click();
const tooltip = page.getByRole('tooltip');
await expect(tooltip).toBeVisible();
await expect(tooltip.getByRole('button', { name: 'View' })).toBeVisible();
await expect(
tooltip.getByRole('button', { name: 'Open in New Tab' }),
).toBeVisible();
await expect(
tooltip.getByRole('button', { name: 'Copy Link' }),
).toBeVisible();
await expect(
tooltip.getByRole('button', { name: 'Export JSON' }),
).toBeVisible();
// Delete is rendered as a generic (not a button) in a separate section
await expect(tooltip.getByText('Delete dashboard')).toBeVisible();
} finally {
await deleteDashboardByName(page, name);
}
});
test('TC-13 view action navigates to the dashboard detail page', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-action-view';
await createDashboardByName(page, name);
try {
await gotoList(page);
await page
.getByRole('textbox', { name: SEARCH_PLACEHOLDER })
.fill(name);
await page.getByTestId('dashboard-action-icon').first().click();
await page
.getByRole('tooltip')
.getByRole('button', { name: 'View' })
.click();
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
} finally {
await deleteDashboardByName(page, name);
}
});
test('TC-14 open in new tab opens the dashboard in a new browser tab', async ({
authedPage: page,
context,
}) => {
const name = 'dashboards-list-action-newtab';
await createDashboardByName(page, name);
try {
await gotoList(page);
await page
.getByRole('textbox', { name: SEARCH_PLACEHOLDER })
.fill(name);
await page.getByTestId('dashboard-action-icon').first().click();
// waitForEvent('page') must be set up before the click that triggers it
const [newPage] = await Promise.all([
context.waitForEvent('page'),
page
.getByRole('tooltip')
.getByRole('button', { name: 'Open in New Tab' })
.click(),
]);
await newPage.waitForLoadState();
await expect(newPage).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
await newPage.close();
} finally {
await deleteDashboardByName(page, name);
}
});
test('TC-15 copy link copies the dashboard URL to the clipboard', async ({
authedPage: page,
context,
}) => {
const name = 'dashboards-list-action-copy';
await createDashboardByName(page, name);
try {
await gotoList(page);
await page
.getByRole('textbox', { name: SEARCH_PLACEHOLDER })
.fill(name);
// Grant clipboard permissions so we can read back what was written
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
await page.getByTestId('dashboard-action-icon').first().click();
await page
.getByRole('tooltip')
.getByRole('button', { name: 'Copy Link' })
.click();
await expect(page.getByText(/copied|success/i)).toBeVisible();
// Cast through unknown to access browser globals inside page.evaluate.
const clipboardText = await page.evaluate(async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return await (globalThis as any).navigator.clipboard.readText();
});
expect(clipboardText).toMatch(/\/dashboard\/[0-9a-f-]+/);
} finally {
await deleteDashboardByName(page, name);
}
});
test('TC-16 export JSON downloads the dashboard as a JSON file', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-action-export';
await createDashboardByName(page, name);
try {
await gotoList(page);
await page
.getByRole('textbox', { name: SEARCH_PLACEHOLDER })
.fill(name);
await page.getByTestId('dashboard-action-icon').first().click();
// waitForEvent('download') must be in place before the triggering click
const [download] = await Promise.all([
page.waitForEvent('download'),
page
.getByRole('tooltip')
.getByRole('button', { name: 'Export JSON' })
.click(),
]);
expect(download.suggestedFilename()).toMatch(/\.json$/);
} finally {
await deleteDashboardByName(page, name);
}
});
test('TC-17 action menu closes when clicking outside the popover', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-action-dismiss';
await createDashboardByName(page, name);
try {
await gotoList(page);
await page
.getByRole('textbox', { name: SEARCH_PLACEHOLDER })
.fill(name);
await page.getByTestId('dashboard-action-icon').first().click();
await expect(page.getByRole('tooltip')).toBeVisible();
// Click on a neutral area — the page heading — to dismiss the popover
await page
.getByRole('heading', { name: 'Dashboards', level: 1 })
.click();
await expect(page.getByRole('tooltip')).not.toBeVisible();
// No navigation should have occurred
await expect(page).toHaveURL(/\/dashboard($|\?)/);
} finally {
await deleteDashboardByName(page, name);
}
});
// ─── Creating dashboards ─────────────────────────────────────────────────
test('TC-18 submit button is disabled when the name input is empty', async ({
authedPage: page,
}) => {
await gotoList(page);
// Before typing, Submit must be disabled — clicking it should do nothing
await expect(page.getByRole('button', { name: 'Submit' })).toBeDisabled();
});
test('TC-19 inline name field creates a named dashboard and navigates to it', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-create-inline';
try {
await gotoList(page);
const nameInput = page.getByRole('textbox', { name: NAME_PLACEHOLDER });
await nameInput.fill(name);
// Submit becomes enabled once text is present
await expect(page.getByRole('button', { name: 'Submit' })).toBeEnabled();
await page.getByRole('button', { name: 'Submit' }).click();
// Should navigate directly to the new dashboard's detail page
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
} finally {
await deleteDashboardByName(page, name);
}
});
test('TC-20 New dashboard dropdown shows exactly three options', async ({
authedPage: page,
}) => {
await gotoList(page);
await page.getByRole('button', { name: 'New dashboard' }).click();
const menu = page.getByRole('menu');
await expect(menu).toBeVisible();
// Exactly three items: Create dashboard, Import JSON, View templates
await expect(
menu.getByRole('menuitem', { name: 'Create dashboard' }),
).toBeVisible();
await expect(
menu.getByRole('menuitem', { name: 'Import JSON' }),
).toBeVisible();
await expect(
menu.getByRole('menuitem', { name: 'View templates' }),
).toBeVisible();
});
test('TC-21 Create dashboard dropdown option creates dashboard with default name', async ({
authedPage: page,
}) => {
const defaultName = 'Sample Title';
try {
await gotoList(page);
await page.getByRole('button', { name: 'New dashboard' }).click();
await page.getByRole('menuitem', { name: 'Create dashboard' }).click();
// New dashboard detail page loads
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
// Default name is "Sample Title" and onboarding state is shown
await expect(
page.getByText('Configure your new dashboard'),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Configure' }),
).toBeVisible();
await expect(
page.getByRole('button', { name: /New Panel/ }),
).toBeVisible();
} finally {
await deleteDashboardByName(page, defaultName);
}
});
test('TC-22 Import JSON dialog opens with code editor and upload button', async ({
authedPage: page,
}) => {
await gotoList(page);
await page.getByRole('button', { name: 'New dashboard' }).click();
await page.getByRole('menuitem', { name: 'Import JSON' }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await expect(dialog.getByText('Import Dashboard JSON')).toBeVisible();
// Monaco editor renders line numbers — line "1" is the presence signal
await expect(dialog.getByText('1').first()).toBeVisible();
await expect(
dialog.getByRole('button', { name: 'Upload JSON file' }),
).toBeVisible();
await expect(
dialog.getByRole('button', { name: 'Import and Next' }),
).toBeVisible();
});
test('TC-23 Import JSON dialog closes on Escape without creating a dashboard', async ({
authedPage: page,
}) => {
await gotoList(page);
await page.getByRole('button', { name: 'New dashboard' }).click();
await page.getByRole('menuitem', { name: 'Import JSON' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.getByRole('dialog')).not.toBeVisible();
await expect(page).toHaveURL(/\/dashboard($|\?)/);
});
test('TC-24 Import JSON dialog closes on clicking the close button', async ({
authedPage: page,
}) => {
await gotoList(page);
await page.getByRole('button', { name: 'New dashboard' }).click();
await page.getByRole('menuitem', { name: 'Import JSON' }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: /close/i }).click();
await expect(dialog).not.toBeVisible();
await expect(page).toHaveURL(/\/dashboard($|\?)/);
});
// ─── Deleting dashboards ─────────────────────────────────────────────────
//
// Known behaviour: clicking Cancel in the confirmation dialog navigates to
// the dashboard detail page rather than staying on the list.
test('TC-25 delete confirmation dialog shows dashboard name with Cancel and Delete buttons', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-delete-confirm';
await createDashboardByName(page, name);
try {
await gotoList(page);
await page
.getByRole('textbox', { name: SEARCH_PLACEHOLDER })
.fill(name);
await page.getByTestId('dashboard-action-icon').first().click();
await page.getByRole('tooltip').getByText('Delete dashboard').click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await expect(dialog.getByRole('heading')).toContainText(
'Are you sure you want to delete the',
);
await expect(dialog.getByRole('heading')).toContainText(name);
await expect(
dialog.getByRole('button', { name: 'Cancel' }),
).toBeVisible();
await expect(
dialog.getByRole('button', { name: 'Delete' }),
).toBeVisible();
// Confirm delete to keep the workspace clean
await dialog.getByRole('button', { name: 'Delete' }).click();
} finally {
await deleteDashboardByName(page, name);
}
});
test('TC-26 cancelling delete navigates to the dashboard detail page (known behaviour)', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-delete-cancel';
await createDashboardByName(page, name);
try {
await gotoList(page);
await page
.getByRole('textbox', { name: SEARCH_PLACEHOLDER })
.fill(name);
await page.getByTestId('dashboard-action-icon').first().click();
await page.getByRole('tooltip').getByText('Delete dashboard').click();
await expect(page.getByRole('dialog')).toBeVisible();
// Cancel — known behaviour: lands on detail page, not back on the list
await page.getByRole('button', { name: 'Cancel' }).click();
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
} finally {
await deleteDashboardByName(page, name);
}
});
test('TC-27 confirming delete removes the dashboard from the list', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-delete-confirmed';
await createDashboardByName(page, name);
await gotoList(page);
await page.getByRole('textbox', { name: SEARCH_PLACEHOLDER }).fill(name);
await expect(page.getByAltText('dashboard-image').first()).toBeVisible();
await page.getByTestId('dashboard-action-icon').first().click();
await page.getByRole('tooltip').getByText('Delete dashboard').click();
await page.getByRole('button', { name: 'Delete' }).click();
// After deletion, searching for the name should return no results
await gotoList(page);
await page.getByRole('textbox', { name: SEARCH_PLACEHOLDER }).fill(name);
await expect(page.getByAltText('dashboard-image')).toHaveCount(0);
});
// ─── Row click navigation ────────────────────────────────────────────────
test('TC-28 clicking a dashboard row navigates to the detail page', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-row-click';
await createDashboardByName(page, name);
try {
await gotoList(page);
await page
.getByRole('textbox', { name: SEARCH_PLACEHOLDER })
.fill(name);
// Click the thumbnail image — a stable, always-present click target
// that is not the action icon
await page.getByAltText('dashboard-image').first().click();
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
} finally {
await deleteDashboardByName(page, name);
}
});
test('TC-29 dashboard detail page shows the breadcrumb after row click', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-breadcrumb';
await createDashboardByName(page, name);
try {
await gotoList(page);
await page
.getByRole('textbox', { name: SEARCH_PLACEHOLDER })
.fill(name);
await page.getByAltText('dashboard-image').first().click();
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
// Breadcrumb "Dashboard /" confirms correct page structure loaded
await expect(
page.getByRole('button', { name: /Dashboard \// }),
).toBeVisible();
} finally {
await deleteDashboardByName(page, name);
}
});
test('TC-30 sidebar Dashboards link navigates to the list page', async ({
authedPage: page,
}) => {
await page.goto('/home');
await page.getByText(LIST_LABEL).first().waitFor({ state: 'hidden' });
await page.getByRole('link', { name: 'Dashboards' }).click();
await expect(page).toHaveURL(/\/dashboard/);
await expect(page).toHaveTitle('SigNoz | All Dashboards');
});
// ─── URL state and deep linking ──────────────────────────────────────────
test('TC-31 search term updates the URL in real time', async ({
authedPage: page,
}) => {
await gotoList(page);
await page
.getByRole('textbox', { name: SEARCH_PLACEHOLDER })
.fill('realtime');
await expect(page).toHaveURL(/search=realtime/);
});
test('TC-32 browser Back after navigating to a dashboard restores search state', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-back-search';
await createDashboardByName(page, name);
try {
await page.goto(`/dashboard?search=${name}`);
await page.getByText(LIST_LABEL).first().waitFor({ state: 'visible' });
// Navigate into a dashboard row
await page.getByAltText('dashboard-image').first().click();
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
// Browser back should restore the list with the search param intact
await page.goBack();
await expect(page).toHaveURL(new RegExp(`search=${name}`));
await expect(
page.getByRole('textbox', { name: SEARCH_PLACEHOLDER }),
).toHaveValue(name);
} finally {
await deleteDashboardByName(page, name);
}
});
test('TC-33 sort params appear in URL only after interacting with the sort button', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-sort-url';
await createDashboardByName(page, name);
try {
await gotoList(page);
await expect(page).not.toHaveURL(/columnKey/);
await page.getByTestId('sort-by').click();
await expect(page).toHaveURL(/columnKey=updatedAt/);
await expect(page).toHaveURL(/order=descend/);
// Navigating directly with sort params should honour them on load
await page.goto('/dashboard?columnKey=updatedAt&order=descend');
await page.getByText(LIST_LABEL).first().waitFor({ state: 'visible' });
await expect(page).toHaveURL(/columnKey=updatedAt/);
await expect(page).toHaveURL(/order=descend/);
} finally {
await deleteDashboardByName(page, name);
}
});
// ─── Page header actions ─────────────────────────────────────────────────
test('TC-34 feedback button is visible and opens a feedback mechanism', async ({
authedPage: page,
}) => {
await gotoList(page);
const feedbackButton = page.getByRole('button', { name: 'Feedback' });
await expect(feedbackButton).toBeVisible();
// Clicking should trigger a feedback mechanism (modal, widget, or external link)
// — we verify it is interactive without asserting the exact implementation
await feedbackButton.click();
await expect(page).toHaveURL(/\/dashboard/); // no unintended navigation
});
test('TC-35 share button is visible and triggers a share action', async ({
authedPage: page,
}) => {
await gotoList(page);
const shareButton = page.getByRole('button', { name: 'Share' });
await expect(shareButton).toBeVisible();
await shareButton.click();
// Clicking Share either opens a dialog or copies the URL — either way the
// page should remain on /dashboard with no unintended navigation
await expect(page).toHaveURL(/\/dashboard/);
});
// ─── Edge cases ──────────────────────────────────────────────────────────
test('TC-36 dashboard with no tags shows a clean row with no empty tag containers', async ({
authedPage: page,
}) => {
const name = 'dashboards-list-no-tags';
await createDashboardByName(page, name);
try {
await gotoList(page);
await page
.getByRole('textbox', { name: SEARCH_PLACEHOLDER })
.fill(name);
await page
.getByAltText('dashboard-image')
.first()
.waitFor({ state: 'visible' });
// Row must be visible with thumbnail and text — no broken layout
await expect(page.getByAltText('dashboard-image').first()).toBeVisible();
await expect(page.getByText(name).first()).toBeVisible();
} finally {
await deleteDashboardByName(page, name);
}
});
});

View File

@@ -20,7 +20,7 @@ from fixtures.querier import (
def _get_bodies(response: requests.Response) -> list[dict[str, Any]]:
return [json.loads(row["data"]["body"]) for row in get_rows(response)]
return [row["data"]["body"] for row in get_rows(response)]
def _run_query_case(signoz: types.SigNoz, token: str, now: datetime, case: dict[str, Any]) -> None:
@@ -1183,7 +1183,7 @@ def test_message_searches(
token = get_token(email=USER_ADMIN_EMAIL, password=USER_ADMIN_PASSWORD)
def _body_messages(response: requests.Response) -> list[str]:
return [json.loads(row["data"]["body"]).get("message", "") for row in get_rows(response)]
return [row["data"]["body"].get("message", "") for row in get_rows(response)]
payment_messages = {
"Payment processed successfully",