Compare commits

...

2 Commits

Author SHA1 Message Date
Abhi Kumar
f5c48c1b66 test(dashboard-v2): e2e for panel actions, controls, drilldown and the View modal
Covers what a reader does to a panel on the dashboard, without opening the
editor: the actions menu (clone, delete, move to section, download, Create
Alerts), the header search and column resize on Table/List, List pagination,
the drilldown context menu, the View modal, and grid drag/resize.

The drilldown and View modal specs are the load-bearing ones. Drilldown
asserts the aggregate menu's navigation targets and its variable set/create
paths, which reach into the dashboard spec. The View modal specs assert the
two-way handoff with the editor: both directions carry live unsaved state, so
they check the state arrives AND that nothing was persisted on the way.

Adds a test handle to the column resize grip (ResizableHeader takes the column
key) so a resized column can be identified; no behaviour change.

The editor locators (helpers/panel-editor-v2.ts) land here because the View
modal handoff is their first consumer; the editor specs that follow reuse them.

Specs: actions menu, table and list controls, drilldown, View modal, grid
layout (38 tests).
2026-08-05 18:36:10 +05:30
Abhi Kumar
b475efceee test(dashboard-v2): e2e harness and panel rendering specs
Stands up the shared harness for the V2 dashboard E2E suite and lands the
four panel specs that exercise it end to end.

The harness:
- a `dashboards` fixture that seeds a dashboard over POST /api/v2/dashboards
  and opens it, so specs never build state through the UI
- typed spec builders (helpers/dashboard-v2-spec.ts) for V5 panel specs, so a
  test declares the panel it needs instead of hand-writing envelope JSON
- deterministic query_range mocks, keyed by panel kind, for the assertions
  that need known series rather than whatever the stack happens to hold
- uPlot readers that assert chart state (hidden series, axis bounds, scales)
  instead of pixels

Two supporting changes outside the suite:
- UPlotChart hangs its live uPlot instance off the chart container. A canvas
  exposes nothing assertable from the outside, so this is what lets a test
  read real chart state.
- the V2 specs are removed from playwright.config testIgnore; the V1
  dashboard specs stay ignored until they are ported.

Also tolerates the sidenav_pinned unique-constraint 500 in the auth fixture:
parallel workers log in as the same user and race on that row, and the loser
of the race already has the state it asked for.

Specs: panel rendering, panel states, zoom, legend and tooltip (25 tests).
2026-08-05 18:35:59 +05:30
24 changed files with 3820 additions and 10 deletions

View File

@@ -31,6 +31,36 @@ function sameConfig(prev: UPlotChartProps, next: UPlotChartProps): boolean {
return isEqual(next.config, prev.config);
}
/**
* The live uPlot instance, hung off its own container element.
*
* Charts render to a canvas, so from the outside there is nothing to assert on
* beyond pixels — an E2E test can see that a chart exists but not that a series
* was hidden, an axis bound applied, or a line turned dashed. Exposing the
* instance on its container gives those tests (and anyone debugging in the
* console) the real chart state to read.
*
* On the container rather than a global: it is naturally scoped per panel, it
* needs no registry keyed by id, and it is collected along with the node.
*/
export interface UPlotHostElement extends HTMLDivElement {
__uplot?: uPlot;
}
function setInstanceHandle(
node: HTMLDivElement | null,
plot: uPlot | null,
): void {
if (!node) {
return;
}
if (plot) {
(node as UPlotHostElement).__uplot = plot;
} else {
delete (node as UPlotHostElement).__uplot;
}
}
/**
* Plot component for rendering uPlot charts using the builder pattern
* Manages uPlot instance lifecycle and handles updates efficiently
@@ -58,6 +88,7 @@ export default function UPlotChart({
onDestroy?.(plotInstanceRef.current);
plotInstanceRef.current.destroy();
plotInstanceRef.current = null;
setInstanceHandle(containerRef.current, null);
setPlotContextInitialState({ uPlotInstance: null });
plotRef?.(null);
}
@@ -101,6 +132,7 @@ export default function UPlotChart({
});
plotInstanceRef.current = plot;
setInstanceHandle(containerRef.current, plot);
}, [
config,
data,

View File

@@ -81,7 +81,7 @@ function PreviewPane({
const [searchTerm, setSearchTerm] = useState('');
return (
<div className={styles.preview}>
<div className={styles.preview} data-testid="preview-pane">
{!hideHeader && (
<div className={styles.header}>
<PlotTag

View File

@@ -16,6 +16,8 @@ export interface ResizableHeaderProps extends Omit<
> {
width?: number;
onResize?: (e: SyntheticEvent<Element>, data: ResizeCallbackData) => void;
/** Column key, used only to give the drag grip a stable test handle. */
columnKey?: string;
}
/**
@@ -27,6 +29,7 @@ export interface ResizableHeaderProps extends Omit<
function ResizableHeader({
width,
onResize,
columnKey,
...restProps
}: ResizableHeaderProps): JSX.Element {
const handle = useMemo(
@@ -34,13 +37,14 @@ function ResizableHeader({
<span
className={styles.handle}
role="presentation"
data-testid={columnKey ? `column-resize-${columnKey}` : undefined}
// Stop the grip's click from reaching the column sorter underneath.
// The grip is a pointer-only resize affordance, not keyboard-actionable.
// oxlint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
onClick={(e): void => e.stopPropagation()}
/>
),
[],
[columnKey],
);
if (!width || !onResize) {
@@ -64,6 +68,7 @@ function ResizableHeader({
ResizableHeader.defaultProps = {
width: undefined,
onResize: undefined,
columnKey: undefined,
};
export default ResizableHeader;

View File

@@ -132,6 +132,7 @@ export function useResizableColumns<T>({
onHeaderCell: (): ResizableHeaderProps => ({
width,
onResize: key && width ? handleResize(key) : undefined,
columnKey: key,
}),
} as Column<T>;
}),

View File

@@ -99,7 +99,9 @@ function PanelHeader({
return (
<div className={cx(styles.header, 'panel-drag-handle')}>
<div className={styles.headerLeft}>
<Typography.Text className={styles.headerTitle}>{name}</Typography.Text>
<Typography.Text className={styles.headerTitle} data-testid="panel-title">
{name}
</Typography.Text>
{description && (
<TooltipSimple
title={description}

View File

@@ -70,11 +70,22 @@ async function pinSidenav(page: Page): Promise<void> {
data: { value: true },
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok()) {
throw new Error(
`PUT /api/v1/user/preferences/sidenav_pinned ${res.status()}: ${await res.text()}`,
);
if (res.ok()) {
return;
}
// Workers log in concurrently as the same user and race on this row; the
// backend insert isn't upsert-safe, so the loser gets a unique-constraint
// 500. The winner already set the pref to the value we want, so the desired
// state holds — tolerate exactly this error and keep failing on the rest
// (a 401 here means auth is broken and every spec should stop).
const body = await res.text();
if (/uq_user_preference_name_user_id|23505|duplicate key/i.test(body)) {
return;
}
throw new Error(
`PUT /api/v1/user/preferences/sidenav_pinned ${res.status()}: ${body}`,
);
}
export const test = base.extend<{

View File

@@ -0,0 +1,65 @@
import { authToken } from '../helpers/common';
import type { PostableDashboardV2 } from '../helpers/dashboard-v2-spec';
import {
createDashboardV2ViaApi,
deleteDashboardV2ViaApi,
gotoDashboardV2,
gotoPanelEditor,
} from '../helpers/dashboards-v2';
import { expect, test as base } from './auth';
// Seeding fixtures for the Dashboards V2 suite.
//
// Fixture teardown runs before its dependencies, so cleanup reuses the test's
// own authenticated context — no `seedIds` set, no `afterAll`, and no second
// admin browser context (which `authedPage` being test-scoped would force).
export interface SeedApi {
/** Create a dashboard for this test; deleted automatically when it ends. */
seed: (dashboard: PostableDashboardV2) => Promise<string>;
/** Seed, then open the dashboard and wait for its grid to mount. */
seedAndOpen: (dashboard: PostableDashboardV2) => Promise<string>;
/** Seed, then open one panel directly in the editor. */
seedAndEdit: (
dashboard: PostableDashboardV2,
panelId: string,
) => Promise<string>;
}
export const test = base.extend<{ dashboards: SeedApi }>({
dashboards: async ({ authedPage }, use) => {
const created: string[] = [];
const seed = async (dashboard: PostableDashboardV2): Promise<string> => {
const id = await createDashboardV2ViaApi(authedPage, dashboard);
created.push(id);
return id;
};
await use({
seed,
seedAndOpen: async (dashboard) => {
const id = await seed(dashboard);
await gotoDashboardV2(authedPage, id);
return id;
},
seedAndEdit: async (dashboard, panelId) => {
const id = await seed(dashboard);
await gotoPanelEditor(authedPage, id, panelId);
return id;
},
});
if (created.length === 0) {
return;
}
// Not asserted: a spec that deleted its own dashboard must not fail here.
const token = await authToken(authedPage);
for (const id of created) {
await deleteDashboardV2ViaApi(authedPage.request, id, token);
}
},
});
export { expect };

View File

@@ -0,0 +1,461 @@
// Typed builders for the V2 (Perses-shaped) dashboard spec — pure data, no
// Playwright. Mirrors Go's `dashboardtypes.DashboardSpec`.
//
// Three server rules that surface as an opaque 400:
// 1. POST decodes with DisallowUnknownFields — any stray key rejects it all.
// 2. Every panel needs EXACTLY ONE query.
// 3. Panel keys match /^[a-zA-Z0-9_.-]+$/, so readable ids double as test
// handles (`panel-actions-${id}`).
// ─── Kinds ───────────────────────────────────────────────────────────────
export const PanelKind = {
TimeSeries: 'signoz/TimeSeriesPanel',
BarChart: 'signoz/BarChartPanel',
Histogram: 'signoz/HistogramPanel',
Number: 'signoz/NumberPanel',
PieChart: 'signoz/PieChartPanel',
Table: 'signoz/TablePanel',
List: 'signoz/ListPanel',
} as const;
export type PanelKind = (typeof PanelKind)[keyof typeof PanelKind];
export type Signal = 'metrics' | 'logs' | 'traces';
// ─── Queries ─────────────────────────────────────────────────────────────
export interface MetricAggregation {
metricName: string;
timeAggregation: string;
spaceAggregation: string;
reduceTo: string;
}
/** Logs/traces aggregations are expression-shaped ("count()"), not metric-shaped. */
export interface ExpressionAggregation {
expression: string;
}
export interface OrderBy {
key: { name: string };
direction: 'asc' | 'desc';
}
export interface BuilderQuerySpec {
name: string;
signal: Signal;
aggregations: (MetricAggregation | ExpressionAggregation)[];
filter?: { expression: string };
groupBy?: { name: string }[];
order?: OrderBy[];
limit?: number;
}
export type QueryPlugin =
| { kind: 'signoz/BuilderQuery'; spec: BuilderQuerySpec }
| { kind: 'signoz/PromQLQuery'; spec: { name: string; query: string } }
| { kind: 'signoz/ClickHouseSQL'; spec: { name: string; query: string } };
/** List reads `raw`; every other kind reads `time_series`. Mixing them renders empty. */
export type QueryResultKind = 'time_series' | 'raw';
export interface Query {
kind: QueryResultKind;
spec: { plugin: QueryPlugin };
}
// ─── Panel plugin specs ──────────────────────────────────────────────────
export interface Threshold {
value: number;
color: string;
operator?:
| 'above'
| 'aboveOrEqual'
| 'below'
| 'belowOrEqual'
| 'equal'
| 'notEqual';
format?: 'background' | 'text';
unit?: string;
label?: string;
columnName?: string;
}
export interface PanelPluginSpec {
visualization?: {
timePreference?: string;
fillSpans?: boolean;
stackedBarChart?: boolean;
};
formatting?: {
unit?: string;
decimalPrecision?: string;
columnUnits?: Record<string, string>;
};
axes?: {
softMin?: number | null;
softMax?: number | null;
isLogScale?: boolean;
};
legend?: {
position?: 'bottom' | 'right';
customColors?: Record<string, string>;
};
chartAppearance?: {
lineStyle?: 'solid' | 'dashed';
lineInterpolation?: 'linear' | 'spline' | 'step_before' | 'step_after';
fillMode?: 'none' | 'solid' | 'gradient';
showPoints?: boolean;
spanGaps?: { fillOnlyBelow?: boolean; fillLessThan?: string };
};
histogramBuckets?: {
bucketCount?: number;
bucketWidth?: number;
mergeAllActiveQueries?: boolean;
};
selectFields?: { name: string; signal?: Signal; fieldDataType?: string }[];
thresholds?: Threshold[];
}
/**
* Perses' link model — NOT the editor dialog's field names. The dialog labels
* its first field "Label", but it persists as `name`; there is no `label` key
* and the strict decoder rejects one.
*/
export interface ContextLink {
name: string;
url: string;
tooltip?: string;
renderVariables?: boolean;
targetBlank?: boolean;
}
export interface Panel {
kind: 'Panel';
spec: {
display: { name: string; description?: string };
links: ContextLink[];
plugin: { kind: PanelKind; spec: PanelPluginSpec };
queries: Query[];
};
}
// ─── Layout ──────────────────────────────────────────────────────────────
export interface GridItem {
x: number;
y: number;
width: number;
height: number;
content: { $ref: string };
}
export interface Layout {
kind: 'Grid';
spec: { display: { title: string }; items: GridItem[] };
}
// ─── Variables ───────────────────────────────────────────────────────────
export type Variable =
| {
kind: 'ListVariable';
spec: {
name: string;
display: { name: string };
allowAllValue: boolean;
allowMultiple: boolean;
sort: 'none' | 'alphabetical-asc' | 'alphabetical-desc';
plugin:
| { kind: 'signoz/CustomVariable'; spec: { customValue: string } }
| { kind: 'signoz/QueryVariable'; spec: { queryValue: string } }
| {
kind: 'signoz/DynamicVariable';
spec: { name: string; signal: Signal };
};
};
}
| {
kind: 'TextVariable';
spec: { name: string; display: { name: string }; value: string };
};
// ─── Dashboard ───────────────────────────────────────────────────────────
export interface DashboardSpec {
display: { name: string; description?: string };
variables: Variable[];
panels: Record<string, Panel>;
layouts: Layout[];
links: ContextLink[];
duration?: string;
}
export interface Tag {
key: string;
value: string;
}
export interface PostableDashboardV2 {
schemaVersion: 'v6';
/** Must be EMPTY when `generateName` is true; the server derives it. */
name: string;
generateName: boolean;
tags: Tag[];
spec: DashboardSpec;
}
/** The backend pins this; a mismatch fails validation before anything else. */
export const SCHEMA_VERSION = 'v6' as const;
// ─── Golden-dataset constants ────────────────────────────────────────────
//
// What `seed/golden` writes: four metrics over 8 services, 6h of 5-min buckets.
export const GOLDEN = {
metrics: {
calls: 'signoz_calls_total',
latencyCount: 'signoz_latency_count',
latencySum: 'signoz_latency_sum',
dbLatencyCount: 'signoz_db_latency_count',
},
services: [
'adservice',
'cartservice',
'checkoutservice',
'currencyservice',
'frontend',
'paymentservice',
'productcatalogservice',
'shippingservice',
],
environment: 'production',
/** Golden telemetry spans the last 6 hours, so windows wider than this add nothing. */
windowHours: 6,
} as const;
// ─── Query builders ──────────────────────────────────────────────────────
/** A rate-over-counter metrics query grouped by service — the default fixture query. */
export function metricsQuery(options?: {
name?: string;
metricName?: string;
timeAggregation?: string;
spaceAggregation?: string;
reduceTo?: string;
filter?: string;
groupBy?: string[];
}): Query {
return {
kind: 'time_series',
spec: {
plugin: {
kind: 'signoz/BuilderQuery',
spec: {
name: options?.name ?? 'A',
signal: 'metrics',
aggregations: [
{
metricName: options?.metricName ?? GOLDEN.metrics.calls,
timeAggregation: options?.timeAggregation ?? 'rate',
spaceAggregation: options?.spaceAggregation ?? 'sum',
reduceTo: options?.reduceTo ?? 'sum',
},
],
filter: { expression: options?.filter ?? '' },
groupBy: (options?.groupBy ?? ['service.name']).map((name) => ({
name,
})),
},
},
},
};
}
/** A `raw` logs/traces query for List panels — ordered newest-first like the UI seeds it. */
export function rawQuery(options?: {
name?: string;
signal?: Extract<Signal, 'logs' | 'traces'>;
filter?: string;
limit?: number;
}): Query {
const signal = options?.signal ?? 'logs';
return {
kind: 'raw',
spec: {
plugin: {
kind: 'signoz/BuilderQuery',
spec: {
name: options?.name ?? 'A',
signal,
aggregations: [{ expression: 'count()' }],
filter: { expression: options?.filter ?? '' },
groupBy: [],
order: [
{ key: { name: 'timestamp' }, direction: 'desc' },
{ key: { name: 'id' }, direction: 'desc' },
],
...(options?.limit === undefined ? {} : { limit: options.limit }),
},
},
},
};
}
/**
* A logs/traces `count()` shaped as `time_series` — what Table and the chart
* kinds read (unlike `rawQuery`, which only List consumes). Also the only
* non-metrics seed that saves without picking a metric.
*/
export function logsCountQuery(options?: {
name?: string;
signal?: Extract<Signal, 'logs' | 'traces'>;
filter?: string;
groupBy?: string[];
}): Query {
return {
kind: 'time_series',
spec: {
plugin: {
kind: 'signoz/BuilderQuery',
spec: {
name: options?.name ?? 'A',
signal: options?.signal ?? 'logs',
aggregations: [{ expression: 'count()' }],
filter: { expression: options?.filter ?? '' },
groupBy: (options?.groupBy ?? ['service.name']).map((name) => ({
name,
})),
},
},
},
};
}
export function promqlQuery(query: string, name = 'A'): Query {
return {
kind: 'time_series',
spec: { plugin: { kind: 'signoz/PromQLQuery', spec: { name, query } } },
};
}
export function clickhouseQuery(query: string, name = 'A'): Query {
return {
kind: 'time_series',
spec: { plugin: { kind: 'signoz/ClickHouseSQL', spec: { name, query } } },
};
}
// ─── Panel builder ───────────────────────────────────────────────────────
export interface PanelOptions {
name: string;
description?: string;
pluginSpec?: PanelPluginSpec;
/** Defaults to a metrics query for every kind except List, which needs `raw`. */
query?: Query;
links?: ContextLink[];
}
export function panel(kind: PanelKind, options: PanelOptions): Panel {
const defaultQuery = kind === PanelKind.List ? rawQuery() : metricsQuery();
return {
kind: 'Panel',
spec: {
display: {
name: options.name,
...(options.description === undefined
? {}
: { description: options.description }),
},
links: options.links ?? [],
plugin: { kind, spec: options.pluginSpec ?? {} },
queries: [options.query ?? defaultQuery],
},
};
}
// ─── Dashboard composer ──────────────────────────────────────────────────
export interface SectionFixture {
title: string;
/** Keys become panel ids AND test handles, so keep them readable and unique. */
panels: Record<string, Panel>;
}
export interface DashboardFixtureOptions {
/** Defaults to a unique value; the server derives the internal name. */
title?: string;
description?: string;
sections: SectionFixture[];
variables?: Variable[];
duration?: string;
tags?: Tag[];
}
// pid distinguishes workers; the counter distinguishes seeds within one.
let fixtureSeq = 0;
function nextFixtureId(): string {
fixtureSeq += 1;
return `${process.pid}-${fixtureSeq}`;
}
/** Grid is 12 columns wide (SectionGrid `cols`), so 6×6 tiles two per row. */
const PANEL_WIDTH = 6;
const PANEL_HEIGHT = 6;
const GRID_COLS = 12;
const PER_ROW = GRID_COLS / PANEL_WIDTH;
/** Two panels per row. Duplicate ids across sections are rejected loudly. */
export function dashboardV2(
options: DashboardFixtureOptions,
): PostableDashboardV2 {
const panels: Record<string, Panel> = {};
const layouts: Layout[] = [];
for (const section of options.sections) {
const items: GridItem[] = [];
let index = 0;
for (const [id, panelSpec] of Object.entries(section.panels)) {
if (panels[id]) {
throw new Error(`duplicate panel id ${id} in dashboard fixture`);
}
panels[id] = panelSpec;
items.push({
x: (index % PER_ROW) * PANEL_WIDTH,
y: Math.floor(index / PER_ROW) * PANEL_HEIGHT,
width: PANEL_WIDTH,
height: PANEL_HEIGHT,
content: { $ref: `#/spec/panels/${id}` },
});
index += 1;
}
layouts.push({
kind: 'Grid',
spec: { display: { title: section.title }, items },
});
}
const title = options.title ?? `v2-dashboard-${nextFixtureId()}`;
return {
schemaVersion: SCHEMA_VERSION,
name: '',
generateName: true,
tags: options.tags ?? [],
spec: {
display: {
name: title,
...(options.description === undefined
? {}
: { description: options.description }),
},
variables: options.variables ?? [],
panels,
layouts,
links: [],
...(options.duration === undefined ? {} : { duration: options.duration }),
},
};
}

View File

@@ -0,0 +1,156 @@
import { expect, type APIRequestContext, type Page } from '@playwright/test';
import { authToken } from './common';
import type { PanelKind, PostableDashboardV2 } from './dashboard-v2-spec';
// Seeding + navigation for V2 dashboards.
//
// Separate from `helpers/dashboards.ts`, whose `/api/v1/dashboards` calls now
// return NewV1DeprecatedError. V2's POST takes the full spec in one call.
// ─── Routes ──────────────────────────────────────────────────────────────
export const DASHBOARDS_API = '/api/v2/dashboards';
export function dashboardPath(dashboardId: string): string {
return `/dashboard/${dashboardId}`;
}
/** Editor route — `ROUTES.DASHBOARD_PANEL_EDITOR`. `panelId` is 'new' for creation. */
export function panelEditorPath(dashboardId: string, panelId: string): string {
return `/dashboard/${dashboardId}/panel/${panelId}`;
}
// ─── API ─────────────────────────────────────────────────────────────────
export interface GettableDashboardV2 {
id: string;
name: string;
locked: boolean;
tags: { key: string; value: string }[];
spec: PostableDashboardV2['spec'];
}
async function bearer(page: Page): Promise<{ Authorization: string }> {
return { Authorization: `Bearer ${await authToken(page)}` };
}
/**
* A 400 here is almost always the strict decoder rejecting a stray key; the
* body names the field, so it's surfaced verbatim.
*/
export async function createDashboardV2ViaApi(
page: Page,
dashboard: PostableDashboardV2,
): Promise<string> {
const res = await page.request.post(DASHBOARDS_API, {
data: dashboard,
headers: await bearer(page),
});
if (!res.ok()) {
throw new Error(
`POST ${DASHBOARDS_API} ${res.status()}: ${await res.text()}`,
);
}
const body = (await res.json()) as { data: GettableDashboardV2 };
return body.data.id;
}
/** Read the persisted spec — use to assert what a UI edit actually saved. */
export async function getDashboardV2ViaApi(
page: Page,
dashboardId: string,
): Promise<GettableDashboardV2> {
const res = await page.request.get(`${DASHBOARDS_API}/${dashboardId}`, {
headers: await bearer(page),
});
if (!res.ok()) {
throw new Error(
`GET ${DASHBOARDS_API}/${dashboardId} ${res.status()}: ${await res.text()}`,
);
}
const body = (await res.json()) as { data: GettableDashboardV2 };
return body.data;
}
/** Best-effort: a UI flow may already have deleted it. */
export async function deleteDashboardV2ViaApi(
request: APIRequestContext,
dashboardId: string,
token: string,
): Promise<void> {
await request
.delete(`${DASHBOARDS_API}/${dashboardId}`, {
headers: { Authorization: `Bearer ${token}` },
})
.catch(() => undefined);
}
/** Same path, no body: PUT locks, DELETE unlocks. */
export async function setDashboardLockedViaApi(
page: Page,
dashboardId: string,
locked: boolean,
): Promise<void> {
const url = `${DASHBOARDS_API}/${dashboardId}/lock`;
const headers = await bearer(page);
const res = locked
? await page.request.put(url, { headers })
: await page.request.delete(url, { headers });
if (!res.ok()) {
throw new Error(
`${locked ? 'PUT' : 'DELETE'} ${url} ${res.status()}: ${await res.text()}`,
);
}
}
// ─── Navigation ──────────────────────────────────────────────────────────
/** Waits for `data-panel-root` — the first marker that the grid, not just the shell, rendered. */
export async function gotoDashboardV2(
page: Page,
dashboardId: string,
): Promise<void> {
await page.goto(dashboardPath(dashboardId));
await page.locator('[data-panel-root]').first().waitFor({ state: 'attached' });
}
/** Open a dashboard expected to have no panels (asserts the empty state instead). */
export async function gotoEmptyDashboardV2(
page: Page,
dashboardId: string,
): Promise<void> {
await page.goto(dashboardPath(dashboardId));
await expect(page.getByTestId('add-panel')).toBeVisible();
}
/** Open the editor for an existing panel. */
export async function gotoPanelEditor(
page: Page,
dashboardId: string,
panelId: string,
): Promise<void> {
await page.goto(panelEditorPath(dashboardId, panelId));
// Scoped: the ResizablePanelGroup shares this testid (derived from its id).
await expect(
page.locator('[data-testid="panel-editor-v2"]:not([data-group])'),
).toBeVisible();
}
/** `panelKind` is required — without it the route redirects to the dashboard. */
export async function gotoNewPanelEditor(
page: Page,
dashboardId: string,
panelKind: PanelKind,
layoutIndex = 0,
): Promise<void> {
const search = new URLSearchParams({
panelKind,
layoutIndex: String(layoutIndex),
});
await page.goto(`${panelEditorPath(dashboardId, 'new')}?${search.toString()}`);
// Scoped: the ResizablePanelGroup shares this testid (derived from its id).
await expect(
page.locator('[data-testid="panel-editor-v2"]:not([data-group])'),
).toBeVisible();
}

View File

@@ -0,0 +1,288 @@
import { expect, type Locator, type Page } from '@playwright/test';
// Locators and interactions for the V2 panel editor.
//
// Two gotchas: antd popups portal to `document.body` (a testid finds the
// TRIGGER, options live in a detached `.ant-select-dropdown`), and config
// sections only mount their editors while open.
// ─── Labels ──────────────────────────────────────────────────────────────
export const EditorText = {
title: 'Configure panel',
unsavedBadge: 'Unsaved Changes',
save: 'Save changes',
switchToView: 'Switch to View Mode',
discardTitle: 'Discard changes?',
discardBody: 'Your unsaved edits to this panel will be lost.',
savedToast: 'Panel saved',
lockedReason: 'This dashboard is locked',
runQuery: 'Run Query',
} as const;
export const QueryTab = {
builder: 'Query Builder',
clickhouse: 'ClickHouse Query',
promql: 'PromQL',
} as const;
/** SettingsSection titles, as rendered — `sectionTestId` slugifies them. */
export const Section = {
visualization: 'Visualization',
formatting: 'Formatting & Units',
axes: 'Axes',
legend: 'Legend',
chartAppearance: 'Chart Appearance',
buckets: 'Histogram / Buckets',
thresholds: 'Thresholds',
contextLinks: 'Context Links',
} as const;
/** Slugified as `title.toLowerCase().replace(/\s+/g,'-')` — `&` and `/` survive. */
export function sectionTestId(title: string): string {
return `config-section-${title.toLowerCase().replace(/\s+/g, '-')}`;
}
// ─── Shell locators ──────────────────────────────────────────────────────
/**
* `panel-editor-v2` is NOT unique — the ResizablePanelGroup derives the same
* testid from its `id` (also the localStorage layout key, so unrenameable).
* `:not([data-group])` picks the page root.
*/
const EDITOR_ROOT = '[data-testid="panel-editor-v2"]:not([data-group])';
export const editor = {
root: (page: Page): Locator => page.locator(EDITOR_ROOT),
title: (page: Page): Locator => page.getByTestId('panel-editor-v2-title'),
description: (page: Page): Locator =>
page.getByTestId('panel-editor-v2-description'),
save: (page: Page): Locator => page.getByTestId('panel-editor-v2-save'),
close: (page: Page): Locator => page.getByTestId('panel-editor-v2-close'),
unsavedBadge: (page: Page): Locator =>
page.getByTestId('panel-editor-v2-unsaved-badge'),
switchToView: (page: Page): Locator =>
page.getByTestId('panel-editor-v2-switch-to-view'),
typeSwitcher: (page: Page): Locator =>
page.getByTestId('panel-editor-v2-type-switcher'),
queryBuilder: (page: Page): Locator =>
page.getByTestId('panel-editor-v2-query-builder'),
};
// ─── Sections ────────────────────────────────────────────────────────────
export function sectionToggle(page: Page, title: string): Locator {
return page.getByTestId(sectionTestId(title));
}
/** Idempotent: a blind click on an open section would collapse it. */
export async function expandSection(page: Page, title: string): Promise<void> {
const toggle = sectionToggle(page, title);
await expect(toggle).toBeVisible();
if ((await toggle.getAttribute('aria-expanded')) !== 'true') {
await toggle.click();
}
await expect(toggle).toHaveAttribute('aria-expanded', 'true');
}
export async function collapseSection(
page: Page,
title: string,
): Promise<void> {
const toggle = sectionToggle(page, title);
if ((await toggle.getAttribute('aria-expanded')) === 'true') {
await toggle.click();
}
await expect(toggle).toHaveAttribute('aria-expanded', 'false');
}
// ─── antd Select helpers ─────────────────────────────────────────────────
/**
* Open a Select and resolve ITS dropdown via `aria-controls`. A closing
* dropdown still matches `:not(.ant-select-dropdown-hidden)`, so opening two
* Selects in a row otherwise trips strict mode.
*/
async function openDropdown(
page: Page,
triggerTestId: string,
): Promise<Locator> {
const trigger = page.getByTestId(triggerTestId);
await trigger.click();
const listId = await trigger.locator('input').getAttribute('aria-controls');
const dropdown = listId
? page
.locator(`#${listId}`)
.locator('xpath=ancestor::div[contains(@class,"ant-select-dropdown")][1]')
: page
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)')
.last();
await expect(dropdown).toBeVisible();
return dropdown;
}
/** Pick an option from an antd Select identified by the trigger's testid. */
export async function selectOption(
page: Page,
triggerTestId: string,
optionLabel: string,
): Promise<void> {
const dropdown = await openDropdown(page, triggerTestId);
await dropdown
.locator('.ant-select-item-option')
.filter({ hasText: optionLabel })
.first()
.click();
}
/** Searches first — long option lists are virtualised (needed for unit pickers). */
export async function searchAndSelectOption(
page: Page,
triggerTestId: string,
searchTerm: string,
optionLabel: string,
): Promise<void> {
const dropdown = await openDropdown(page, triggerTestId);
await page.getByTestId(triggerTestId).locator('input').fill(searchTerm);
await dropdown
.locator('.ant-select-item-option')
.filter({ hasText: optionLabel })
.first()
.click();
}
/** Read the option labels a Select currently offers, plus their disabled state. */
export async function selectOptions(
page: Page,
triggerTestId: string,
): Promise<{ label: string; disabled: boolean }[]> {
const dropdown = await openDropdown(page, triggerTestId);
return dropdown.locator('.ant-select-item-option').evaluateAll((nodes) =>
nodes.map((node) => ({
label: node.textContent?.trim() ?? '',
disabled: node.classList.contains('ant-select-item-option-disabled'),
})),
);
}
// ─── Segmented / switch controls ─────────────────────────────────────────
/** Segments carry `aria-label`; the testid is on the group. */
export async function setSegment(
page: Page,
groupTestId: string,
label: string,
): Promise<void> {
await page.getByTestId(groupTestId).locator(`[aria-label="${label}"]`).click();
}
export function segment(
page: Page,
groupTestId: string,
label: string,
): Locator {
return page.getByTestId(groupTestId).locator(`[aria-label="${label}"]`);
}
// ─── Query builder ───────────────────────────────────────────────────────
/** The Run button has no testid at this call site, so match by role. */
export async function runQuery(page: Page): Promise<void> {
const response = page.waitForResponse((r) => r.url().includes('/query_range'));
await page.getByRole('button', { name: EditorText.runQuery }).click();
await response;
}
export function queryTab(page: Page, label: string): Locator {
return editor.queryBuilder(page).getByRole('tab', { name: label });
}
/**
* Pick a metric. An antd AutoComplete: testid is on a wrapper, options are
* fetched as you type. Required before a new metrics panel can be saved — the
* backend rejects an empty aggregation with "metric name is required".
*/
export async function selectMetric(
page: Page,
metricName: string,
index = 0,
): Promise<void> {
const field = page.getByTestId(`metric-name-selector-${index}`);
await field.click();
await field.locator('input').fill(metricName);
const dropdown = page.locator(
'.ant-select-dropdown:not(.ant-select-dropdown-hidden)',
);
await expect(dropdown).toBeVisible();
await dropdown
.locator('.ant-select-item-option')
.filter({ hasText: metricName })
.first()
.click();
}
// ─── Save / discard ──────────────────────────────────────────────────────
/** Save is NOT gated on dirty state — a pristine panel still saves. */
export async function savePanel(page: Page): Promise<void> {
const patch = page.waitForResponse(
(r) =>
r.request().method() === 'PATCH' && /\/api\/v2\/dashboards\//.test(r.url()),
);
await editor.save(page).click();
const response = await patch;
// A rejected patch leaves the editor open, which would surface as an
// unrelated timeout several lines later.
expect(
response.ok(),
`PATCH ${response.url()} failed: ${response.status()} ${await response.text()}`,
).toBe(true);
}
/** A dirty panel raises the discard dialog; a pristine one closes immediately. */
export async function closeEditor(
page: Page,
options?: { expectDirty?: boolean; keepEditing?: boolean },
): Promise<void> {
await editor.close(page).click();
if (!options?.expectDirty) {
return;
}
await expect(page.getByTestId('panel-editor-v2-discard-modal')).toBeVisible();
await page
.getByTestId(
options.keepEditing
? 'panel-editor-v2-discard-cancel'
: 'panel-editor-v2-discard-confirm',
)
.click();
}
// ─── Patch capture ───────────────────────────────────────────────────────
export interface PatchOperation {
op: string;
path: string;
value?: unknown;
}
/**
* Record the RFC-6902 ops sent on save. Stricter than re-reading the dashboard:
* catches a whole-spec replace that would clobber concurrent edits.
*/
export function capturePatchOps(page: Page): PatchOperation[][] {
const batches: PatchOperation[][] = [];
page.on('request', (request) => {
if (
request.method() !== 'PATCH' ||
!/\/api\/v2\/dashboards\//.test(request.url())
) {
return;
}
const body = request.postDataJSON() as PatchOperation[] | null;
if (body) {
batches.push(body);
}
});
return batches;
}

View File

@@ -0,0 +1,255 @@
import { expect, type Locator, type Page } from '@playwright/test';
import { PanelKind } from './dashboard-v2-spec';
// Locators and interactions for rendered V2 panels (dashboard grid + View
// modal). Editor-side helpers live in `helpers/panel-editor-v2.ts`.
// ─── Labels ──────────────────────────────────────────────────────────────
//
// @signozhq/ui's dropdown preset doesn't forward `testId` to menu items, so the
// visible label IS the contract — matched via Radix's `role=menuitem`.
export const PanelAction = {
view: 'View',
edit: 'Edit panel',
clone: 'Clone',
download: 'Download',
downloadCsv: 'Download as CSV',
downloadPng: 'Download as PNG',
downloadSvg: 'Download as SVG',
createAlert: 'Create Alerts',
move: 'Move to section',
moveToRoot: 'Dashboard (root)',
delete: 'Delete panel',
} as const;
export const PanelMessageText = {
noQueryTitle: 'Nothing to visualize yet',
// Curly apostrophe (U+2019) — a straight quote will not match.
errorTitle: 'Couldnt load panel data',
noDataTitle: 'No data in this time range',
extendAction: 'Extend time range',
retryAction: 'Retry',
} as const;
/** Root `data-testid` each kind's renderer puts on its own subtree. */
export const RENDERER_TESTID: Record<PanelKind, string> = {
[PanelKind.TimeSeries]: 'time-series-renderer',
[PanelKind.BarChart]: 'bar-panel-renderer',
[PanelKind.Histogram]: 'histogram-panel-renderer',
[PanelKind.Number]: 'number-panel-renderer',
[PanelKind.PieChart]: 'pie-panel-renderer',
[PanelKind.Table]: 'table-panel-renderer',
[PanelKind.List]: 'list-panel-renderer',
};
// ─── Locators ────────────────────────────────────────────────────────────
/** No testid on the panel root; `data-panel-root` is the stable handle. */
export function panelRoot(page: Page, panelId: string): Locator {
return page.locator(`[data-panel-root="${panelId}"]`);
}
export function panelRenderer(
page: Page,
panelId: string,
kind: PanelKind,
): Locator {
return panelRoot(page, panelId).getByTestId(RENDERER_TESTID[kind]);
}
/** Scrolls into view first — panels fetch lazily behind an IntersectionObserver. */
export async function waitForPanelRendered(
page: Page,
panelId: string,
kind: PanelKind,
): Promise<void> {
const root = panelRoot(page, panelId);
await root.scrollIntoViewIfNeeded();
await expect(root).toHaveAttribute('data-panel-visible', 'true');
await expect(root.getByTestId(RENDERER_TESTID[kind])).toBeVisible();
}
// ─── Actions menu ────────────────────────────────────────────────────────
/** The ⋮ trigger only appears on hover. */
export async function openPanelActions(
page: Page,
panelId: string,
): Promise<Locator> {
// A previous menu caught mid-dismiss trips strict mode or eats the click.
await closePanelActions(page);
const root = panelRoot(page, panelId);
await root.scrollIntoViewIfNeeded();
await root.hover();
await page.getByTestId(`panel-actions-${panelId}`).click();
const menu = page.getByRole('menu');
await expect(menu).toBeVisible();
return menu;
}
/** Dismiss an open panel menu and wait until it is really gone. */
export async function closePanelActions(page: Page): Promise<void> {
if ((await page.getByRole('menu').count()) === 0) {
return;
}
await page.keyboard.press('Escape');
await expect(page.getByRole('menu')).toHaveCount(0);
}
/** Open the ⋮ menu and click one item by its visible label. */
export async function runPanelAction(
page: Page,
panelId: string,
label: string,
): Promise<void> {
await openPanelActions(page, panelId);
await page.getByRole('menuitem', { name: label, exact: true }).click();
}
/** Hover opens the submenu; clicking the parent would close the dropdown. */
export async function downloadPanelAs(
page: Page,
panelId: string,
format: 'CSV' | 'PNG' | 'SVG',
): Promise<void> {
await openPanelActions(page, panelId);
await page
.getByRole('menuitem', { name: PanelAction.download, exact: true })
.hover();
await page
.getByRole('menuitem', { name: `Download as ${format}`, exact: true })
.click();
}
// ─── Geometry ────────────────────────────────────────────────────────────
export interface Box {
x: number;
y: number;
width: number;
height: number;
}
/** Throws instead of returning null (specs can't contain conditionals). */
export async function boundingBoxOf(
locator: Locator,
description: string,
): Promise<Box> {
await locator.scrollIntoViewIfNeeded();
const box = await locator.boundingBox();
expect(box, `expected a bounding box for ${description}`).not.toBeNull();
return box as Box;
}
/** The extra pre-mousedown move gives uPlot a cursor anchor to drag from. */
export async function dragHorizontally(
page: Page,
box: Box,
fromFraction: number,
toFraction: number,
): Promise<void> {
const y = box.y + box.height / 2;
const startX = box.x + box.width * fromFraction;
const endX = box.x + box.width * toFraction;
await page.mouse.move(startX, y);
await page.mouse.move(startX, y);
await page.mouse.down();
await page.mouse.move(endX, y, { steps: 16 });
await page.mouse.up();
}
/** The uPlot canvas host inside a panel. */
export function panelChart(page: Page, panelId: string): Locator {
return panelRoot(page, panelId).getByTestId('uplot-main-div');
}
/**
* uPlot binds its cursor handlers to `.u-over`, so this — not the container —
* is the drag target. Container-relative fractions drift onto the axis gutter,
* whose width is browser/DPR dependent.
*/
export function panelPlotArea(page: Page, panelId: string): Locator {
return panelChart(page, panelId).locator('.u-over');
}
/** RGL appends the resize grip here, a SIBLING of `[data-panel-root]`. */
export function panelGridItem(page: Page, panelId: string): Locator {
return panelRoot(page, panelId).locator(
'xpath=ancestor::div[contains(@class,"react-grid-item")][1]',
);
}
/** RGL's south-east resize grip for a panel. */
export function panelResizeHandle(page: Page, panelId: string): Locator {
return panelGridItem(page, panelId).locator('.react-resizable-handle');
}
// ─── Drilldown ───────────────────────────────────────────────────────────
/** The drilldown popover, portalled to body and shared by every panel. */
export function contextMenu(page: Page): Locator {
return page.locator('.context-menu');
}
/** Testids sit on an inner span; walk up so disabled-state assertions work. */
export function drilldownItem(page: Page, testId: string): Locator {
return page
.getByTestId(testId)
.locator(
'xpath=ancestor-or-self::button[contains(@class,"context-menu-item")][1]',
);
}
// ─── States ──────────────────────────────────────────────────────────────
export function panelNoData(page: Page, panelId: string): Locator {
return panelRoot(page, panelId).getByTestId('panel-no-data');
}
export function panelError(page: Page, panelId: string): Locator {
return panelRoot(page, panelId).getByTestId('panel-error');
}
// ─── Header search (Table / List only) ───────────────────────────────────
export async function searchInPanel(
page: Page,
panelId: string,
term: string,
): Promise<void> {
const root = panelRoot(page, panelId);
await root.hover();
await root.getByTestId('panel-header-search-trigger').click();
await root.getByTestId('panel-header-search-input').fill(term);
}
// ─── List pagination ─────────────────────────────────────────────────────
export const listPager = {
root: (page: Page, panelId: string): Locator =>
panelRoot(page, panelId).getByTestId('list-panel-pager'),
prev: (page: Page, panelId: string): Locator =>
panelRoot(page, panelId).getByTestId('list-panel-prev'),
next: (page: Page, panelId: string): Locator =>
panelRoot(page, panelId).getByTestId('list-panel-next'),
page: (page: Page, panelId: string): Locator =>
panelRoot(page, panelId).getByTestId('list-panel-page'),
pageSize: (page: Page, panelId: string): Locator =>
panelRoot(page, panelId).getByTestId('list-panel-page-size'),
};
// ─── View modal ──────────────────────────────────────────────────────────
export async function openViewModal(
page: Page,
panelId: string,
): Promise<Locator> {
await runPanelAction(page, panelId, PanelAction.view);
const content = page.getByTestId('view-panel-modal-content');
await expect(content).toBeVisible();
return content;
}

View File

@@ -0,0 +1,272 @@
import type { Page, Route } from '@playwright/test';
// Pinned `/api/v5/query_range` payloads. Install BEFORE `page.goto`.
//
// Prefer golden data and assert STRUCTURE: the dataset's content is
// deterministic but `seed/golden` rebases timestamps to `now`, so labels and
// counts are stable while exact numbers are not. Reach for this only when the
// backend can't promise what the test needs — error/no-data/warning states,
// exact numbers for thresholds and units, a known series count, or stable
// pagination.
//
// Envelope (double-nested, easy to get wrong), mirroring `QueryRangeV5200`:
// { status, data: { type, meta, warning, data: { results: [...] } } }
// `data.type` is the discriminator — a body under the wrong type renders as an
// empty panel rather than failing loudly.
const QUERY_RANGE_GLOB = '**/api/v5/query_range';
type RequestType = 'time_series' | 'scalar' | 'raw' | 'trace';
export interface QueryRangeBody {
status: string;
data: {
type: RequestType;
data: { results: unknown[] };
meta?: Record<string, unknown>;
warning?: Record<string, unknown>;
};
}
function envelope(type: RequestType, results: unknown[]): QueryRangeBody {
return { status: 'success', data: { type, data: { results } } };
}
// ─── Time helpers ────────────────────────────────────────────────────────
const MINUTE_MS = 60_000;
export interface TimeSeriesPoint {
timestamp: number;
value: number;
}
export interface SeriesSpec {
labels: Record<string, string>;
points: TimeSeriesPoint[];
}
/** Evenly spaced epoch-ms timestamps ending at `endMs`. */
export function timestamps(
count: number,
stepMinutes = 5,
endMs = Date.now(),
): number[] {
return Array.from(
{ length: count },
(_, i) => endMs - (count - 1 - i) * stepMinutes * MINUTE_MS,
);
}
/** A ramp of `count` points from `from` to `to` — readable, non-flat test data. */
export function ramp(
count: number,
from: number,
to: number,
endMs = Date.now(),
): TimeSeriesPoint[] {
const step = count > 1 ? (to - from) / (count - 1) : 0;
return timestamps(count, 5, endMs).map((timestamp, i) => ({
timestamp,
value: from + step * i,
}));
}
/** A flat line — use when the assertion is about a specific value, not a shape. */
export function flat(
count: number,
value: number,
endMs = Date.now(),
): TimeSeriesPoint[] {
return timestamps(count, 5, endMs).map((timestamp) => ({
timestamp,
value,
}));
}
// ─── Payload builders ────────────────────────────────────────────────────
export const QueryRange = {
/** Series must nest at `results[].aggregations[].series[]`. */
timeSeries(series: SeriesSpec[], queryName = 'A'): QueryRangeBody {
return envelope('time_series', [
{
queryName,
aggregations: [
{
index: 0,
alias: '',
series: series.map((s) => ({
labels: Object.entries(s.labels).map(([name, value]) => ({
key: { name },
value,
})),
values: s.points.map((p) => ({
timestamp: p.timestamp,
value: p.value,
})),
})),
},
],
},
]);
},
/** Rows are positional across `[...groupColumns, ...aggregationColumns]`. */
scalar(options: {
groupColumns?: string[];
aggregationColumns?: string[];
rows: (string | number)[][];
queryName?: string;
units?: Record<string, string>;
}): QueryRangeBody {
const queryName = options.queryName ?? 'A';
const group = (options.groupColumns ?? []).map((name) => ({
name,
columnType: 'group',
...(options.units?.[name] ? { meta: { unit: options.units[name] } } : {}),
}));
const aggregation = (options.aggregationColumns ?? []).map((name, index) => ({
name,
columnType: 'aggregation',
aggregationIndex: index,
...(options.units?.[name] ? { meta: { unit: options.units[name] } } : {}),
}));
return envelope('scalar', [
{ queryName, columns: [...group, ...aggregation], data: options.rows },
]);
},
/** List rows. `timestamp` is RFC-3339 here, not epoch ms — per the contract. */
raw(
rows: Record<string, unknown>[],
options?: { queryName?: string; nextCursor?: string },
): QueryRangeBody {
return envelope('raw', [
{
queryName: options?.queryName ?? 'A',
...(options?.nextCursor ? { nextCursor: options.nextCursor } : {}),
rows: rows.map((data) => ({
timestamp:
typeof data.timestamp === 'string'
? data.timestamp
: new Date(Number(data.timestamp ?? 0)).toISOString(),
data,
})),
},
]);
},
/** Successful but empty — drives `panel-no-data`. */
empty(queryName = 'A'): QueryRangeBody {
return envelope('time_series', [{ queryName, aggregations: [] }]);
},
/** Successful with a warning — drives the `panel-status-warning` indicator. */
withWarning(body: QueryRangeBody, message: string): QueryRangeBody {
return {
...body,
data: {
...body.data,
warning: { warnings: [{ message }], message },
},
};
},
};
// ─── Route installers ────────────────────────────────────────────────────
export interface MockOptions {
/** Stop intercepting after N calls, letting later requests hit the backend. */
times?: number;
}
/** Fulfil every query_range call with one pinned body. */
export async function mockQueryRange(
page: Page,
body: QueryRangeBody,
options?: MockOptions,
): Promise<void> {
let served = 0;
await page.route(QUERY_RANGE_GLOB, async (route: Route) => {
if (options?.times !== undefined && served >= options.times) {
await route.fallback();
return;
}
served += 1;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(body),
});
});
}
/** Fail every query_range call — drives `panel-error`. */
export async function mockQueryRangeError(
page: Page,
message = 'mocked query failure',
options?: MockOptions & { status?: number },
): Promise<void> {
let served = 0;
await page.route(QUERY_RANGE_GLOB, async (route: Route) => {
if (options?.times !== undefined && served >= options.times) {
await route.fallback();
return;
}
served += 1;
await route.fulfill({
status: options?.status ?? 500,
contentType: 'application/json',
body: JSON.stringify({
status: 'error',
error: { code: 'internal', message },
}),
});
});
}
/** One body per call, repeating the last — for pagination and retry flows. */
export async function mockQueryRangeSequence(
page: Page,
bodies: QueryRangeBody[],
): Promise<void> {
let call = 0;
await page.route(QUERY_RANGE_GLOB, async (route: Route) => {
const body = bodies[Math.min(call, bodies.length - 1)];
call += 1;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(body),
});
});
}
/** Record request bodies without changing responses. */
export function recordQueryRange(page: Page): {
requests: Record<string, unknown>[];
install: () => Promise<void>;
} {
const requests: Record<string, unknown>[] = [];
return {
requests,
install: async () => {
await page.route(QUERY_RANGE_GLOB, async (route: Route) => {
const payload = route.request().postDataJSON() as Record<
string,
unknown
> | null;
if (payload) {
requests.push(payload);
}
await route.fallback();
});
},
};
}
/** Drop the interception so later navigation reaches the real backend again. */
export async function unmockQueryRange(page: Page): Promise<void> {
await page.unroute(QUERY_RANGE_GLOB);
}

138
tests/e2e/helpers/uplot.ts Normal file
View File

@@ -0,0 +1,138 @@
import { expect, type Locator, type Page } from '@playwright/test';
import { panelChart } from './panels-v2';
// Reading real chart state out of uPlot, via the `__uplot` handle that
// UPlotChart hangs off its container.
//
// The DOM only says a chart exists; the saved spec only proves the editor wrote
// the right JSON. Neither catches a renderer that ignores a setting.
export interface UPlotSeriesState {
label?: string;
/** false once a series is hidden via the legend. */
show: boolean;
/** Dash pattern; non-empty for a dashed line style. */
dash?: number[];
/** uPlot only sets a fill when the panel asks for one. */
hasFill: boolean;
/** A predicate function can't cross `evaluate`, so it reports as 'fn'. */
pointsShow?: boolean | 'fn' | null;
width?: number;
}
export interface UPlotScaleState {
min: number | null;
max: number | null;
/** uPlot scale distribution: 1 linear, 3 logarithmic. */
distr?: number;
}
export interface UPlotState {
/** Excludes the x series at index 0 — callers care about the plotted ones. */
series: UPlotSeriesState[];
scales: Record<string, UPlotScaleState>;
/** Number of points in the x series. */
pointCount: number;
}
/** Throws if no instance — usually the panel is showing "No Data". */
export async function uplotState(
page: Page,
panelId: string,
): Promise<UPlotState> {
return uplotStateAt(panelChart(page, panelId));
}
/** The editor preview has no `data-panel-root`, so it needs its own locator. */
export function previewChart(page: Page): Locator {
return page.getByTestId('preview-pane').getByTestId('uplot-main-div');
}
export async function previewState(page: Page): Promise<UPlotState> {
return uplotStateAt(previewChart(page));
}
/** Read chart state from an explicit `uplot-main-div` locator. */
export async function uplotStateAt(chart: Locator): Promise<UPlotState> {
await expect(chart).toBeVisible();
return chart.evaluate((node) => {
const plot = (node as { __uplot?: unknown }).__uplot as
| {
series: {
label?: string;
show?: boolean;
dash?: number[];
fill?: unknown;
width?: number;
points?: { show?: boolean | ((...args: unknown[]) => boolean) };
}[];
scales: Record<string, { min?: number; max?: number; distr?: number }>;
data: unknown[][];
}
| undefined;
if (!plot) {
throw new Error(
'no uPlot instance on the chart container — the panel probably rendered "No Data" instead of a plot',
);
}
return {
series: plot.series.slice(1).map((series) => ({
label: series.label,
show: series.show !== false,
dash: series.dash,
hasFill: series.fill != null,
pointsShow:
typeof series.points?.show === 'function'
? ('fn' as const)
: (series.points?.show ?? null),
width: series.width,
})),
scales: Object.fromEntries(
Object.entries(plot.scales).map(([key, scale]) => [
key,
{
min: scale.min ?? null,
max: scale.max ?? null,
distr: scale.distr,
},
]),
),
pointCount: plot.data?.[0]?.length ?? 0,
};
});
}
/** Wait until a panel's chart reports the expected number of plotted series. */
export async function expectSeriesCount(
page: Page,
panelId: string,
count: number,
): Promise<void> {
await expect
.poll(async () => (await uplotState(page, panelId)).series.length)
.toBe(count);
}
/** Where axis bounds and log mode land. */
export async function yScale(
page: Page,
panelId: string,
): Promise<UPlotScaleState> {
const state = await uplotState(page, panelId);
// Panels name the left scale 'y'; fall back to the first non-x scale so this
// keeps working if a kind introduces its own name.
return (
state.scales.y ??
Object.entries(state.scales).find(([key]) => key !== 'x')?.[1] ?? {
min: null,
max: null,
}
);
}
/** uPlot encodes a log scale as `distr: 3`. */
export const LOG_DISTR = 3;

View File

@@ -15,9 +15,13 @@ export default defineConfig({
testDir: './tests',
// Temporarily excluded: the V1 -> V2 dashboard migration changes the
// behaviour the dashboards specs assert against, so they fail as written.
// Remove this once they are updated for the V2 dashboard.
testIgnore: ['**/tests/dashboards/**'],
// behaviour these specs assert against, so they fail as written. The V2
// suite under tests/dashboards/v2/ is deliberately NOT ignored — narrow
// this list further as the V1 specs are ported, don't re-broaden it.
testIgnore: [
'**/tests/dashboards/list.spec.ts',
'**/tests/dashboards/details/**',
],
// All Playwright output lands under artifacts/. One subdir per reporter
// plus results/ for per-test artifacts (traces/screenshots/videos).

View File

@@ -0,0 +1,297 @@
import {
GOLDEN,
PanelKind,
clickhouseQuery,
dashboardV2,
metricsQuery,
panel,
promqlQuery,
rawQuery,
type ContextLink,
type PanelPluginSpec,
type PostableDashboardV2,
type Query,
type Variable,
} from '../../helpers/dashboard-v2-spec';
// Dashboard fixtures for the V2 suites. TypeScript rather than frozen JSON so
// a contract change is a compile error, and panel ids stay visible to the specs
// that use them as test handles.
let seq = 0;
function uniqueSuffix(): string {
seq += 1;
return `${process.pid}-${seq}`;
}
// ─── All kinds ───────────────────────────────────────────────────────────
export const ALL_KINDS_PANELS = {
timeseries: 'ts-panel',
bar: 'bar-panel',
histogram: 'histogram-panel',
number: 'number-panel',
pie: 'pie-panel',
table: 'table-panel',
list: 'list-panel',
} as const;
/**
* One panel of every kind. Only for "every kind renders" and lazy-load — use
* `compactDashboard` otherwise; seven panels means seven real queries.
*/
export function allKindsDashboard(title?: string): PostableDashboardV2 {
return dashboardV2({
title: title ?? `v2-all-kinds-${Date.now()}-${uniqueSuffix()}`,
sections: [
{
title: 'Charts',
panels: {
[ALL_KINDS_PANELS.timeseries]: panel(PanelKind.TimeSeries, {
name: 'Calls by service',
description: 'Rate of signoz_calls_total grouped by service.name',
}),
[ALL_KINDS_PANELS.bar]: panel(PanelKind.BarChart, {
name: 'Calls bar',
}),
[ALL_KINDS_PANELS.histogram]: panel(PanelKind.Histogram, {
name: 'Latency distribution',
query: metricsQuery({
metricName: GOLDEN.metrics.latencySum,
groupBy: [],
}),
}),
[ALL_KINDS_PANELS.pie]: panel(PanelKind.PieChart, {
name: 'Calls share',
}),
},
},
{
title: 'Tabular',
panels: {
[ALL_KINDS_PANELS.number]: panel(PanelKind.Number, {
name: 'Total calls',
pluginSpec: { formatting: { unit: 'short', decimalPrecision: '2' } },
query: metricsQuery({ groupBy: [] }),
}),
[ALL_KINDS_PANELS.table]: panel(PanelKind.Table, {
name: 'Calls table',
}),
[ALL_KINDS_PANELS.list]: panel(PanelKind.List, {
name: 'Recent logs',
pluginSpec: {
selectFields: [
{ name: 'timestamp', signal: 'logs' },
{ name: 'body', signal: 'logs' },
],
},
query: rawQuery({ signal: 'logs' }),
}),
},
},
],
});
}
// ─── Compact (default multi-panel fixture) ───────────────────────────────
export const COMPACT_PANELS = {
timeseries: ALL_KINDS_PANELS.timeseries,
table: ALL_KINDS_PANELS.table,
list: ALL_KINDS_PANELS.list,
} as const;
/**
* The default multi-panel fixture: three panels, two sections.
*
* Every panel fires its own `query_range`, and over-seeding was the dominant
* load on the single-container stack. These three keep every distinction the
* specs assert — TimeSeries (charts, zoom, drilldown, createAlert), Table (CSV
* download, search), List (search, no createAlert) — and two sections keep
* Move-to-section and the modal's select-then-confirm branch reachable.
*/
export function compactDashboard(title?: string): PostableDashboardV2 {
return dashboardV2({
title: title ?? `v2-compact-${Date.now()}-${uniqueSuffix()}`,
sections: [
{
title: 'Charts',
panels: {
[COMPACT_PANELS.timeseries]: panel(PanelKind.TimeSeries, {
name: 'Calls by service',
description: 'Rate of signoz_calls_total grouped by service.name',
}),
},
},
{
title: 'Tabular',
panels: {
[COMPACT_PANELS.table]: panel(PanelKind.Table, {
name: 'Calls table',
}),
[COMPACT_PANELS.list]: panel(PanelKind.List, {
name: 'Recent logs',
pluginSpec: {
selectFields: [
{ name: 'timestamp', signal: 'logs' },
{ name: 'body', signal: 'logs' },
],
},
query: rawQuery({ signal: 'logs' }),
}),
},
},
],
});
}
// ─── Single panel ────────────────────────────────────────────────────────
export const SINGLE_PANEL_ID = 'solo-panel';
export interface SinglePanelOptions {
title?: string;
kind?: PanelKind;
panelName?: string;
/** Plugin config (legend, axes, formatting, thresholds, …) for the panel. */
pluginSpec?: PanelPluginSpec;
/** Replaces the kind's default query. */
query?: Query;
/** Context links attached to the panel. */
links?: ContextLink[];
}
/**
* One panel, one section. Fully configurable so specs never reach into the
* returned object — post-hoc mutation is four levels deep and breaks silently
* when the spec shape moves.
*/
export function singlePanelDashboard(
options: SinglePanelOptions = {},
): PostableDashboardV2 {
return dashboardV2({
title: options.title ?? `v2-single-${Date.now()}-${uniqueSuffix()}`,
sections: [
{
title: 'Section',
panels: {
[SINGLE_PANEL_ID]: panel(options.kind ?? PanelKind.TimeSeries, {
name: options.panelName ?? 'Solo panel',
...(options.pluginSpec ? { pluginSpec: options.pluginSpec } : {}),
...(options.query ? { query: options.query } : {}),
...(options.links ? { links: options.links } : {}),
}),
},
},
],
});
}
/** No panels at all — drives the dashboard empty state and its New Panel CTA. */
export function emptyDashboard(title?: string): PostableDashboardV2 {
return dashboardV2({
title: title ?? `v2-empty-${Date.now()}-${uniqueSuffix()}`,
sections: [],
});
}
// ─── Query-type coverage ─────────────────────────────────────────────────
export const QUERY_TYPE_PANELS = {
builder: 'qb-panel',
promql: 'promql-panel',
clickhouse: 'chsql-panel',
} as const;
/** One panel per query type, for the capability and drilldown-gating specs. */
export function queryTypesDashboard(title?: string): PostableDashboardV2 {
return dashboardV2({
title: title ?? `v2-query-types-${Date.now()}-${uniqueSuffix()}`,
sections: [
{
title: 'Query types',
panels: {
[QUERY_TYPE_PANELS.builder]: panel(PanelKind.TimeSeries, {
name: 'Builder query',
}),
[QUERY_TYPE_PANELS.promql]: panel(PanelKind.TimeSeries, {
name: 'PromQL query',
query: promqlQuery(`sum(rate(${GOLDEN.metrics.calls}[5m]))`),
}),
[QUERY_TYPE_PANELS.clickhouse]: panel(PanelKind.Table, {
name: 'ClickHouse query',
query: clickhouseQuery(
"SELECT now() AS ts, 'adservice' AS service, 1 AS A",
),
}),
},
},
],
});
}
// ─── Variables ───────────────────────────────────────────────────────────
export const VARIABLE_NAMES = {
custom: 'serviceCustom',
dynamic: 'serviceDynamic',
} as const;
const VARIABLES: Variable[] = [
{
kind: 'ListVariable',
spec: {
name: VARIABLE_NAMES.custom,
display: { name: VARIABLE_NAMES.custom },
allowAllValue: false,
allowMultiple: false,
sort: 'none',
plugin: {
kind: 'signoz/CustomVariable',
// Closed list: resolves instantly, no backend round-trip.
spec: { customValue: GOLDEN.services.slice(0, 3).join(',') },
},
},
},
{
kind: 'ListVariable',
spec: {
name: VARIABLE_NAMES.dynamic,
display: { name: VARIABLE_NAMES.dynamic },
allowAllValue: true,
allowMultiple: true,
sort: 'alphabetical-asc',
plugin: {
kind: 'signoz/DynamicVariable',
spec: { name: 'service.name', signal: 'metrics' },
},
},
},
];
export const VARIABLE_PANEL_ID = 'var-panel';
/** A panel filtered by a dashboard variable — backs the drilldown variable cases. */
export function variablesDashboard(
title?: string,
query?: Query,
): PostableDashboardV2 {
return dashboardV2({
title: title ?? `v2-variables-${Date.now()}-${uniqueSuffix()}`,
variables: VARIABLES,
sections: [
{
title: 'Variables',
panels: {
[VARIABLE_PANEL_ID]: panel(PanelKind.TimeSeries, {
name: 'Calls for $serviceCustom',
query:
query ??
metricsQuery({
filter: `service.name = $${VARIABLE_NAMES.custom}`,
}),
}),
},
},
],
});
}

View File

@@ -0,0 +1,184 @@
import { expect, test } from '../../../../fixtures/dashboards';
import {
PanelKind,
dashboardV2,
panel,
} from '../../../../helpers/dashboard-v2-spec';
import {
panelRenderer,
panelRoot,
waitForPanelRendered,
RENDERER_TESTID,
} from '../../../../helpers/panels-v2';
import {
QueryRange,
mockQueryRange,
mockQueryRangeError,
ramp,
} from '../../../../helpers/query-range-mock';
import {
ALL_KINDS_PANELS,
COMPACT_PANELS,
allKindsDashboard,
compactDashboard,
} from '../../../../testdata/v2/panels-dashboard';
// Scope: each kind mounts its renderer, the header shows the panel's identity,
// and lazy-fetch/status affordances behave. Interaction lives in sibling specs.
test.describe('Dashboards V2 — panel rendering', () => {
test('TC-01 every panel kind mounts its own renderer', async ({
authedPage: page,
dashboards,
}) => {
await dashboards.seedAndOpen(allKindsDashboard());
const expected: [string, PanelKind][] = [
[ALL_KINDS_PANELS.timeseries, PanelKind.TimeSeries],
[ALL_KINDS_PANELS.bar, PanelKind.BarChart],
[ALL_KINDS_PANELS.histogram, PanelKind.Histogram],
[ALL_KINDS_PANELS.pie, PanelKind.PieChart],
[ALL_KINDS_PANELS.number, PanelKind.Number],
[ALL_KINDS_PANELS.table, PanelKind.Table],
[ALL_KINDS_PANELS.list, PanelKind.List],
];
for (const [panelId, kind] of expected) {
await waitForPanelRendered(page, panelId, kind);
await expect(panelRenderer(page, panelId, kind)).toBeVisible();
}
});
test('TC-02 header shows the panel title and description tooltip', async ({
authedPage: page,
dashboards,
}) => {
await dashboards.seedAndOpen(compactDashboard());
const root = panelRoot(page, COMPACT_PANELS.timeseries);
await expect(root.getByTestId('panel-title')).toHaveText('Calls by service');
const info = root.getByTestId('panel-header-info-icon');
await expect(info).toBeVisible();
await info.hover();
await expect(
page.getByText('Rate of signoz_calls_total grouped by service.name'),
).toBeVisible();
await expect(
panelRoot(page, COMPACT_PANELS.table).getByTestId('panel-header-info-icon'),
).toHaveCount(0);
});
test('TC-03 a below-the-fold panel does not query until scrolled into view', async ({
authedPage: page,
dashboards,
}) => {
// Panels fetch lazily, so the second section stays cold on first paint.
await dashboards.seedAndOpen(allKindsDashboard());
const list = panelRoot(page, ALL_KINDS_PANELS.list);
await expect(list).toHaveAttribute('data-panel-visible', 'false');
await expect(list.getByTestId(RENDERER_TESTID[PanelKind.List])).toHaveCount(
0,
);
await list.scrollIntoViewIfNeeded();
await expect(list).toHaveAttribute('data-panel-visible', 'true');
await expect(list.getByTestId(RENDERER_TESTID[PanelKind.List])).toBeVisible();
});
test('TC-04 a query warning surfaces the warning indicator and its popover', async ({
authedPage: page,
dashboards,
}) => {
await mockQueryRange(
page,
QueryRange.withWarning(
QueryRange.timeSeries([
{ labels: { 'service.name': 'adservice' }, points: ramp(12, 1, 9) },
]),
'sampled result',
),
);
await dashboards.seedAndOpen(compactDashboard());
const root = panelRoot(page, COMPACT_PANELS.timeseries);
const warning = root.getByTestId('panel-status-warning');
await expect(warning).toBeVisible();
await expect(warning).toHaveAttribute('aria-label', 'Panel warning');
// Hover tooltip, not a popover; Radix renders the content twice, so scope
// to the tooltip to avoid strict mode.
await warning.hover();
await expect(
page.getByRole('tooltip').getByTestId('panel-status-content'),
).toContainText('sampled result');
});
test('TC-05 a failed query surfaces the error indicator alongside the error body', async ({
authedPage: page,
dashboards,
}) => {
await mockQueryRangeError(page, 'boom from the querier');
await dashboards.seedAndOpen(compactDashboard());
const root = panelRoot(page, COMPACT_PANELS.timeseries);
await expect(root.getByTestId('panel-status-error')).toBeVisible();
await expect(root.getByTestId('panel-error')).toBeVisible();
});
test('TC-06 the time-preference pill reflects a panel-scoped window', async ({
authedPage: page,
dashboards,
}) => {
// The pill only renders when a panel opts OUT of the global range, so the
// unpinned neighbour is the control.
await dashboards.seedAndOpen(
dashboardV2({
sections: [
{
title: 'Time preference',
panels: {
pinned: panel(PanelKind.TimeSeries, {
name: 'Pinned to 15m',
pluginSpec: { visualization: { timePreference: 'last_15_min' } },
}),
global: panel(PanelKind.TimeSeries, {
name: 'Follows the dashboard',
}),
},
},
],
}),
);
await expect(
panelRoot(page, 'pinned').getByTestId('panel-time-preference'),
).toBeVisible();
await expect(
panelRoot(page, 'global').getByTestId('panel-time-preference'),
).toHaveCount(0);
});
test('TC-07 the Number panel renders a value, not an empty state', async ({
authedPage: page,
dashboards,
}) => {
await mockQueryRange(
page,
QueryRange.scalar({
aggregationColumns: ['A'],
rows: [[1234]],
}),
);
await dashboards.seedAndOpen(allKindsDashboard());
const root = panelRoot(page, ALL_KINDS_PANELS.number);
await root.scrollIntoViewIfNeeded();
await expect(
panelRenderer(page, ALL_KINDS_PANELS.number, PanelKind.Number),
).toBeVisible();
await expect(root.getByTestId('number-panel-value')).toBeVisible();
await expect(root.getByTestId('number-panel-no-data')).toHaveCount(0);
});
});

View File

@@ -0,0 +1,231 @@
import { expect, test } from '../../../../fixtures/dashboards';
import {
getDashboardV2ViaApi,
gotoDashboardV2,
setDashboardLockedViaApi,
} from '../../../../helpers/dashboards-v2';
import {
PanelAction,
closePanelActions,
downloadPanelAs,
openPanelActions,
panelRoot,
runPanelAction,
} from '../../../../helpers/panels-v2';
import {
COMPACT_PANELS,
compactDashboard,
} from '../../../../testdata/v2/panels-dashboard';
// Scope: the panel ⋮ menu — which items exist per kind, how capability and the
// dashboard lock gate them, and that the mutating ones reach the spec.
//
// Items carry no testid, so everything matches role + visible label.
test.describe('Dashboards V2 — panel actions menu', () => {
test('TC-01 an editable panel offers the full action set', async ({
authedPage: page,
dashboards,
}) => {
await dashboards.seedAndOpen(compactDashboard());
await openPanelActions(page, COMPACT_PANELS.timeseries);
for (const label of [
PanelAction.view,
PanelAction.edit,
PanelAction.clone,
PanelAction.download,
PanelAction.createAlert,
PanelAction.move,
PanelAction.delete,
]) {
await expect(
page.getByRole('menuitem', { name: label, exact: true }),
).toBeVisible();
}
});
test('TC-02 Download offers CSV only on Table', async ({
authedPage: page,
dashboards,
}) => {
await dashboards.seedAndOpen(compactDashboard());
// Table declares `csv: true`; others expose PNG/SVG only.
await openPanelActions(page, COMPACT_PANELS.table);
await page
.getByRole('menuitem', { name: PanelAction.download, exact: true })
.hover();
await expect(
page.getByRole('menuitem', { name: PanelAction.downloadCsv, exact: true }),
).toBeVisible();
await closePanelActions(page);
await openPanelActions(page, COMPACT_PANELS.timeseries);
await page
.getByRole('menuitem', { name: PanelAction.download, exact: true })
.hover();
await expect(
page.getByRole('menuitem', { name: PanelAction.downloadPng, exact: true }),
).toBeVisible();
await expect(
page.getByRole('menuitem', { name: PanelAction.downloadCsv, exact: true }),
).toHaveCount(0);
});
test('TC-03 Create Alerts is hidden for kinds that do not declare it', async ({
authedPage: page,
dashboards,
}) => {
await dashboards.seedAndOpen(compactDashboard());
await openPanelActions(page, COMPACT_PANELS.timeseries);
await expect(
page.getByRole('menuitem', { name: PanelAction.createAlert, exact: true }),
).toBeVisible();
await closePanelActions(page);
for (const panelId of [COMPACT_PANELS.table, COMPACT_PANELS.list]) {
await openPanelActions(page, panelId);
await expect(
page.getByRole('menuitem', {
name: PanelAction.createAlert,
exact: true,
}),
).toHaveCount(0);
await closePanelActions(page);
}
});
test('TC-04 Create Alerts opens the alert builder in a new tab', async ({
authedPage: page,
dashboards,
}) => {
await dashboards.seedAndOpen(compactDashboard());
const popup = page.context().waitForEvent('page');
await runPanelAction(
page,
COMPACT_PANELS.timeseries,
PanelAction.createAlert,
);
const alertTab = await popup;
await expect(alertTab).toHaveURL(/\/alerts\/new/);
await alertTab.close();
});
// Chromium-only: headless Firefox/WebKit don't surface the canvas blob as a
// Playwright download event. CSV and SVG are unaffected.
test('TC-05 Download as PNG produces a file', async ({
authedPage: page,
dashboards,
browserName,
}) => {
test.skip(
browserName !== 'chromium',
'headless Firefox/WebKit do not emit a download event for the canvas blob',
);
await dashboards.seedAndOpen(compactDashboard());
await panelRoot(page, COMPACT_PANELS.timeseries).scrollIntoViewIfNeeded();
const download = page.waitForEvent('download');
await downloadPanelAs(page, COMPACT_PANELS.timeseries, 'PNG');
const file = await download;
expect(file.suggestedFilename()).toMatch(/\.png$/);
});
test('TC-06 Clone adds a second panel and persists it', async ({
authedPage: page,
dashboards,
}) => {
const id = await dashboards.seedAndOpen(compactDashboard());
const before = await getDashboardV2ViaApi(page, id);
const beforeCount = Object.keys(before.spec.panels).length;
await runPanelAction(page, COMPACT_PANELS.timeseries, PanelAction.clone);
await expect
.poll(async () => {
const after = await getDashboardV2ViaApi(page, id);
return Object.keys(after.spec.panels).length;
})
.toBe(beforeCount + 1);
});
test('TC-07 Delete panel removes the panel and its layout item', async ({
authedPage: page,
dashboards,
}) => {
const id = await dashboards.seedAndOpen(compactDashboard());
await runPanelAction(page, COMPACT_PANELS.list, PanelAction.delete);
await expect(page.getByText('Delete panel?')).toBeVisible();
await page.getByTestId('confirm-delete').click();
await expect(panelRoot(page, COMPACT_PANELS.list)).toHaveCount(0);
// Optimistic: the panel leaves the DOM before the PATCH lands.
await expect
.poll(async () => {
const after = await getDashboardV2ViaApi(page, id);
return after.spec.panels[COMPACT_PANELS.list];
})
.toBeUndefined();
// The grid item must go too, or a dangling $ref renders an empty tile.
const after = await getDashboardV2ViaApi(page, id);
const refs = after.spec.layouts.flatMap((layout) =>
layout.spec.items.map((item) => item.content.$ref),
);
expect(refs).not.toContain(`#/spec/panels/${COMPACT_PANELS.list}`);
});
test('TC-08 Move to section relocates the panel between sections', async ({
authedPage: page,
dashboards,
}) => {
const id = await dashboards.seedAndOpen(compactDashboard());
await openPanelActions(page, COMPACT_PANELS.timeseries);
await page
.getByRole('menuitem', { name: PanelAction.move, exact: true })
.hover();
await page.getByRole('menuitem', { name: 'Tabular', exact: true }).click();
await expect
.poll(async () => {
const after = await getDashboardV2ViaApi(page, id);
const target = after.spec.layouts.find(
(layout) => layout.spec.display.title === 'Tabular',
);
return target?.spec.items.some(
(item) =>
item.content.$ref === `#/spec/panels/${COMPACT_PANELS.timeseries}`,
);
})
.toBe(true);
});
test('TC-09 a locked dashboard disables the mutating actions', async ({
authedPage: page,
dashboards,
}) => {
const id = await dashboards.seed(compactDashboard());
await setDashboardLockedViaApi(page, id, true);
await gotoDashboardV2(page, id);
await openPanelActions(page, COMPACT_PANELS.timeseries);
// View and Download don't mutate, so they stay available.
await expect(
page.getByRole('menuitem', { name: PanelAction.view, exact: true }),
).toBeEnabled();
for (const label of [PanelAction.edit, PanelAction.clone]) {
await expect(
page.getByRole('menuitem', { name: label, exact: true }),
).toBeDisabled();
}
});
});

View File

@@ -0,0 +1,147 @@
import { expect, test } from '../../../../fixtures/dashboards';
import { PanelMessageText, panelRoot } from '../../../../helpers/panels-v2';
import {
QueryRange,
mockQueryRange,
mockQueryRangeError,
ramp,
unmockQueryRange,
} from '../../../../helpers/query-range-mock';
import {
SINGLE_PANEL_ID,
singlePanelDashboard,
} from '../../../../testdata/v2/panels-dashboard';
// Scope: the PanelBody states and their recovery affordances.
//
// Mock-only: a healthy stack returns data, so no-data/error/warning are
// unreachable live. `panel-no-query` is not covered — the backend requires one
// VALID query per panel, so a saved dashboard can't contain a query-less one.
test.describe('Dashboards V2 — panel states', () => {
test('TC-02 a failing query shows the error state and Retry refetches', async ({
authedPage: page,
dashboards,
}) => {
// Fail every call, then swap to success before Retry: react-query retries
// on its own, so "fail once then succeed" never surfaces the error state.
await mockQueryRangeError(page, 'querier exploded');
await dashboards.seedAndOpen(singlePanelDashboard());
const root = panelRoot(page, SINGLE_PANEL_ID);
const error = root.getByTestId('panel-error');
await expect(error).toBeVisible();
await expect(root.getByText(PanelMessageText.errorTitle)).toBeVisible();
await expect(root.getByText('querier exploded')).toBeVisible();
await unmockQueryRange(page);
await mockQueryRange(
page,
QueryRange.timeSeries([
{ labels: { 'service.name': 'adservice' }, points: ramp(12, 1, 9) },
]),
);
// Retry must re-issue the query.
await root.getByTestId('panel-error-action').click();
await expect(error).toHaveCount(0);
await expect(root.getByTestId('time-series-renderer')).toBeVisible();
});
test('TC-03 an empty result offers Extend time range as primary and Retry as secondary', async ({
authedPage: page,
dashboards,
}) => {
await mockQueryRange(page, QueryRange.empty());
await dashboards.seedAndOpen(singlePanelDashboard());
const root = panelRoot(page, SINGLE_PANEL_ID);
await expect(root.getByTestId('panel-no-data')).toBeVisible();
await expect(root.getByText(PanelMessageText.noDataTitle)).toBeVisible();
// Widenable window: Extend primary, Retry secondary.
await expect(root.getByTestId('panel-no-data-action')).toHaveText(
PanelMessageText.extendAction,
);
await expect(root.getByTestId('panel-no-data-secondary-action')).toHaveText(
PanelMessageText.retryAction,
);
});
test('TC-04 Extend time range widens the dashboard window', async ({
authedPage: page,
dashboards,
}) => {
await mockQueryRange(page, QueryRange.empty());
await dashboards.seedAndOpen(singlePanelDashboard());
const root = panelRoot(page, SINGLE_PANEL_ID);
const extend = root.getByTestId('panel-no-data-action');
await expect(extend).toBeVisible();
const refetch = page.waitForRequest((r) => r.url().includes('/query_range'));
await extend.click();
await refetch;
// Widening walks the URL-backed zoom-out ladder.
await expect
.poll(() => new URL(page.url()).searchParams.get('relativeTime'))
.not.toBeNull();
});
test('TC-05 a panel pinned to a fixed window cannot extend, so Retry is primary', async ({
authedPage: page,
dashboards,
}) => {
await mockQueryRange(page, QueryRange.empty());
// A panel-scoped window means extend has nothing to widen.
await dashboards.seedAndOpen(
singlePanelDashboard({
pluginSpec: { visualization: { timePreference: 'last_15_min' } },
}),
);
const root = panelRoot(page, SINGLE_PANEL_ID);
await expect(root.getByTestId('panel-no-data')).toBeVisible();
await expect(root.getByTestId('panel-no-data-action')).toHaveText(
PanelMessageText.retryAction,
);
await expect(root.getByTestId('panel-no-data-secondary-action')).toHaveCount(
0,
);
});
test('TC-06 an in-flight first query shows the loading state', async ({
authedPage: page,
dashboards,
}) => {
// LOADING, not `panel-refetching` — the refetch spinner needs prior data.
let release: () => void = () => undefined;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
await page.route('**/api/v5/query_range', async (route) => {
await gate;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(
QueryRange.timeSeries([
{ labels: { 'service.name': 'adservice' }, points: ramp(12, 1, 9) },
]),
),
});
});
await dashboards.seedAndOpen(singlePanelDashboard());
const root = panelRoot(page, SINGLE_PANEL_ID);
await expect(root.getByTestId('panel-loading')).toBeVisible();
release();
await expect(root.getByTestId('time-series-renderer')).toBeVisible();
await expect(root.getByTestId('panel-loading')).toHaveCount(0);
});
});

View File

@@ -0,0 +1,195 @@
import type { Page } from '@playwright/test';
import { expect, test } from '../../../../fixtures/dashboards';
import {
boundingBoxOf,
dragHorizontally,
openViewModal,
panelPlotArea,
panelRoot,
} from '../../../../helpers/panels-v2';
import {
QueryRange,
mockQueryRange,
ramp,
} from '../../../../helpers/query-range-mock';
import { uplotState } from '../../../../helpers/uplot';
import {
COMPACT_PANELS,
SINGLE_PANEL_ID,
compactDashboard,
singlePanelDashboard,
} from '../../../../testdata/v2/panels-dashboard';
// Scope: brush-select zoom and the window it writes to.
//
// Mocked, not golden-backed: these need a rendered CANVAS to drag across, and a
// query issued before ClickHouse has the re-seeded rows queryable returns empty
// — "No Data", no canvas, intermittent timeout. Nothing here asserts a value.
/** Fractions are of the plot area's own box, so panel size doesn't matter. */
async function dragAcross(
page: Page,
panelId: string,
fromFraction: number,
toFraction: number,
): Promise<void> {
// Plot area, not the container: container fractions drift onto the axis.
const box = await boundingBoxOf(
panelPlotArea(page, panelId),
`plot area of panel ${panelId}`,
);
await dragHorizontally(page, box, fromFraction, toFraction);
}
/**
* WebKit only, for the tests that need a drag to actually ZOOM.
*
* Mid-drag under WebKit the live uPlot instance shows `cursor.left` updating
* (mousemove arrives) but `cursor.drag._x === false` and `select.width === 0` —
* the synthetic mousedown never starts a drag. Chromium and Firefox zoom fine,
* so this looks like input synthesis rather than the app, though unproven.
* TC-03/TC-05 still run here: they assert NO zoom, which stays meaningful.
*/
function skipDragZoomOnWebkit(browserName: string): void {
test.skip(
browserName === 'webkit',
'WebKit: synthetic mousedown does not start a uPlot drag (cursor.drag._x stays false)',
);
}
const ZOOM_SERIES = [
{ labels: { 'service.name': 'adservice' }, points: ramp(24, 2, 9) },
];
// 24 points × 5min ≈ 2h; a zoom must land strictly inside that.
const SERIES_SPAN_MS = 24 * 5 * 60_000;
test.describe('Dashboards V2 — panel zoom', () => {
test('TC-01 brush-select writes an absolute window into the URL', async ({
authedPage: page,
dashboards,
browserName,
}) => {
skipDragZoomOnWebkit(browserName);
await mockQueryRange(page, QueryRange.timeSeries(ZOOM_SERIES));
await dashboards.seedAndOpen(singlePanelDashboard());
await expect(
panelRoot(page, SINGLE_PANEL_ID).getByTestId('time-series-renderer'),
).toBeVisible();
await dragAcross(page, SINGLE_PANEL_ID, 0.3, 0.7);
// An absolute range in the URL is what makes the zoom shareable.
await expect
.poll(() => {
const params = new URL(page.url()).searchParams;
return Boolean(params.get('startTime') && params.get('endTime'));
})
.toBe(true);
const params = new URL(page.url()).searchParams;
expect(Number(params.get('endTime'))).toBeGreaterThan(
Number(params.get('startTime')),
);
// The chart must actually show the narrower window, not just the URL.
const zoomed = await uplotState(page, SINGLE_PANEL_ID);
const span = (zoomed.scales.x.max ?? 0) - (zoomed.scales.x.min ?? 0);
expect(span).toBeGreaterThan(0);
expect(span).toBeLessThan(SERIES_SPAN_MS);
});
test('TC-02 zooming one panel refetches every panel on the dashboard', async ({
authedPage: page,
dashboards,
browserName,
}) => {
skipDragZoomOnWebkit(browserName);
await mockQueryRange(page, QueryRange.timeSeries(ZOOM_SERIES));
await dashboards.seedAndOpen(compactDashboard());
await expect(
panelRoot(page, COMPACT_PANELS.timeseries).getByTestId(
'time-series-renderer',
),
).toBeVisible();
// Zoom updates the GLOBAL interval, so siblings must re-query.
const queries: string[] = [];
page.on('request', (request) => {
if (request.url().includes('/query_range')) {
queries.push(request.url());
}
});
await dragAcross(page, COMPACT_PANELS.timeseries, 0.25, 0.75);
await expect.poll(() => queries.length).toBeGreaterThan(1);
});
test('TC-03 a zero-width drag does not zoom', async ({
authedPage: page,
dashboards,
}) => {
await mockQueryRange(page, QueryRange.timeSeries(ZOOM_SERIES));
await dashboards.seedAndOpen(singlePanelDashboard());
await expect(
panelRoot(page, SINGLE_PANEL_ID).getByTestId('time-series-renderer'),
).toBeVisible();
// A width-0 selection is dropped, so click-to-drilldown never zooms.
await dragAcross(page, SINGLE_PANEL_ID, 0.5, 0.5);
const params = new URL(page.url()).searchParams;
expect(params.get('startTime')).toBeNull();
expect(params.get('endTime')).toBeNull();
});
test('TC-04 the global Zoom out button widens the range again', async ({
authedPage: page,
dashboards,
browserName,
}) => {
skipDragZoomOnWebkit(browserName);
await mockQueryRange(page, QueryRange.timeSeries(ZOOM_SERIES));
await dashboards.seedAndOpen(singlePanelDashboard());
await expect(
panelRoot(page, SINGLE_PANEL_ID).getByTestId('time-series-renderer'),
).toBeVisible();
await dragAcross(page, SINGLE_PANEL_ID, 0.35, 0.65);
await expect
.poll(() => new URL(page.url()).searchParams.get('startTime'))
.not.toBeNull();
const zoomedStart = Number(new URL(page.url()).searchParams.get('startTime'));
// No per-panel reset — zoom-out is global only.
const zoomOut = page.getByTestId('zoom-out-btn');
await expect(zoomOut).toBeVisible();
await zoomOut.click();
await expect
.poll(() => Number(new URL(page.url()).searchParams.get('startTime')))
.toBeLessThan(zoomedStart);
});
test('TC-05 zooming inside the View modal leaves the dashboard window alone', async ({
authedPage: page,
dashboards,
}) => {
await mockQueryRange(page, QueryRange.timeSeries(ZOOM_SERIES));
await dashboards.seedAndOpen(singlePanelDashboard());
const modal = await openViewModal(page, SINGLE_PANEL_ID);
const chart = modal.getByTestId('uplot-main-div').locator('.u-over');
await expect(chart).toBeVisible();
const box = await boundingBoxOf(chart, 'the modal plot area');
await dragHorizontally(page, box, 0.3, 0.7);
// The modal keeps its own window.
const params = new URL(page.url()).searchParams;
expect(params.get('startTime')).toBeNull();
expect(params.get('endTime')).toBeNull();
});
});

View File

@@ -0,0 +1,238 @@
import type { Page } from '@playwright/test';
import { expect, test, type SeedApi } from '../../../../fixtures/dashboards';
import {
boundingBoxOf,
panelChart,
panelRoot,
} from '../../../../helpers/panels-v2';
import {
QueryRange,
mockQueryRange,
ramp,
type SeriesSpec,
} from '../../../../helpers/query-range-mock';
import { uplotState } from '../../../../helpers/uplot';
import {
SINGLE_PANEL_ID,
singlePanelDashboard,
} from '../../../../testdata/v2/panels-dashboard';
// Scope: legend show/hide/solo semantics, legend search, and the tooltip.
//
// Three things shape the assertions:
// 1. Mocked — the golden series set shifts with the rolling window.
// 2. The legend is a virtualized VirtuosoGrid, so only in-view items exist in
// the DOM; every assertion targets a named series, never a total count.
// 3. The click targets do the OPPOSITE of their names (PlotContext.tsx): the
// item BODY solos (hides all others), the MARKER is the per-series on/off.
//
// Handles are `data-legend-item-id` and `data-is-legend-marker` — what
// `useLegendActions` itself branches on.
const SERIES: SeriesSpec[] = [
{ labels: { 'service.name': 'adservice' }, points: ramp(12, 1, 9) },
{ labels: { 'service.name': 'cartservice' }, points: ramp(12, 3, 6) },
{ labels: { 'service.name': 'frontend' }, points: ramp(12, 5, 2) },
];
async function seedThreeSeriesPanel(
page: Page,
dashboards: SeedApi,
legendPosition?: 'bottom' | 'right',
): Promise<string> {
await mockQueryRange(page, QueryRange.timeSeries(SERIES));
const id = await dashboards.seedAndOpen(
singlePanelDashboard(
legendPosition
? { pluginSpec: { legend: { position: legendPosition } } }
: {},
),
);
await expect(
panelRoot(page, SINGLE_PANEL_ID).getByTestId('time-series-renderer'),
).toBeVisible();
return id;
}
test.describe('Dashboards V2 — legend and tooltip', () => {
test('TC-01 the legend renders an item per series label', async ({
authedPage: page,
dashboards,
}) => {
await seedThreeSeriesPanel(page, dashboards);
const root = panelRoot(page, SINGLE_PANEL_ID);
// Virtualized — assert named series, not a total.
for (const label of ['adservice', 'cartservice']) {
await expect(
root.locator('[data-legend-item-id]').filter({ hasText: label }),
).toHaveCount(1);
}
});
test('TC-02 clicking an item body solos that series', async ({
authedPage: page,
dashboards,
}) => {
await seedThreeSeriesPanel(page, dashboards);
const root = panelRoot(page, SINGLE_PANEL_ID);
const soloed = root
.locator('[data-legend-item-id]')
.filter({ hasText: 'cartservice' });
const other = root
.locator('[data-legend-item-id]')
.filter({ hasText: 'adservice' });
// Body click => solo.
await soloed.locator('.legend-label').click();
await expect(other).toHaveClass(/legend-item-off/);
await expect(soloed).not.toHaveClass(/legend-item-off/);
// Confirm the CHART hid it, not just the legend entry. uPlot labels are the
// full legend string, so match on the series name inside.
await expect
.poll(async () => {
const { series } = await uplotState(page, SINGLE_PANEL_ID);
return series
.filter((entry) => entry.show)
.map((entry) => entry.label ?? '')
.filter((label) => label.includes('cartservice')).length;
})
.toBe(1);
await expect
.poll(async () => {
const { series } = await uplotState(page, SINGLE_PANEL_ID);
return series.filter((entry) => entry.show).length;
})
.toBe(1);
});
test('TC-03 re-clicking the soloed item restores every series', async ({
authedPage: page,
dashboards,
}) => {
await seedThreeSeriesPanel(page, dashboards);
const root = panelRoot(page, SINGLE_PANEL_ID);
const soloed = root
.locator('[data-legend-item-id]')
.filter({ hasText: 'cartservice' });
const other = root
.locator('[data-legend-item-id]')
.filter({ hasText: 'adservice' });
await soloed.locator('.legend-label').click();
await expect(other).toHaveClass(/legend-item-off/);
// Re-soloing the active series resets.
await soloed.locator('.legend-label').click();
await expect(other).not.toHaveClass(/legend-item-off/);
});
test('TC-03b clicking the marker toggles just that series off', async ({
authedPage: page,
dashboards,
}) => {
await seedThreeSeriesPanel(page, dashboards);
const root = panelRoot(page, SINGLE_PANEL_ID);
const target = root
.locator('[data-legend-item-id]')
.filter({ hasText: 'adservice' });
const other = root
.locator('[data-legend-item-id]')
.filter({ hasText: 'cartservice' });
// Marker => per-series toggle, neighbours untouched.
await target.locator('[data-is-legend-marker]').click();
await expect(target).toHaveClass(/legend-item-off/);
await expect(other).not.toHaveClass(/legend-item-off/);
// In the chart: exactly the clicked series is hidden.
await expect
.poll(async () => {
const { series } = await uplotState(page, SINGLE_PANEL_ID);
const hidden = series.filter((entry) => !entry.show);
return {
count: hidden.length,
isAdservice: hidden[0]?.label?.includes('adservice') ?? false,
};
})
.toEqual({ count: 1, isAdservice: true });
});
test('TC-04 the legend search filters items when positioned right', async ({
authedPage: page,
dashboards,
}) => {
// Search only renders for a right-positioned legend.
await seedThreeSeriesPanel(page, dashboards, 'right');
const root = panelRoot(page, SINGLE_PANEL_ID);
const search = root.getByTestId('legend-search-input');
await expect(search).toBeVisible();
await search.fill('cart');
await expect(root.locator('[data-legend-item-id]')).toHaveCount(1);
await search.fill('nothing-matches-this');
await expect(root.locator('[data-legend-item-id]')).toHaveCount(0);
await expect(
root.getByText('No series found matching "nothing-matches-this"'),
).toBeVisible();
});
test('TC-05 a bottom legend has no search box', async ({
authedPage: page,
dashboards,
}) => {
await seedThreeSeriesPanel(page, dashboards, 'bottom');
await expect(
panelRoot(page, SINGLE_PANEL_ID).getByTestId('legend-search-input'),
).toHaveCount(0);
});
test('TC-06 hovering the chart opens the tooltip', async ({
authedPage: page,
dashboards,
}) => {
await seedThreeSeriesPanel(page, dashboards);
const box = await boundingBoxOf(
panelChart(page, SINGLE_PANEL_ID),
'the chart',
);
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await expect(page.getByTestId('uplot-tooltip-container')).toBeVisible();
await expect(page.getByTestId('uplot-tooltip-list')).toBeVisible();
});
// Pin key is 'p' (DEFAULT_PIN_TOOLTIP_KEY); its JSDoc still claims 'l'.
test('TC-07 pressing P pins the tooltip and unpins it again', async ({
authedPage: page,
dashboards,
}) => {
await seedThreeSeriesPanel(page, dashboards);
const box = await boundingBoxOf(
panelChart(page, SINGLE_PANEL_ID),
'the chart',
);
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await expect(page.getByTestId('uplot-tooltip-container')).toBeVisible();
await page.keyboard.press('p');
const unpin = page.getByTestId('uplot-tooltip-unpin');
await expect(unpin).toBeVisible();
// A pinned tooltip survives the pointer leaving the plot.
await page.mouse.move(box.x - 20, box.y - 20);
await expect(page.getByTestId('uplot-tooltip-container')).toBeVisible();
await unpin.click();
await expect(page.getByTestId('uplot-tooltip-unpin')).toHaveCount(0);
});
});

View File

@@ -0,0 +1,218 @@
import { expect, test } from '../../../../fixtures/dashboards';
import { PanelKind } from '../../../../helpers/dashboard-v2-spec';
import {
boundingBoxOf,
listPager,
panelRoot,
searchInPanel,
} from '../../../../helpers/panels-v2';
import {
QueryRange,
mockQueryRange,
mockQueryRangeSequence,
} from '../../../../helpers/query-range-mock';
import {
COMPACT_PANELS,
SINGLE_PANEL_ID,
singlePanelDashboard,
compactDashboard,
} from '../../../../testdata/v2/panels-dashboard';
// Scope: the tabular-only controls — header search, column resize, and List's
// server-side pager. Paging is mocked (it needs a stable row set).
// Next only enables when a response FILLS the page, so page one must be full.
const PAGE_SIZE = 25;
const LOG_ROWS = Array.from({ length: PAGE_SIZE }, (_, i) => ({
timestamp: new Date(Date.UTC(2026, 0, 1, 0, i)).toISOString(),
body: `page-one line ${i}`,
'service.name': 'adservice',
}));
const LOG_ROWS_PAGE_TWO = Array.from({ length: 6 }, (_, i) => ({
timestamp: new Date(Date.UTC(2026, 0, 1, 1, i)).toISOString(),
body: `page-two line ${i}`,
'service.name': 'cartservice',
}));
test.describe('Dashboards V2 — table and list controls', () => {
test('TC-01 header search filters the table and can be cleared', async ({
authedPage: page,
dashboards,
}) => {
await mockQueryRange(
page,
QueryRange.scalar({
groupColumns: ['service.name'],
aggregationColumns: ['A'],
rows: [
['adservice', 10],
['cartservice', 20],
['frontend', 30],
],
}),
);
await dashboards.seedAndOpen(compactDashboard());
const root = panelRoot(page, COMPACT_PANELS.table);
await root.scrollIntoViewIfNeeded();
await expect(root.getByTestId('table-panel-renderer')).toBeVisible();
const rows = root.locator('tbody tr.ant-table-row');
await expect(rows).toHaveCount(3);
await searchInPanel(page, COMPACT_PANELS.table, 'cart');
await expect(rows).toHaveCount(1);
await root.getByTestId('panel-header-search-clear').click();
await expect(rows).toHaveCount(3);
});
test('TC-02 Escape closes the search box', async ({
authedPage: page,
dashboards,
}) => {
await dashboards.seedAndOpen(compactDashboard());
const root = panelRoot(page, COMPACT_PANELS.table);
await root.scrollIntoViewIfNeeded();
await searchInPanel(page, COMPACT_PANELS.table, 'cart');
await page.keyboard.press('Escape');
await expect(root.getByTestId('panel-header-search-input')).toHaveCount(0);
});
test('TC-03 search is not offered on kinds that do not declare it', async ({
authedPage: page,
dashboards,
}) => {
await dashboards.seedAndOpen(compactDashboard());
const chart = panelRoot(page, COMPACT_PANELS.timeseries);
await chart.hover();
await expect(chart.getByTestId('panel-header-search-trigger')).toHaveCount(0);
});
test('TC-04 a resized column persists across a reload', async ({
authedPage: page,
dashboards,
}) => {
await mockQueryRange(
page,
QueryRange.scalar({
groupColumns: ['service.name'],
aggregationColumns: ['A'],
rows: [['adservice', 10]],
}),
);
await dashboards.seedAndOpen(singlePanelDashboard({ kind: PanelKind.Table }));
const root = panelRoot(page, SINGLE_PANEL_ID);
await expect(root.getByTestId('table-panel-renderer')).toBeVisible();
const header = root.locator('th').filter({ hasText: 'service.name' });
const before = (await header.boundingBox())?.width ?? 0;
expect(before).toBeGreaterThan(0);
const gripBox = await boundingBoxOf(
root.getByTestId('column-resize-service.name'),
'the resize grip',
);
await page.mouse.move(
gripBox.x + gripBox.width / 2,
gripBox.y + gripBox.height / 2,
);
await page.mouse.down();
await page.mouse.move(gripBox.x + 120, gripBox.y + gripBox.height / 2, {
steps: 10,
});
await page.mouse.up();
await expect
.poll(async () => (await header.boundingBox())?.width ?? 0)
.toBeGreaterThan(before);
// Widths persist behind a 400ms debounce; reloading before it flushes drops
// the write and looks exactly like a persistence bug.
await expect
.poll(async () =>
page.evaluate((panelId) => {
const raw = localStorage.getItem('DASHBOARD_V2_PANEL_COLUMN_WIDTHS');
const widths = raw ? JSON.parse(raw) : {};
return widths?.[panelId]?.['service.name'] ?? 0;
}, SINGLE_PANEL_ID),
)
.toBeGreaterThan(before);
const widened = (await header.boundingBox())?.width ?? 0;
await page.reload();
await expect(root.getByTestId('table-panel-renderer')).toBeVisible();
await expect
.poll(async () => (await header.boundingBox())?.width ?? 0)
.toBeCloseTo(widened, -1);
});
test('TC-05 the List pager advances and re-queries', async ({
authedPage: page,
dashboards,
}) => {
await mockQueryRangeSequence(page, [
QueryRange.raw(LOG_ROWS),
QueryRange.raw(LOG_ROWS_PAGE_TWO),
]);
await dashboards.seedAndOpen(singlePanelDashboard({ kind: PanelKind.List }));
const root = panelRoot(page, SINGLE_PANEL_ID);
await root.scrollIntoViewIfNeeded();
await expect(root.getByTestId('list-panel-renderer')).toBeVisible();
await expect(listPager.page(page, SINGLE_PANEL_ID)).toHaveText('Page 1');
await expect(root.getByText('page-one line 0')).toBeVisible();
// Server-side: Next must issue a new query.
const nextQuery = page.waitForRequest((r) =>
r.url().includes('/query_range'),
);
await listPager.next(page, SINGLE_PANEL_ID).click();
await nextQuery;
await expect(listPager.page(page, SINGLE_PANEL_ID)).toHaveText('Page 2');
await expect(root.getByText('page-two line 0')).toBeVisible();
});
test('TC-06 Previous is disabled on the first page', async ({
authedPage: page,
dashboards,
}) => {
await mockQueryRange(page, QueryRange.raw(LOG_ROWS));
await dashboards.seedAndOpen(singlePanelDashboard({ kind: PanelKind.List }));
const root = panelRoot(page, SINGLE_PANEL_ID);
await root.scrollIntoViewIfNeeded();
await expect(root.getByTestId('list-panel-renderer')).toBeVisible();
await expect(listPager.prev(page, SINGLE_PANEL_ID)).toBeDisabled();
});
test('TC-07 changing the page size re-queries', async ({
authedPage: page,
dashboards,
}) => {
await mockQueryRange(page, QueryRange.raw(LOG_ROWS));
await dashboards.seedAndOpen(singlePanelDashboard({ kind: PanelKind.List }));
const root = panelRoot(page, SINGLE_PANEL_ID);
await root.scrollIntoViewIfNeeded();
await expect(root.getByTestId('list-panel-renderer')).toBeVisible();
const resize = page.waitForRequest((r) => r.url().includes('/query_range'));
await listPager.pageSize(page, SINGLE_PANEL_ID).click();
await page
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)')
.getByText('50 / page')
.click();
await resize;
// Page size resets to page 1.
await expect(listPager.page(page, SINGLE_PANEL_ID)).toHaveText('Page 1');
});
});

View File

@@ -0,0 +1,259 @@
import type { Page } from '@playwright/test';
import { expect, test, type SeedApi } from '../../../../fixtures/dashboards';
import { PanelKind, metricsQuery } from '../../../../helpers/dashboard-v2-spec';
import { getDashboardV2ViaApi } from '../../../../helpers/dashboards-v2';
import {
boundingBoxOf,
contextMenu,
drilldownItem,
panelChart,
panelRoot,
} from '../../../../helpers/panels-v2';
import {
QueryRange,
mockQueryRange,
ramp,
} from '../../../../helpers/query-range-mock';
import {
QUERY_TYPE_PANELS,
SINGLE_PANEL_ID,
VARIABLE_NAMES,
VARIABLE_PANEL_ID,
queryTypesDashboard,
singlePanelDashboard,
variablesDashboard,
} from '../../../../testdata/v2/panels-dashboard';
// Scope: the drilldown ContextMenu — items, navigation, and the kinds/query
// types deliberately excluded. Mocked so a click lands on a known series.
async function openChartDrilldown(page: Page, panelId: string): Promise<void> {
const box = await boundingBoxOf(panelChart(page, panelId), 'the chart');
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
await expect(contextMenu(page)).toBeVisible();
}
async function seedChartPanel(
page: Page,
dashboards: SeedApi,
): Promise<string> {
await mockQueryRange(
page,
QueryRange.timeSeries([
{ labels: { 'service.name': 'adservice' }, points: ramp(12, 2, 8) },
]),
);
const id = await dashboards.seedAndOpen(singlePanelDashboard());
await expect(
panelRoot(page, SINGLE_PANEL_ID).getByTestId('time-series-renderer'),
).toBeVisible();
return id;
}
test.describe('Dashboards V2 — panel drilldown', () => {
test('TC-01 clicking a series opens the aggregate menu', async ({
authedPage: page,
dashboards,
}) => {
await seedChartPanel(page, dashboards);
await openChartDrilldown(page, SINGLE_PANEL_ID);
await expect(drilldownItem(page, 'drilldown-view-logs')).toBeVisible();
await expect(drilldownItem(page, 'drilldown-view-traces')).toBeVisible();
await expect(drilldownItem(page, 'drilldown-breakout')).toBeVisible();
});
test('TC-02 clicking the backdrop closes the menu', async ({
authedPage: page,
dashboards,
}) => {
await seedChartPanel(page, dashboards);
await openChartDrilldown(page, SINGLE_PANEL_ID);
// Backdrop click. Escape only works when the backdrop holds focus.
await page.locator('.context-menu-backdrop').click();
await expect(contextMenu(page)).toHaveCount(0);
});
test('TC-03 View in Logs navigates to the logs explorer', async ({
authedPage: page,
dashboards,
}) => {
await seedChartPanel(page, dashboards);
await openChartDrilldown(page, SINGLE_PANEL_ID);
// Disabled while the drilldown query resolves.
const viewLogs = drilldownItem(page, 'drilldown-view-logs');
await expect(viewLogs).toBeEnabled();
// safeNavigate uses `{ newTab: true }`, so the dashboard stays put.
const popup = page.context().waitForEvent('page');
await viewLogs.click();
const logsTab = await popup;
await logsTab.waitForURL(/\/logs\/logs-explorer/);
// The clicked series carries across as a composite query.
expect(
new URL(logsTab.url()).searchParams.get('compositeQuery'),
).toBeTruthy();
await logsTab.close();
await expect(page).toHaveURL(/\/dashboard\//);
});
test('TC-04 Breakout opens a submenu and the back arrow returns', async ({
authedPage: page,
dashboards,
}) => {
await seedChartPanel(page, dashboards);
await openChartDrilldown(page, SINGLE_PANEL_ID);
await drilldownItem(page, 'drilldown-breakout').click();
const back = page.getByTestId('drilldown-breakout-back');
await expect(back).toBeVisible();
await expect(
page.getByPlaceholder('Search breakout options...'),
).toBeVisible();
// Back returns to the aggregate menu.
await back.click();
await expect(drilldownItem(page, 'drilldown-view-logs')).toBeVisible();
});
test('TC-05 the Dashboard Variables submenu offers set and create', async ({
authedPage: page,
dashboards,
}) => {
await mockQueryRange(
page,
QueryRange.timeSeries([
{ labels: { 'service.name': 'adservice' }, points: ramp(12, 2, 8) },
]),
);
await dashboards.seedAndOpen(variablesDashboard());
await expect(
panelRoot(page, VARIABLE_PANEL_ID).getByTestId('time-series-renderer'),
).toBeVisible();
await openChartDrilldown(page, VARIABLE_PANEL_ID);
await drilldownItem(page, 'drilldown-dashboard-variables').click();
// `service.name` is grouped-by and already has a variable, so Set is offered.
await expect(drilldownItem(page, 'drilldown-var-set')).toBeVisible();
await expect(page.getByTestId('drilldown-var-back')).toBeVisible();
});
test('TC-06 setting a variable from the menu updates the variables bar', async ({
authedPage: page,
dashboards,
}) => {
await mockQueryRange(
page,
QueryRange.timeSeries([
{ labels: { 'service.name': 'adservice' }, points: ramp(12, 2, 8) },
]),
);
await dashboards.seedAndOpen(variablesDashboard());
await expect(
panelRoot(page, VARIABLE_PANEL_ID).getByTestId('time-series-renderer'),
).toBeVisible();
await openChartDrilldown(page, VARIABLE_PANEL_ID);
await drilldownItem(page, 'drilldown-dashboard-variables').click();
await drilldownItem(page, 'drilldown-var-set').click();
await expect(
page.getByTestId(`variable-${VARIABLE_NAMES.custom}`),
).toContainText('adservice');
});
test('TC-07 creating a variable from the menu patches the dashboard spec', async ({
authedPage: page,
dashboards,
}) => {
await mockQueryRange(
page,
QueryRange.timeSeries([
{
labels: { 'k8s.namespace.name': 'signoz-adservice' },
points: ramp(12, 2, 8),
},
]),
);
// No matching variable for this field, so the menu offers Create.
const id = await dashboards.seedAndOpen(
variablesDashboard(
undefined,
metricsQuery({ groupBy: ['k8s.namespace.name'] }),
),
);
await expect(
panelRoot(page, VARIABLE_PANEL_ID).getByTestId('time-series-renderer'),
).toBeVisible();
await openChartDrilldown(page, VARIABLE_PANEL_ID);
await drilldownItem(page, 'drilldown-dashboard-variables').click();
await drilldownItem(page, 'drilldown-var-create').click();
// Create persists a DYNAMIC variable into spec.variables.
await expect
.poll(async () => {
const after = await getDashboardV2ViaApi(page, id);
return after.spec.variables.some(
(variable) => variable.spec.name === 'k8s.namespace.name',
);
})
.toBe(true);
});
test('TC-08 kinds that do not declare drilldown open no menu', async ({
authedPage: page,
dashboards,
}) => {
await mockQueryRange(
page,
QueryRange.timeSeries([
{ labels: { 'service.name': 'adservice' }, points: ramp(12, 2, 8) },
]),
);
await dashboards.seedAndOpen(
singlePanelDashboard({ kind: PanelKind.Histogram }),
);
const root = panelRoot(page, SINGLE_PANEL_ID);
await expect(root.getByTestId('histogram-panel-renderer')).toBeVisible();
// Histogram sets `drilldown: false`.
const box = await boundingBoxOf(
root.getByTestId('uplot-main-div'),
'the histogram chart',
);
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
await expect(contextMenu(page)).toHaveCount(0);
});
test('TC-09 a non-builder query opens no menu', async ({
authedPage: page,
dashboards,
}) => {
await mockQueryRange(
page,
QueryRange.timeSeries([
{ labels: { 'service.name': 'adservice' }, points: ramp(12, 2, 8) },
]),
);
await dashboards.seedAndOpen(queryTypesDashboard());
const root = panelRoot(page, QUERY_TYPE_PANELS.promql);
await expect(root.getByTestId('time-series-renderer')).toBeVisible();
// Gated to QUERY_BUILDER queries.
const box = await boundingBoxOf(
root.getByTestId('uplot-main-div'),
'the promql chart',
);
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
await expect(contextMenu(page)).toHaveCount(0);
});
});

View File

@@ -0,0 +1,184 @@
import { expect, test } from '../../../../fixtures/dashboards';
import {
getDashboardV2ViaApi,
gotoDashboardV2,
setDashboardLockedViaApi,
} from '../../../../helpers/dashboards-v2';
import { editor } from '../../../../helpers/panel-editor-v2';
import {
PanelAction,
openViewModal,
runPanelAction,
} from '../../../../helpers/panels-v2';
import {
SINGLE_PANEL_ID,
singlePanelDashboard,
} from '../../../../testdata/v2/panels-dashboard';
// Scope: the View modal, and its two-way handoff with the panel editor.
//
// Both directions carry LIVE, unsaved state — modal → editor via router state
// (`editSpec`), editor → modal via sessionStorage + `compositeQuery`. Losing
// either silently discards in-progress work, so TC-09/TC-10 assert the carried
// state AND that nothing was persisted.
test.describe('Dashboards V2 — View modal', () => {
test('TC-01 View opens the modal and reflects it in the URL', async ({
authedPage: page,
dashboards,
}) => {
await dashboards.seedAndOpen(singlePanelDashboard());
const modal = await openViewModal(page, SINGLE_PANEL_ID);
await expect(modal).toBeVisible();
await expect(page.getByTestId('view-panel-refresh')).toBeVisible();
expect(new URL(page.url()).searchParams.get('expandedWidgetId')).toBe(
SINGLE_PANEL_ID,
);
});
test('TC-02 the modal opens directly from a deep link', async ({
authedPage: page,
dashboards,
}) => {
const id = await dashboards.seedAndOpen(singlePanelDashboard());
await page.goto(
`/dashboard/${id}?expandedWidgetId=${SINGLE_PANEL_ID}&graphType=graph`,
);
await expect(page.getByTestId('view-panel-modal-content')).toBeVisible();
});
test('TC-04 Refresh re-issues the query', async ({
authedPage: page,
dashboards,
}) => {
await dashboards.seedAndOpen(singlePanelDashboard());
await openViewModal(page, SINGLE_PANEL_ID);
const refetch = page.waitForRequest((r) => r.url().includes('/query_range'));
await page.getByTestId('view-panel-refresh').click();
const request = await refetch;
expect(request.method()).toBe('POST');
});
test('TC-05 switching the panel type in the modal does not persist', async ({
authedPage: page,
dashboards,
}) => {
const id = await dashboards.seedAndOpen(singlePanelDashboard());
await openViewModal(page, SINGLE_PANEL_ID);
await page.getByTestId('view-panel-type-selector').click();
await page
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)')
.getByText('Table', { exact: true })
.click();
await expect(page.getByTestId('table-panel-renderer')).toBeVisible();
await page.goBack();
const after = await getDashboardV2ViaApi(page, id);
expect(after.spec.panels[SINGLE_PANEL_ID].spec.plugin.kind).toBe(
'signoz/TimeSeriesPanel',
);
});
test('TC-06 Switch to Edit Mode hands off to the panel editor', async ({
authedPage: page,
dashboards,
}) => {
const id = await dashboards.seedAndOpen(singlePanelDashboard());
await openViewModal(page, SINGLE_PANEL_ID);
await page.getByTestId('view-panel-switch-to-edit').click();
await page.waitForURL(new RegExp(`/dashboard/${id}/panel/`));
await expect(editor.root(page)).toBeVisible();
});
test('TC-07 a locked panel opens in View but offers no Switch to Edit', async ({
authedPage: page,
dashboards,
}) => {
// `canSwitchToEdit = canEditDashboard && !isLocked`, but View itself is not
// role-gated — so the modal opens and only the handoff button disappears.
const id = await dashboards.seed(singlePanelDashboard());
await setDashboardLockedViaApi(page, id, true);
await gotoDashboardV2(page, id);
await runPanelAction(page, SINGLE_PANEL_ID, PanelAction.view);
await expect(page.getByTestId('view-panel-modal-content')).toBeVisible();
await expect(page.getByTestId('view-panel-switch-to-edit')).toHaveCount(0);
await expect(page.getByTestId('view-panel-refresh')).toBeVisible();
});
test('TC-08 View → Edit → View round-trips with no changes', async ({
authedPage: page,
dashboards,
}) => {
const id = await dashboards.seedAndOpen(
singlePanelDashboard({ panelName: 'Round trip' }),
);
await openViewModal(page, SINGLE_PANEL_ID);
await page.getByTestId('view-panel-switch-to-edit').click();
await page.waitForURL(new RegExp(`/dashboard/${id}/panel/`));
await expect(editor.title(page)).toHaveValue('Round trip');
await expect(editor.unsavedBadge(page)).toHaveCount(0);
await editor.switchToView(page).click();
await expect(page.getByTestId('view-panel-modal-content')).toBeVisible();
const after = await getDashboardV2ViaApi(page, id);
expect(after.spec.panels[SINGLE_PANEL_ID].spec.display.name).toBe(
'Round trip',
);
});
test('TC-09 an unsaved change in the modal carries into the editor', async ({
authedPage: page,
dashboards,
}) => {
const id = await dashboards.seedAndOpen(singlePanelDashboard());
await openViewModal(page, SINGLE_PANEL_ID);
await page.getByTestId('view-panel-type-selector').click();
await page
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)')
.getByText('Table', { exact: true })
.click();
await expect(page.getByTestId('table-panel-renderer')).toBeVisible();
await page.getByTestId('view-panel-switch-to-edit').click();
await page.waitForURL(new RegExp(`/dashboard/${id}/panel/`));
// Editor opens on the MODIFIED panel, and still nothing is committed.
await expect(page.getByTestId('table-panel-renderer')).toBeVisible();
const after = await getDashboardV2ViaApi(page, id);
expect(after.spec.panels[SINGLE_PANEL_ID].spec.plugin.kind).toBe(
'signoz/TimeSeriesPanel',
);
});
test('TC-10 an unsaved change in the editor carries back into the modal', async ({
authedPage: page,
dashboards,
}) => {
const id = await dashboards.seedAndEdit(
singlePanelDashboard(),
SINGLE_PANEL_ID,
);
await editor.title(page).fill('Edited but not saved');
await expect(editor.unsavedBadge(page)).toBeVisible();
await editor.switchToView(page).click();
await expect(page.getByTestId('view-panel-modal-content')).toBeVisible();
await expect(page.getByRole('dialog')).toContainText('Edited but not saved');
const after = await getDashboardV2ViaApi(page, id);
expect(after.spec.panels[SINGLE_PANEL_ID].spec.display.name).toBe(
'Solo panel',
);
});
});

View File

@@ -0,0 +1,167 @@
import type { Page } from '@playwright/test';
import { expect, test } from '../../../../fixtures/dashboards';
import { getDashboardV2ViaApi } from '../../../../helpers/dashboards-v2';
import {
boundingBoxOf,
panelResizeHandle,
panelRoot,
} from '../../../../helpers/panels-v2';
import {
COMPACT_PANELS,
compactDashboard,
} from '../../../../testdata/v2/panels-dashboard';
// Scope: grid layout mutations — drag, resize, and the guarantee that the
// header's action cluster never starts a drag.
//
// react-grid-layout exposes no testids; `.panel-drag-handle` and
// `.react-resizable-handle` are the documented class contract it is configured
// with (draggableHandle / draggableCancel in SectionGrid), so they're used
// directly here.
/** Grid geometry for one panel, read from the persisted spec. */
async function gridItemOf(
page: Page,
dashboardId: string,
panelId: string,
): Promise<{ x: number; y: number; width: number; height: number }> {
const dashboard = await getDashboardV2ViaApi(page, dashboardId);
for (const layout of dashboard.spec.layouts) {
const item = layout.spec.items.find(
(candidate) => candidate.content.$ref === `#/spec/panels/${panelId}`,
);
if (item) {
return {
x: item.x,
y: item.y,
width: item.width,
height: item.height,
};
}
}
throw new Error(`no grid item for panel ${panelId}`);
}
test.describe('Dashboards V2 — grid layout', () => {
test('TC-01 resizing a panel persists its new size', async ({
authedPage: page,
dashboards,
}) => {
const id = await dashboards.seedAndOpen(compactDashboard());
const root = panelRoot(page, COMPACT_PANELS.timeseries);
await expect(root.getByTestId('time-series-renderer')).toBeVisible();
const before = await gridItemOf(page, id, COMPACT_PANELS.timeseries);
const handle = await boundingBoxOf(
panelResizeHandle(page, COMPACT_PANELS.timeseries),
'the resize handle',
);
await page.mouse.move(
handle.x + handle.width / 2,
handle.y + handle.height / 2,
);
await page.mouse.down();
await page.mouse.move(handle.x + 60, handle.y + 90, { steps: 12 });
await page.mouse.up();
// The grid persists on resize-stop, so the spec is the source of truth
// rather than the rendered pixel size.
await expect
.poll(async () => {
const after = await gridItemOf(page, id, COMPACT_PANELS.timeseries);
return after.height > before.height || after.width > before.width;
})
.toBe(true);
});
test('TC-02 a resized layout survives a reload', async ({
authedPage: page,
dashboards,
}) => {
const id = await dashboards.seedAndOpen(compactDashboard());
const root = panelRoot(page, COMPACT_PANELS.timeseries);
await expect(root.getByTestId('time-series-renderer')).toBeVisible();
const handle = await boundingBoxOf(
panelResizeHandle(page, COMPACT_PANELS.timeseries),
'the resize handle',
);
await page.mouse.move(
handle.x + handle.width / 2,
handle.y + handle.height / 2,
);
await page.mouse.down();
await page.mouse.move(handle.x, handle.y + 90, { steps: 12 });
await page.mouse.up();
await expect
.poll(async () => {
const item = await gridItemOf(page, id, COMPACT_PANELS.timeseries);
return item.height;
})
.toBeGreaterThan(6);
const persisted = await gridItemOf(page, id, COMPACT_PANELS.timeseries);
await page.reload();
await expect(root.getByTestId('time-series-renderer')).toBeVisible();
expect(await gridItemOf(page, id, COMPACT_PANELS.timeseries)).toEqual(
persisted,
);
});
test('TC-03 opening the actions menu does not start a drag', async ({
authedPage: page,
dashboards,
}) => {
const id = await dashboards.seedAndOpen(compactDashboard());
const root = panelRoot(page, COMPACT_PANELS.timeseries);
await expect(root.getByTestId('time-series-renderer')).toBeVisible();
const before = await gridItemOf(page, id, COMPACT_PANELS.timeseries);
// The ⋮ button sits inside the drag handle but is marked `panel-no-drag`
// and stops pointerdown, so opening the menu must leave the grid alone.
await root.hover();
await page.getByTestId(`panel-actions-${COMPACT_PANELS.timeseries}`).click();
await expect(page.getByRole('menu')).toBeVisible();
await page.keyboard.press('Escape');
expect(await gridItemOf(page, id, COMPACT_PANELS.timeseries)).toEqual(before);
});
test('TC-04 dragging by the header moves the panel within its section', async ({
authedPage: page,
dashboards,
}) => {
const id = await dashboards.seedAndOpen(compactDashboard());
const root = panelRoot(page, COMPACT_PANELS.timeseries);
await expect(root.getByTestId('time-series-renderer')).toBeVisible();
const before = await gridItemOf(page, id, COMPACT_PANELS.timeseries);
const handle = await boundingBoxOf(
root.locator('.panel-drag-handle').first(),
'the drag handle',
);
await page.mouse.move(
handle.x + handle.width / 4,
handle.y + handle.height / 2,
);
await page.mouse.down();
// Drag a full tile-width right so the swap is unambiguous.
await page.mouse.move(handle.x + handle.width, handle.y + handle.height / 2, {
steps: 15,
});
await page.mouse.up();
await expect
.poll(async () => {
const after = await gridItemOf(page, id, COMPACT_PANELS.timeseries);
return after.x !== before.x || after.y !== before.y;
})
.toBe(true);
});
});