mirror of
https://github.com/SigNoz/signoz.git
synced 2026-08-05 20:50:45 +01:00
Compare commits
1 Commits
test/e2e-d
...
test/dashb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b475efceee |
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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<{
|
||||
|
||||
65
tests/e2e/fixtures/dashboards.ts
Normal file
65
tests/e2e/fixtures/dashboards.ts
Normal 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 };
|
||||
461
tests/e2e/helpers/dashboard-v2-spec.ts
Normal file
461
tests/e2e/helpers/dashboard-v2-spec.ts
Normal 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 }),
|
||||
},
|
||||
};
|
||||
}
|
||||
156
tests/e2e/helpers/dashboards-v2.ts
Normal file
156
tests/e2e/helpers/dashboards-v2.ts
Normal 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();
|
||||
}
|
||||
255
tests/e2e/helpers/panels-v2.ts
Normal file
255
tests/e2e/helpers/panels-v2.ts
Normal 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: 'Couldn’t 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;
|
||||
}
|
||||
272
tests/e2e/helpers/query-range-mock.ts
Normal file
272
tests/e2e/helpers/query-range-mock.ts
Normal 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
138
tests/e2e/helpers/uplot.ts
Normal 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;
|
||||
@@ -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).
|
||||
|
||||
297
tests/e2e/testdata/v2/panels-dashboard.ts
vendored
Normal file
297
tests/e2e/testdata/v2/panels-dashboard.ts
vendored
Normal 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}`,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
184
tests/e2e/tests/dashboards/v2/panels/03-rendering.spec.ts
Normal file
184
tests/e2e/tests/dashboards/v2/panels/03-rendering.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
147
tests/e2e/tests/dashboards/v2/panels/21-states.spec.ts
Normal file
147
tests/e2e/tests/dashboards/v2/panels/21-states.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
195
tests/e2e/tests/dashboards/v2/panels/30-zoom.spec.ts
Normal file
195
tests/e2e/tests/dashboards/v2/panels/30-zoom.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
238
tests/e2e/tests/dashboards/v2/panels/39-legend-tooltip.spec.ts
Normal file
238
tests/e2e/tests/dashboards/v2/panels/39-legend-tooltip.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user