Compare commits

...

12 Commits

Author SHA1 Message Date
Abhi Kumar
639e091892 feat(dashboards): add an axis to scatter plot thresholds 2026-10-16 00:41:46 +05:30
Naman Verma
2bb9496b35 chore: add more validations for scatter plot 2026-10-16 00:41:45 +05:30
Abhi Kumar
251855873d feat(dashboards): rename scatter plot dimensions to colorBy and sizeBy 2026-10-16 00:41:45 +05:30
Abhi Kumar
69d94733ce chore(openapi): regenerate specs for scatter plot panel
Assisted-by: Claude Opus 5.5
2026-10-16 00:41:45 +05:30
Abhi Kumar
a83b78d401 feat(dashboards): add point size and opacity to scatter plot spec
The fixed size and the min/max range are both stored so binding or unbinding
the size dimension restores the other's last setting.

Assisted-by: Claude Opus 5.5
2026-10-16 00:41:45 +05:30
Abhi Kumar
214d8e4d58 chore(openapi): regenerate specs for scatter plot panel
Assisted-by: Claude Opus 5.5
2026-10-16 00:41:45 +05:30
Abhi Kumar
74f1a1be42 feat(dashboards): add axis labels and multi-key colour to scatter plot spec
An empty colour list colours by every group-by label.

Assisted-by: Claude Opus 5.5
2026-10-16 00:41:45 +05:30
Abhi Kumar
658b2a654b chore(openapi): regenerate specs for scatter plot panel
Assisted-by: Claude Opus 5.5
2026-10-16 00:41:45 +05:30
Abhi Kumar
088d55cca0 feat(dashboards): add plugin schema for scatter plot panel
Assisted-by: Claude Opus 5.5
2026-10-16 00:41:45 +05:30
Ashwin Bhatkal
d28cd7cf2e ci(e2e): run e2e on every PR that changes the frontend (#13147)
#### Description

- e2e now runs on every PR that changes `frontend/` or `tests/e2e/`. The
`safe-to-e2e` label still opts in any other PR.
- A new `changes` job reads the PR's file list and gates the `fmtlint`
and `test` jobs on it.
- A new `e2e` job is the single check to mark as required. It fails when
e2e was needed but did not pass, including fork and dependabot PRs still
waiting on `safe-to-test`.
- In the merge queue, `e2e` passes without running the suite, so the
queue is not blocked.
- New pushes cancel the PR's in-flight run.
2026-10-15 18:26:10 +00:00
Vinicius Lourenço
268c67d34b test(e2e): add e2e for routing (#13078)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

This is a PR to add basic testing for our routing, specific parts of the
app that goes from one page to another.

This will act as a base test layer for upcoming react-router upgrade,
this PR also follows the new approach of drivers instead of random
getByTestId in the spec layer.

I also took the opportunity to add support to run the E2E with the base
path feature (eg: /signoz/).

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR

Closes https://github.com/SigNoz/uforge-pod/issues/6190
2026-10-15 17:53:54 +00:00
Gaurav Tewari
206349b75c fix: hide create alert in case of ai query (#13122)
#### Description

AI query do not support creating alert for now. so we should remove
create alert action as well for ai query.

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR

https://github.com/SigNoz/nerve-pod/issues/460 


#### Screenshots / Screen Recordings


https://github.com/user-attachments/assets/d2634f0e-1334-47b3-9ad0-1cb3a226d81b



<!--Anything reviewers should keep in mind while reviewing -->
#### Additional Information

<!--Please delete paragraphs that you did not use before submitting.-->

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-10-15 16:55:44 +00:00
51 changed files with 2462 additions and 131 deletions

1
.gitattributes vendored
View File

@@ -1,3 +1,4 @@
*.css linguist-detectable=false
*.stories.mocks.tsx linguist-generated=true
**/__story_mockdata__/** linguist-generated=true
tests/e2e/drivers/** linguist-generated=true

View File

@@ -3,16 +3,44 @@ name: e2eci
on:
pull_request:
types:
- opened
- synchronize
- reopened
- labeled
pull_request_target:
types:
- labeled
merge_group:
types:
- checks_requested
concurrency:
group: e2eci-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
changes:
if: github.event_name != 'merge_group'
runs-on: ubuntu-latest
outputs:
e2e: ${{ steps.changes.outputs.e2e }}
steps:
- name: changes
id: changes
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
LABELED: ${{ contains(github.event.pull_request.labels.*.name, 'safe-to-e2e') }}
run: |
files=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" --paginate --jq '.[].filename')
if [ "$LABELED" = true ] || grep -qE '^(frontend|tests/e2e)/' <<< "$files"; then
echo "e2e=true" >> "$GITHUB_OUTPUT"
fi
fmtlint:
needs: changes
if: |
((github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))) && contains(github.event.pull_request.labels.*.name, 'safe-to-e2e')
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))) && needs.changes.outputs.e2e == 'true'
runs-on: ubuntu-latest
steps:
- name: checkout
@@ -41,9 +69,10 @@ jobs:
project:
- chromium
shard: [1, 2, 3, 4]
needs: changes
if: |
((github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))) && contains(github.event.pull_request.labels.*.name, 'safe-to-e2e')
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))) && needs.changes.outputs.e2e == 'true'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
@@ -152,3 +181,17 @@ jobs:
name: playwright-artifacts-${{ matrix.project }}-${{ matrix.shard }}
path: tests/e2e/artifacts/
retention-days: 5
# Required status check. Fails when e2e was needed but did not pass,
# including fork and dependabot PRs still waiting on safe-to-test.
e2e:
if: always()
needs: [changes, fmtlint, test]
runs-on: ubuntu-latest
steps:
- name: verdict
if: |
github.event_name != 'merge_group' && (needs.changes.result != 'success' ||
(needs.changes.outputs.e2e == 'true' && (needs.fmtlint.result != 'success' || needs.test.result != 'success')))
run: |
echo "::error::e2e did not pass. Fork and dependabot PRs need the safe-to-test label."
exit 1

View File

@@ -3702,6 +3702,13 @@ components:
nullable: true
type: number
type: object
DashboardtypesAxisScale:
enum:
- auto
- linear
- log
- symlog
type: string
DashboardtypesBarChartPanelSpec:
properties:
axes:
@@ -4444,6 +4451,7 @@ components:
signoz/ListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
signoz/NumberPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec'
signoz/PieChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec'
signoz/ScatterPlotPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
signoz/TablePanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
signoz/TextPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
signoz/TimeSeriesPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec'
@@ -4459,6 +4467,7 @@ components:
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
type: object
DashboardtypesPanelPluginKind:
enum:
@@ -4472,6 +4481,7 @@ components:
- signoz/ListPanel
- signoz/TextPanel
- signoz/HeatmapPanel
- signoz/ScatterPlotPanel
type: string
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec:
properties:
@@ -4557,6 +4567,18 @@ components:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec:
properties:
kind:
enum:
- signoz/ScatterPlotPanel
type: string
spec:
$ref: '#/components/schemas/DashboardtypesScatterPlotPanelSpec'
required:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec:
properties:
kind:
@@ -4636,6 +4658,16 @@ components:
visualization:
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
type: object
DashboardtypesPointDiameter:
maximum: 40
minimum: 2
nullable: true
type: integer
DashboardtypesPointOpacity:
maximum: 1
minimum: 0.1
nullable: true
type: number
DashboardtypesPostableDashboardV2:
properties:
generateName:
@@ -4824,6 +4856,104 @@ components:
required:
- queryValue
type: object
DashboardtypesScatterPlotAxes:
properties:
x:
$ref: '#/components/schemas/DashboardtypesScatterPlotAxis'
"y":
$ref: '#/components/schemas/DashboardtypesScatterPlotAxis'
type: object
DashboardtypesScatterPlotAxis:
properties:
label:
description: Axis title. Empty draws none.
type: string
scale:
$ref: '#/components/schemas/DashboardtypesAxisScale'
softMax:
nullable: true
type: number
softMin:
nullable: true
type: number
type: object
DashboardtypesScatterPlotChartAppearance:
properties:
points:
$ref: '#/components/schemas/DashboardtypesScatterPlotPoints'
type: object
DashboardtypesScatterPlotDimensions:
properties:
colorBy:
description: Group-by label names (e.g. k8s.namespace.name) whose combined
values colour dots and drive the legend. Empty colours by every group-by
label.
items:
type: string
nullable: true
type: array
sizeBy:
description: Value column key that scales dot size. Empty draws every dot
at the default size.
type: string
x:
description: Value column key (queryName, or queryName.expression for a
multi-aggregation query) plotted on the x axis. Empty uses the first value
column.
type: string
"y":
description: Value column key plotted on the y axis. Empty uses the second
value column.
type: string
type: object
DashboardtypesScatterPlotPanelSpec:
properties:
axes:
$ref: '#/components/schemas/DashboardtypesScatterPlotAxes'
chartAppearance:
$ref: '#/components/schemas/DashboardtypesScatterPlotChartAppearance'
dimensions:
$ref: '#/components/schemas/DashboardtypesScatterPlotDimensions'
formatting:
$ref: '#/components/schemas/DashboardtypesTableFormatting'
legend:
$ref: '#/components/schemas/DashboardtypesLegend'
thresholds:
items:
$ref: '#/components/schemas/DashboardtypesScatterPlotThreshold'
nullable: true
type: array
visualization:
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
type: object
DashboardtypesScatterPlotPoints:
properties:
maxSize:
$ref: '#/components/schemas/DashboardtypesPointDiameter'
minSize:
$ref: '#/components/schemas/DashboardtypesPointDiameter'
opacity:
$ref: '#/components/schemas/DashboardtypesPointOpacity'
size:
$ref: '#/components/schemas/DashboardtypesPointDiameter'
type: object
DashboardtypesScatterPlotThreshold:
properties:
axis:
$ref: '#/components/schemas/DashboardtypesThresholdAxis'
color:
type: string
label:
type: string
unit:
type: string
value:
format: double
type: number
required:
- value
- color
type: object
DashboardtypesSource:
enum:
- user
@@ -4937,6 +5067,11 @@ components:
- value
- name
type: object
DashboardtypesThresholdAxis:
enum:
- x
- "y"
type: string
DashboardtypesThresholdFormat:
enum:
- text

View File

@@ -86,6 +86,9 @@ tests/
├── .env.local # generated by bootstrap/setup.py (gitignored)
├── bootstrap/
│ └── setup.py # test_setup / test_teardown — pytest lifecycle
├── drivers/ # the driver kit and one driver per feature
│ ├── kit.ts
│ └── routing.ts
├── fixtures/ # Playwright test fixtures (test.extend) only
│ └── auth.ts
├── helpers/ # function helpers + the constants they share with tests
@@ -112,6 +115,24 @@ These two folders look similar but mean different things:
Rule of thumb: if it's a `test.extend` fixture, put it in `fixtures/`. If it's a function you call explicitly (or a constant the function uses), put it in `helpers/`. If it's a static file the helpers read, put it in `testdata/`.
### Drivers
New suites use a driver rather than loose helpers. A driver is a function that closes over the page and returns the feature's actions and locators. `drivers/kit.ts` turns it into a fixture, so a spec imports one module and never passes a `page` around:
```ts
import { expect, PATH, test } from '../../drivers/routing';
test('TC-09 RouteTab switches the url', async ({ routing }) => {
await routing.goto(PATH.metricsSummary);
await expect(routing.activeRouteTab(PATH.metricsSummary)).toBeVisible();
});
```
- `driver(name, build)` adds a test-scoped fixture built on `authedPage`.
- `seeded(name, open, close)` adds a worker-scoped seed. `open` and `close` run on a throwaway admin page.
- A driver reuses helpers by calling them, never by reaching for another fixture.
- `drivers/` is AI-owned: coding agents generate and maintain it with the specs, and `.gitattributes` collapses it in diffs.
### Extended fixtures
For features needing complex setup (API-seeded data, ruler evaluation waits, cleanup), create domain-specific fixtures that extend `auth`. Group them in `fixtures/<domain>/`.

View File

@@ -72,6 +72,8 @@ interface CustomTimePickerProps {
maxTime: number;
/** When true, zoom-out button is hidden (e.g. in drawer/modal time selection) */
isModalTimeSelection?: boolean;
/** Lands on the trigger input. Not spread — the component takes no rest props. */
'data-testid'?: string;
}
function CustomTimePicker({
@@ -95,6 +97,7 @@ function CustomTimePicker({
minTime,
maxTime,
isModalTimeSelection = false,
'data-testid': dataTestId,
}: CustomTimePickerProps): JSX.Element {
const [selectedTimePlaceholderValue, setSelectedTimePlaceholderValue] =
useState('Select / Enter Time Range');
@@ -596,6 +599,7 @@ function CustomTimePicker({
>
<Input
ref={inputRef}
data-testid={dataTestId}
autoComplete="off"
className={cx(
'timeSelection-input',
@@ -682,4 +686,5 @@ CustomTimePicker.defaultProps = {
onExitLiveLogs: noop,
showLiveLogs: false,
showRecentlyUsed: true,
'data-testid': undefined,
};

View File

@@ -180,6 +180,7 @@ function CustomTimePickerPopoverContent({
type="text"
className="time-btns"
key={option.label + option.value}
data-testid={`time-chip-${option.value}`}
onClick={(): void => {
handleExitLiveLogs();
onSelectHandler(option.label, option.value);
@@ -259,6 +260,7 @@ function CustomTimePickerPopoverContent({
<Button
type="text"
key={option.label + option.value}
data-testid={`time-option-${option.value}`}
onClick={(e: React.MouseEvent<HTMLButtonElement>): void => {
e.stopPropagation();
e.preventDefault();

View File

@@ -98,6 +98,7 @@ exports[`Not Found page test should render Not Found page without errors 1`] = `
<div
class="c0"
data-testid="not-found"
>
<img
alt="not-found"

View File

@@ -6,7 +6,7 @@ import { Button, Container, Text, TextContainer } from './styles';
function NotFound({ text = defaultText }: Props): JSX.Element {
return (
<Container>
<Container data-testid="not-found">
<NotFoundImage />
<TextContainer>

View File

@@ -35,7 +35,8 @@ function TanStackRowCellInner<TData>({
<td
className={cx(tableStyles.tableCell, 'tanstack-cell-' + cell.column.id)}
data-single-column={hasSingleColumn || undefined}
onClick={onClick}
// While loading, rowData may be a placeholder ({ id: 'skeleton-N' }).
onClick={showSkeleton ? undefined : onClick}
>
{showSkeleton ? (
<Skeleton.Input

View File

@@ -91,6 +91,18 @@ describe('TanStackTableView Integration', () => {
expect(screen.getByRole('table')).toBeInTheDocument();
});
it('does not call onRowClick for skeleton rows', async () => {
const user = userEvent.setup();
const onRowClick = jest.fn();
renderTanStackTable({
props: { data: [], isLoading: true, onRowClick },
});
await user.click(screen.getAllByRole('cell')[0]);
expect(onRowClick).not.toHaveBeenCalled();
});
it('shows loading spinner for infinite scroll when loading', () => {
renderTanStackTable({
props: { isLoading: true, onEndReached: jest.fn() },

View File

@@ -314,7 +314,7 @@ function CreateEditRolePageContent(): JSX.Element {
cancelText="Keep editing"
onConfirm={confirmNavigation}
onCancel={cancelNavigation}
data-testid="discard-changes-dialog"
testId="discard-changes-dialog"
>
<Typography>
{isCreateMode

View File

@@ -1,7 +1,10 @@
import { Flame } from '@signozhq/icons';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { getQueryMode } from 'lib/query/queryMode';
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import { useCreateAlertFromPanel } from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useCreateAlertFromPanel';
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
import ConfigActionRow from './ConfigActionRow';
import styles from './ConfigActions.module.scss';
@@ -22,11 +25,16 @@ function ConfigActions({
panelId,
}: ConfigActionsProps): JSX.Element | null {
const createAlert = useCreateAlertFromPanel();
const { currentQuery } = useQueryBuilder();
const { actions } = getPanelDefinition(panel.spec.plugin.kind);
// Only kinds whose query can seed an alert offer this today; mirror the panel
// menu's create-alert capability.
if (!actions.createAlert) {
//TODO: AI query check should be removed after we support alerts on ai query @tewarig
if (
!actions.createAlert ||
getQueryMode(currentQuery) === QueryMode.AI_QUERY_BUILDER
) {
return null;
}

View File

@@ -123,10 +123,22 @@ func (d *DashboardSpec) validatePanels() error {
return err
}
}
// validatePanelQueryCount left exactly one query for a spec that reads it.
if spec, ok := panel.Spec.Plugin.Spec.(queryBoundPanelSpec); ok {
if err := spec.validateAgainstQuery(panel.Spec.Queries[0], path+".spec.plugin.spec"); err != nil {
return err
}
}
}
return nil
}
// queryBoundPanelSpec is a panel spec with fields that depend on the panel's
// query. It is checked after the query itself validates.
type queryBoundPanelSpec interface {
validateAgainstQuery(query Query, path string) error
}
func validatePanelQueryCount(queries []Query, panelKind PanelPluginKind, path string) error {
if queries == nil {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s.spec.queries: is required and must not be null; use [] for a panel that renders without a query", path)

View File

@@ -1870,6 +1870,319 @@ func TestInvalidateAreaChartPanelSpecValues(t *testing.T) {
}
}
func TestScatterPlotPanelDefaults(t *testing.T) {
data := []byte(`{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {
"kind": "signoz/ScatterPlotPanel",
"spec": {}
},
"queries": [{"kind": "scalar", "spec": {"plugin": {"kind": "signoz/ClickHouseSQL", "spec": {"name": "A", "query": "SELECT 1"}}}}]
}
}
},
"links": [],
"layouts": []
}`)
d, err := unmarshalDashboard(data)
require.NoError(t, err, "unmarshal and validate failed")
require.IsType(t, &ScatterPlotPanelSpec{}, d.Panels["p1"].Spec.Plugin.Spec)
spec := d.Panels["p1"].Spec.Plugin.Spec.(*ScatterPlotPanelSpec)
assert.Empty(t, spec.Dimensions.X, "an omitted x stays empty so the renderer picks the first value column")
assert.Empty(t, spec.Dimensions.Y, "an omitted y stays empty so the renderer picks the second value column")
assert.Empty(t, spec.Dimensions.ColorBy, "an omitted colorBy stays empty so the renderer colours by every group-by label")
assert.Equal(t, "auto", spec.Axes.X.Scale.ValueOrDefault(), "expected x scale default auto")
assert.Equal(t, "auto", spec.Axes.Y.Scale.ValueOrDefault(), "expected y scale default auto")
assert.Nil(t, spec.Axes.X.SoftMin, "expected x softMin unset")
assert.Equal(t, "2", spec.Formatting.DecimalPrecision.ValueOrDefault(), "expected DecimalPrecision default 2")
assert.Equal(t, "global_time", spec.Visualization.TimePreference.ValueOrDefault(), "expected TimePreference default global_time")
assert.Equal(t, "bottom", spec.Legend.Position.ValueOrDefault(), "expected LegendPosition default bottom")
output, err := json.Marshal(d)
require.NoError(t, err, "marshal dashboard failed")
assert.Contains(t, string(output), `"axes":{"x":{"softMin":null,"softMax":null,"scale":"auto","label":""},"y":{"softMin":null,"softMax":null,"scale":"auto","label":""}}`, "expected default axes in stored/response JSON")
assert.Contains(t, string(output), `"dimensions":{"x":"","y":"","sizeBy":"","colorBy":null}`, "expected empty dimensions in stored/response JSON")
assert.Contains(t, string(output), `"chartAppearance":{"points":{"size":null,"minSize":null,"maxSize":null,"opacity":null}}`, "omitted point settings stay null so the renderer applies its defaults")
}
func TestValidateScatterPlotDashboard(t *testing.T) {
data, err := os.ReadFile("testdata/perses_scatter_plot_panel.json")
require.NoError(t, err, "reading example file")
spec, err := unmarshalDashboard(data)
require.NoError(t, err, "unmarshal and validate failed")
require.IsType(t, &ScatterPlotPanelSpec{}, spec.Panels["p1"].Spec.Plugin.Spec)
panelSpec := spec.Panels["p1"].Spec.Plugin.Spec.(*ScatterPlotPanelSpec)
softMin, softMax := 0.0, 1000.0
size, minSize, maxSize, opacity := PointDiameter(8), PointDiameter(4), PointDiameter(24), PointOpacity(0.1)
assert.Equal(t, &ScatterPlotPanelSpec{
Visualization: BasicVisualization{TimePreference: TimePreferenceLast1Hr},
Dimensions: ScatterPlotDimensions{X: "A.count()", Y: "A.p99(duration_nano)", SizeBy: "A.countIf(has_error = true)", ColorBy: []string{"k8s.namespace.name", "k8s.pod.name"}},
Formatting: TableFormatting{ColumnUnits: map[string]string{"A.p99(duration_nano)": "ns"}, DecimalPrecision: PrecisionOption1},
Axes: ScatterPlotAxes{
X: ScatterPlotAxis{SoftMin: &softMin, Scale: AxisScaleLog, Label: "Throughput"},
Y: ScatterPlotAxis{SoftMax: &softMax, Scale: AxisScaleSymlog, Label: "p99 latency"},
},
ChartAppearance: ScatterPlotChartAppearance{Points: ScatterPlotPoints{Size: &size, MinSize: &minSize, MaxSize: &maxSize, Opacity: &opacity}},
Legend: Legend{Position: LegendPositionRight, Mode: LegendModeList, CustomColors: map[string]string{"checkout": "#1f77b4"}},
Thresholds: []ScatterPlotThreshold{
{ThresholdWithLabel: ThresholdWithLabel{Value: 300, Unit: "ms", Color: "#f00", Label: "p99 SLO"}, Axis: ThresholdAxisY},
{ThresholdWithLabel: ThresholdWithLabel{Value: 50, Color: "#0f0"}, Axis: ThresholdAxisX},
},
}, panelSpec)
dashboard := &DashboardV2{Spec: *spec}
request, err := dashboard.GetPanelQuery(1, 2, "p1")
require.NoError(t, err, "building the panel's query failed")
assert.Equal(t, qb.RequestTypeScalar, request.RequestType)
require.NotNil(t, request.FormatOptions)
assert.True(t, request.FormatOptions.FormatTableResultForUI, "scatter rows are joined the way a table's are")
require.Len(t, request.CompositeQuery.Queries, 1)
query, ok := request.CompositeQuery.Queries[0].Spec.(qb.QueryBuilderQuery[qb.TraceAggregation])
require.True(t, ok, "expected a traces builder query")
assert.Len(t, query.Aggregations, 3)
assert.Len(t, query.GroupBy, 2)
require.NoError(t, request.Validate(), "the request built from the panel is not a valid scalar request")
// the panel read back out of storage draws the same scatter plot
stored, err := json.Marshal(spec)
require.NoError(t, err, "marshal dashboard failed")
reread, err := unmarshalDashboard(stored)
require.NoError(t, err, "the stored dashboard does not validate")
assert.Equal(t, panelSpec, reread.Panels["p1"].Spec.Plugin.Spec)
rereadRequest, err := (&DashboardV2{Spec: *reread}).GetPanelQuery(1, 2, "p1")
require.NoError(t, err, "building the stored panel's query failed")
assert.Equal(t, request, rereadRequest)
}
func TestInvalidateScatterPlotPanelSpecValues(t *testing.T) {
tests := []struct {
scenario string
panelKind string
panelSpec string
expectedErrorSubstring string
}{
{
scenario: "unknown axis scale",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"axes": {"x": {"scale": "sqrt"}}}`,
expectedErrorSubstring: "invalid axis scale \"sqrt\": must be `auto`, `linear`, `log`, or `symlog`",
},
{
scenario: "non-string axis scale",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"axes": {"y": {"scale": true}}}`,
expectedErrorSubstring: "cannot unmarshal bool",
},
{
scenario: "isLogScale on a scatter plot axis",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"axes": {"x": {"isLogScale": true}}}`,
expectedErrorSubstring: "unknown field",
},
{
scenario: "single-axis axes shape on a scatter plot panel",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"axes": {"softMin": 0}}`,
expectedErrorSubstring: "unknown field",
},
{
scenario: "unknown threshold axis",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"thresholds": [{"value": 1, "color": "#f00", "axis": "z"}]}`,
expectedErrorSubstring: "invalid threshold axis \"z\": must be `x` or `y`",
},
{
scenario: "unknown dimension",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"dimensions": {"z": "A"}}`,
expectedErrorSubstring: "unknown field",
},
{
scenario: "point size below the minimum",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"chartAppearance": {"points": {"size": 1}}}`,
expectedErrorSubstring: "invalid point diameter 1: must be a whole number between 2 and 40",
},
{
scenario: "max point size above the maximum",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"chartAppearance": {"points": {"maxSize": 41}}}`,
expectedErrorSubstring: "invalid point diameter 41",
},
{
scenario: "fractional point size",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"chartAppearance": {"points": {"minSize": 4.5}}}`,
expectedErrorSubstring: "cannot unmarshal number 4.5",
},
{
scenario: "min point size above max",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"chartAppearance": {"points": {"minSize": 20, "maxSize": 10}}}`,
expectedErrorSubstring: "minSize 20 must not exceed maxSize 10",
},
{
scenario: "point opacity below the floor",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"chartAppearance": {"points": {"opacity": 0.05}}}`,
expectedErrorSubstring: "invalid point opacity 0.05: must be between 0.1 and 1",
},
{
scenario: "point opacity as a percentage",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"chartAppearance": {"points": {"opacity": 70}}}`,
expectedErrorSubstring: "invalid point opacity 70",
},
{
scenario: "area fill fields on a scatter plot",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"chartAppearance": {"fillOpacity": 0.5}}`,
expectedErrorSubstring: "unknown field",
},
{
scenario: "a single colour key as a string",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"dimensions": {"colorBy": "service.name"}}`,
expectedErrorSubstring: "cannot unmarshal string",
},
{
scenario: "panel-wide unit on a scatter plot panel",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"formatting": {"unit": "ms"}}`,
expectedErrorSubstring: "unknown field",
},
{
scenario: "threshold without a color",
panelKind: "signoz/ScatterPlotPanel",
panelSpec: `{"thresholds": [{"value": 300}]}`,
expectedErrorSubstring: "Color' Error:Field validation for 'Color' failed on the 'required' tag",
},
{
scenario: "scale on a time series panel",
panelKind: "signoz/TimeSeriesPanel",
panelSpec: `{"axes": {"scale": "log"}}`,
expectedErrorSubstring: "unknown field",
},
}
for _, test := range tests {
t.Run(test.scenario, func(t *testing.T) {
data := []byte(`{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "` + test.panelKind + `", "spec": ` + test.panelSpec + `},
"queries": [{"kind": "scalar", "spec": {"plugin": {"kind": "signoz/ClickHouseSQL", "spec": {"name": "A", "query": "SELECT 1"}}}}]
}
}
},
"links": [],
"layouts": []
}`)
_, err := unmarshalDashboard(data)
require.Error(t, err, "expected the spec to be rejected")
assert.Contains(t, err.Error(), test.expectedErrorSubstring, "unexpected error message: %s", err.Error())
})
}
}
func TestScatterPlotDimensionsBindToQuery(t *testing.T) {
tests := []struct {
scenario string
queryPlugin string
dimensions string
expectedErrorSubstring string
}{
{
scenario: "a single-aggregation query is one value column keyed by its name",
queryPlugin: `{"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "traces", "aggregations": [{"expression": "count()"}], "groupBy": [{"name": "service.name"}]}}`,
dimensions: `{"x": "A", "colorBy": ["service.name"]}`,
},
{
scenario: "a multi-aggregation query keys each value column by name and expression",
queryPlugin: `{"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "traces", "aggregations": [{"expression": "count()"}, {"expression": "p99(duration_nano)"}]}}`,
dimensions: `{"x": "A"}`,
expectedErrorSubstring: "dimensions.x: \"A\" is not a value column of the panel's query; value columns: `A.count()`, `A.p99(duration_nano)`",
},
{
scenario: "a value column of a query the panel does not have",
queryPlugin: `{"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}`,
dimensions: `{"y": "B"}`,
expectedErrorSubstring: "dimensions.y: \"B\" is not a value column of the panel's query; value columns: `A`",
},
{
scenario: "a colour key the query does not group by",
queryPlugin: `{"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "traces", "aggregations": [{"expression": "count()"}], "groupBy": [{"name": "service.name"}]}}`,
dimensions: `{"colorBy": ["service.name", "k8s.pod.name"]}`,
expectedErrorSubstring: "dimensions.colorBy: \"k8s.pod.name\" is not a group-by key of the panel's query; group-by keys: `service.name`",
},
{
scenario: "a formula is a value column and carries the labels of the disabled queries it reads",
queryPlugin: `{"kind": "signoz/CompositeQuery", "spec": {"queries": [
{"type": "builder_query", "spec": {"name": "A", "signal": "traces", "disabled": true, "aggregations": [{"expression": "count()"}], "groupBy": [{"name": "service.name"}]}},
{"type": "builder_query", "spec": {"name": "B", "signal": "traces", "disabled": true, "aggregations": [{"expression": "countIf(has_error = true)"}], "groupBy": [{"name": "service.name"}]}},
{"type": "builder_formula", "spec": {"name": "F1", "expression": "B / A"}}
]}}`,
dimensions: `{"x": "F1", "colorBy": ["service.name"]}`,
},
{
scenario: "a disabled query yields no value column",
queryPlugin: `{"kind": "signoz/CompositeQuery", "spec": {"queries": [
{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "disabled": true, "aggregations": [{"expression": "count()"}]}},
{"type": "builder_formula", "spec": {"name": "F1", "expression": "A * 2"}}
]}}`,
dimensions: `{"sizeBy": "A"}`,
expectedErrorSubstring: "dimensions.sizeBy: \"A\" is not a value column of the panel's query; value columns: `F1`",
},
{
scenario: "clickhouse columns are only known at run time, so the bindings go unchecked",
queryPlugin: `{"kind": "signoz/ClickHouseSQL", "spec": {"name": "A", "query": "SELECT 1"}}`,
dimensions: `{"x": "throughput", "colorBy": ["pod"]}`,
},
}
for _, test := range tests {
t.Run(test.scenario, func(t *testing.T) {
data := []byte(`{
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"links": [],
"plugin": {"kind": "signoz/ScatterPlotPanel", "spec": {"dimensions": ` + test.dimensions + `}},
"queries": [{"kind": "scalar", "spec": {"plugin": ` + test.queryPlugin + `}}]
}
}
},
"links": [],
"layouts": []
}`)
_, err := unmarshalDashboard(data)
if test.expectedErrorSubstring == "" {
require.NoError(t, err)
return
}
require.Error(t, err, "expected the binding to be rejected")
assert.Contains(t, err.Error(), test.expectedErrorSubstring, "unexpected error message: %s", err.Error())
})
}
}
func TestNumberPanelDefaults(t *testing.T) {
data := []byte(`{
"variables": [],
@@ -2228,7 +2541,7 @@ func TestPanelTypeQueryTypeCompatibility(t *testing.T) {
switch panelKind {
case "signoz/ListPanel":
return "raw"
case "signoz/TablePanel", "signoz/NumberPanel", "signoz/PieChartPanel", "signoz/HistogramPanel":
case "signoz/TablePanel", "signoz/NumberPanel", "signoz/PieChartPanel", "signoz/HistogramPanel", "signoz/ScatterPlotPanel":
return "scalar"
default:
return "time_series"
@@ -2272,14 +2585,17 @@ func TestPanelTypeQueryTypeCompatibility(t *testing.T) {
{"List+Builder", mkQuery("signoz/ListPanel", "signoz/BuilderQuery", `{"name":"A","signal":"logs"}`), false},
{"TimeSeries+AIBuilder", mkQuery("signoz/TimeSeriesPanel", "signoz/AIBuilderQuery", `{"name":"A","aggregations":[{"expression":"count()"}]}`), false},
{"List+AIBuilder", mkQuery("signoz/ListPanel", "signoz/AIBuilderQuery", `{"name":"A"}`), false},
{"ScatterPlot+ClickHouse", mkQuery("signoz/ScatterPlotPanel", "signoz/ClickHouseSQL", `{"name":"A","query":"SELECT 1"}`), false},
// Top-level: rejected
{"Table+PromQL", mkQuery("signoz/TablePanel", "signoz/PromQLQuery", `{"name":"A","query":"up"}`), true},
{"ScatterPlot+PromQL", mkQuery("signoz/ScatterPlotPanel", "signoz/PromQLQuery", `{"name":"A","query":"up"}`), true},
{"List+ClickHouse", mkQuery("signoz/ListPanel", "signoz/ClickHouseSQL", `{"name":"A","query":"SELECT 1"}`), true},
{"List+PromQL", mkQuery("signoz/ListPanel", "signoz/PromQLQuery", `{"name":"A","query":"up"}`), true},
{"List+Composite", mkQuery("signoz/ListPanel", "signoz/CompositeQuery", `{"queries":[]}`), true},
{"List+Formula", mkQuery("signoz/ListPanel", "signoz/Formula", `{"name":"F1","expression":"A+B"}`), true},
// Composite sub-queries
{"Table+Composite(promql)", mkComposite("signoz/TablePanel", "promql", `{"name":"A","query":"up"}`), true},
{"ScatterPlot+Composite(promql)", mkComposite("signoz/ScatterPlotPanel", "promql", `{"name":"A","query":"up"}`), true},
{"Table+Composite(clickhouse)", mkComposite("signoz/TablePanel", "clickhouse_sql", `{"name":"A","query":"SELECT 1"}`), false},
{"Table+Composite(builder_ai)", mkComposite("signoz/TablePanel", "builder_ai_query", `{"name":"A","aggregations":[{"expression":"count()"}]}`), false},
}

View File

@@ -28,16 +28,17 @@ type PanelPlugin struct {
// (and strips the duplicate parent properties) after reflection.
func (PanelPlugin) PrepareJSONSchema(s *jsonschema.Schema) error {
return markDiscriminator(s, "kind", map[string]string{
string(PanelKindTimeSeries): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec"),
string(PanelKindBarChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec"),
string(PanelKindAreaChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec"),
string(PanelKindNumber): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec"),
string(PanelKindPieChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec"),
string(PanelKindTable): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec"),
string(PanelKindHistogram): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec"),
string(PanelKindList): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec"),
string(PanelKindText): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec"),
string(PanelKindHeatmap): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec"),
string(PanelKindTimeSeries): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec"),
string(PanelKindBarChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec"),
string(PanelKindAreaChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec"),
string(PanelKindNumber): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec"),
string(PanelKindPieChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec"),
string(PanelKindTable): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec"),
string(PanelKindHistogram): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec"),
string(PanelKindList): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec"),
string(PanelKindText): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec"),
string(PanelKindHeatmap): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec"),
string(PanelKindScatterPlot): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec"),
})
}
@@ -71,6 +72,7 @@ func (PanelPlugin) JSONSchemaOneOf() []any {
PanelPluginVariant[ListPanelSpec]{Kind: string(PanelKindList)},
PanelPluginVariant[TextPanelSpec]{Kind: string(PanelKindText)},
PanelPluginVariant[HeatmapPanelSpec]{Kind: string(PanelKindHeatmap)},
PanelPluginVariant[ScatterPlotPanelSpec]{Kind: string(PanelKindScatterPlot)},
}
}
@@ -234,16 +236,17 @@ func (v VariablePluginVariant[S]) PrepareJSONSchema(s *jsonschema.Schema) error
var (
panelPluginSpecs = map[PanelPluginKind]func() any{
PanelKindTimeSeries: func() any { return new(TimeSeriesPanelSpec) },
PanelKindBarChart: func() any { return new(BarChartPanelSpec) },
PanelKindAreaChart: func() any { return new(AreaChartPanelSpec) },
PanelKindNumber: func() any { return new(NumberPanelSpec) },
PanelKindPieChart: func() any { return new(PieChartPanelSpec) },
PanelKindTable: func() any { return new(TablePanelSpec) },
PanelKindHistogram: func() any { return new(HistogramPanelSpec) },
PanelKindList: func() any { return new(ListPanelSpec) },
PanelKindText: func() any { return new(TextPanelSpec) },
PanelKindHeatmap: func() any { return new(HeatmapPanelSpec) },
PanelKindTimeSeries: func() any { return new(TimeSeriesPanelSpec) },
PanelKindBarChart: func() any { return new(BarChartPanelSpec) },
PanelKindAreaChart: func() any { return new(AreaChartPanelSpec) },
PanelKindNumber: func() any { return new(NumberPanelSpec) },
PanelKindPieChart: func() any { return new(PieChartPanelSpec) },
PanelKindTable: func() any { return new(TablePanelSpec) },
PanelKindHistogram: func() any { return new(HistogramPanelSpec) },
PanelKindList: func() any { return new(ListPanelSpec) },
PanelKindText: func() any { return new(TextPanelSpec) },
PanelKindHeatmap: func() any { return new(HeatmapPanelSpec) },
PanelKindScatterPlot: func() any { return new(ScatterPlotPanelSpec) },
}
queryPluginSpecs = map[QueryPluginKind]func() any{
QueryKindBuilder: func() any { return new(BuilderQuerySpec) },
@@ -260,16 +263,17 @@ var (
VariableKindCustom: func() any { return new(CustomVariableSpec) },
}
allowedQueryKinds = map[PanelPluginKind][]QueryPluginKind{
PanelKindTimeSeries: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindBarChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindAreaChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindNumber: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindHistogram: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindPieChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
PanelKindTable: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
PanelKindList: {QueryKindBuilder, QueryKindAIBuilder},
PanelKindText: {},
PanelKindHeatmap: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindTimeSeries: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindBarChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindAreaChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindNumber: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindHistogram: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindPieChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
PanelKindTable: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
PanelKindList: {QueryKindBuilder, QueryKindAIBuilder},
PanelKindText: {},
PanelKindHeatmap: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindScatterPlot: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
}
)

View File

@@ -221,7 +221,7 @@ func (d *DashboardV2) GetPanelQuery(startTime, endTime uint64, panelKey string)
CompositeQuery: composite,
FormatOptions: &qb.FormatOptions{
FillGaps: fillGaps,
FormatTableResultForUI: panel.Spec.Plugin.Kind == PanelKindTable,
FormatTableResultForUI: panel.Spec.Plugin.Kind == PanelKindTable || panel.Spec.Plugin.Kind == PanelKindScatterPlot,
},
}, nil
}

View File

@@ -251,34 +251,38 @@ func TestDashboardV2GetPanelQuery(t *testing.T) {
assert.Equal(t, telemetrytypes.SignalTraces, spec.Signal)
})
t.Run("sets FormatTableResultForUI only for table panels", func(t *testing.T) {
dashboard := &DashboardV2{
Spec: DashboardSpec{
Panels: map[string]*Panel{
"panel-1": {
Spec: PanelSpec{
Plugin: PanelPlugin{Kind: PanelKindTable},
Queries: []Query{
{
Kind: qb.RequestTypeScalar,
Spec: QuerySpec{
Plugin: QueryPlugin{
Kind: QueryKindBuilder,
Spec: &BuilderQuerySpec{Spec: qb.QueryBuilderQuery[qb.MetricAggregation]{Name: "A"}},
t.Run("sets FormatTableResultForUI for panels that join scalar rows", func(t *testing.T) {
for _, kind := range []PanelPluginKind{PanelKindTable, PanelKindScatterPlot} {
t.Run(string(kind), func(t *testing.T) {
dashboard := &DashboardV2{
Spec: DashboardSpec{
Panels: map[string]*Panel{
"panel-1": {
Spec: PanelSpec{
Plugin: PanelPlugin{Kind: kind},
Queries: []Query{
{
Kind: qb.RequestTypeScalar,
Spec: QuerySpec{
Plugin: QueryPlugin{
Kind: QueryKindBuilder,
Spec: &BuilderQuerySpec{Spec: qb.QueryBuilderQuery[qb.MetricAggregation]{Name: "A"}},
},
},
},
},
},
},
},
},
},
},
}
}
req, err := dashboard.GetPanelQuery(1, 2, "panel-1")
require.NoError(t, err)
require.NotNil(t, req.FormatOptions)
assert.True(t, req.FormatOptions.FormatTableResultForUI)
req, err := dashboard.GetPanelQuery(1, 2, "panel-1")
require.NoError(t, err)
require.NotNil(t, req.FormatOptions)
assert.True(t, req.FormatOptions.FormatTableResultForUI)
})
}
})
t.Run("sets FillGaps from the panel visualization", func(t *testing.T) {

View File

@@ -2,6 +2,8 @@ package dashboardtypes
import (
"encoding/json"
"maps"
"slices"
"strconv"
"github.com/SigNoz/signoz/pkg/errors"
@@ -187,20 +189,21 @@ func (AIBuilderQuerySpec) PrepareJSONSchema(s *jsonschema.Schema) error {
type PanelPluginKind string
const (
PanelKindTimeSeries PanelPluginKind = "signoz/TimeSeriesPanel"
PanelKindBarChart PanelPluginKind = "signoz/BarChartPanel"
PanelKindAreaChart PanelPluginKind = "signoz/AreaChartPanel"
PanelKindNumber PanelPluginKind = "signoz/NumberPanel"
PanelKindPieChart PanelPluginKind = "signoz/PieChartPanel"
PanelKindTable PanelPluginKind = "signoz/TablePanel"
PanelKindHistogram PanelPluginKind = "signoz/HistogramPanel"
PanelKindList PanelPluginKind = "signoz/ListPanel"
PanelKindText PanelPluginKind = "signoz/TextPanel"
PanelKindHeatmap PanelPluginKind = "signoz/HeatmapPanel"
PanelKindTimeSeries PanelPluginKind = "signoz/TimeSeriesPanel"
PanelKindBarChart PanelPluginKind = "signoz/BarChartPanel"
PanelKindAreaChart PanelPluginKind = "signoz/AreaChartPanel"
PanelKindNumber PanelPluginKind = "signoz/NumberPanel"
PanelKindPieChart PanelPluginKind = "signoz/PieChartPanel"
PanelKindTable PanelPluginKind = "signoz/TablePanel"
PanelKindHistogram PanelPluginKind = "signoz/HistogramPanel"
PanelKindList PanelPluginKind = "signoz/ListPanel"
PanelKindText PanelPluginKind = "signoz/TextPanel"
PanelKindHeatmap PanelPluginKind = "signoz/HeatmapPanel"
PanelKindScatterPlot PanelPluginKind = "signoz/ScatterPlotPanel"
)
func (PanelPluginKind) Enum() []any {
return []any{PanelKindTimeSeries, PanelKindBarChart, PanelKindAreaChart, PanelKindNumber, PanelKindPieChart, PanelKindTable, PanelKindHistogram, PanelKindList, PanelKindText, PanelKindHeatmap}
return []any{PanelKindTimeSeries, PanelKindBarChart, PanelKindAreaChart, PanelKindNumber, PanelKindPieChart, PanelKindTable, PanelKindHistogram, PanelKindList, PanelKindText, PanelKindHeatmap, PanelKindScatterPlot}
}
func (k PanelPluginKind) rendersWithoutQuery() bool {
@@ -350,6 +353,95 @@ type TextPanelSpec struct {
HeaderOptions HeaderOptions `json:"headerOptions"`
}
type ScatterPlotPanelSpec struct {
Visualization BasicVisualization `json:"visualization"`
Dimensions ScatterPlotDimensions `json:"dimensions"`
Formatting TableFormatting `json:"formatting"`
Axes ScatterPlotAxes `json:"axes"`
ChartAppearance ScatterPlotChartAppearance `json:"chartAppearance"`
Legend Legend `json:"legend"`
Thresholds []ScatterPlotThreshold `json:"thresholds" validate:"dive"`
}
func (s *ScatterPlotPanelSpec) validateAgainstQuery(query Query, path string) error {
composite, err := query.Spec.Plugin.buildV5CompositeQueryFromPlugin()
if err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s.dimensions: %s", path, err.Error())
}
keys, known := composite.CollectResultColumnKeys()
if !known {
return nil
}
for _, binding := range []struct{ field, key string }{{"x", s.Dimensions.X}, {"y", s.Dimensions.Y}, {"sizeBy", s.Dimensions.SizeBy}} {
// Empty is unset: the renderer picks a column or a fixed size.
if binding.key == "" {
continue
}
if _, ok := keys.Aggregations[binding.key]; !ok {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s.dimensions.%s: %q is not a value column of the panel's query; value columns: %s", path, binding.field, binding.key, allowedValuesForKind(slices.Sorted(maps.Keys(keys.Aggregations))))
}
}
for _, label := range s.Dimensions.ColorBy {
if _, ok := keys.Groups[label]; !ok {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s.dimensions.colorBy: %q is not a group-by key of the panel's query; group-by keys: %s", path, label, allowedValuesForKind(slices.Sorted(maps.Keys(keys.Groups))))
}
}
return nil
}
// ScatterPlotDimensions binds result columns to what a dot encodes. Value columns
// use the Table column key, so formatting.columnUnits carries over between the
// two kinds.
type ScatterPlotDimensions struct {
X string `json:"x" description:"Value column key (queryName, or queryName.expression for a multi-aggregation query) plotted on the x axis. Empty uses the first value column."`
Y string `json:"y" description:"Value column key plotted on the y axis. Empty uses the second value column."`
SizeBy string `json:"sizeBy" description:"Value column key that scales dot size. Empty draws every dot at the default size."`
ColorBy []string `json:"colorBy" description:"Group-by label names (e.g. k8s.namespace.name) whose combined values colour dots and drive the legend. Empty colours by every group-by label."`
}
type ScatterPlotAxes struct {
X ScatterPlotAxis `json:"x"`
Y ScatterPlotAxis `json:"y"`
}
type ScatterPlotAxis struct {
SoftMin *float64 `json:"softMin"`
SoftMax *float64 `json:"softMax"`
Scale AxisScale `json:"scale"`
Label string `json:"label" description:"Axis title. Empty draws none."`
}
type ScatterPlotChartAppearance struct {
Points ScatterPlotPoints `json:"points"`
}
// ScatterPlotPoints keeps the fixed size and the size range side by side so
// binding or unbinding dimensions.sizeBy restores the other's last setting. Nil
// fields resolve to the renderer default.
type ScatterPlotPoints struct {
Size *PointDiameter `json:"size" description:"Diameter of every dot when dimensions.sizeBy is unset."`
MinSize *PointDiameter `json:"minSize" description:"Diameter of the smallest dot when dimensions.sizeBy is set."`
MaxSize *PointDiameter `json:"maxSize" description:"Diameter of the largest dot when dimensions.sizeBy is set."`
Opacity *PointOpacity `json:"opacity"`
}
func (p *ScatterPlotPoints) UnmarshalJSON(data []byte) error {
type alias ScatterPlotPoints
var tmp alias
if err := json.Unmarshal(data, &tmp); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid points")
}
*p = ScatterPlotPoints(tmp)
return p.validate()
}
func (p ScatterPlotPoints) validate() error {
if p.MinSize != nil && p.MaxSize != nil && *p.MinSize > *p.MaxSize {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid points: minSize %d must not exceed maxSize %d", *p.MinSize, *p.MaxSize)
}
return nil
}
type TextPresentation struct {
TextAlign TextAlign `json:"textAlign"`
VerticalAlign VerticalAlign `json:"verticalAlign"`
@@ -431,6 +523,11 @@ type ComparisonThreshold struct {
Format ThresholdFormat `json:"format"`
}
type ScatterPlotThreshold struct {
ThresholdWithLabel
Axis ThresholdAxis `json:"axis"`
}
type TableThreshold struct {
ComparisonThreshold
ColumnName string `json:"columnName" validate:"required" required:"true"`
@@ -716,6 +813,86 @@ func (ls *LineStyle) UnmarshalJSON(data []byte) error {
}
}
// AxisScale `auto` leaves the choice to the renderer; `symlog` is log-like but
// defined at and below zero.
type AxisScale struct{ valuer.String }
var (
AxisScaleAuto = AxisScale{valuer.NewString("auto")} // default
AxisScaleLinear = AxisScale{valuer.NewString("linear")}
AxisScaleLog = AxisScale{valuer.NewString("log")}
AxisScaleSymlog = AxisScale{valuer.NewString("symlog")}
)
func (AxisScale) Enum() []any {
return []any{AxisScaleAuto, AxisScaleLinear, AxisScaleLog, AxisScaleSymlog}
}
func (as AxisScale) ValueOrDefault() string {
if as.IsZero() {
return AxisScaleAuto.StringValue()
}
return as.StringValue()
}
func (as AxisScale) MarshalJSON() ([]byte, error) {
return json.Marshal(as.ValueOrDefault())
}
func (as *AxisScale) UnmarshalJSON(data []byte) error {
var v string
if err := json.Unmarshal(data, &v); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid axis scale: must be a string, one of `auto`, `linear`, `log`, or `symlog`")
}
val := AxisScale{valuer.NewString(v)}
switch val {
case AxisScaleAuto, AxisScaleLinear, AxisScaleLog, AxisScaleSymlog:
*as = val
return nil
default:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid axis scale %q: must be `auto`, `linear`, `log`, or `symlog`", v)
}
}
// ThresholdAxis is the axis a scatter plot threshold line marks: `y` draws it
// horizontally, `x` vertically.
type ThresholdAxis struct{ valuer.String }
var (
ThresholdAxisX = ThresholdAxis{valuer.NewString("x")}
ThresholdAxisY = ThresholdAxis{valuer.NewString("y")} // default
)
func (ThresholdAxis) Enum() []any {
return []any{ThresholdAxisX, ThresholdAxisY}
}
func (ta ThresholdAxis) ValueOrDefault() string {
if ta.IsZero() {
return ThresholdAxisY.StringValue()
}
return ta.StringValue()
}
func (ta ThresholdAxis) MarshalJSON() ([]byte, error) {
return json.Marshal(ta.ValueOrDefault())
}
func (ta *ThresholdAxis) UnmarshalJSON(data []byte) error {
var v string
if err := json.Unmarshal(data, &v); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid threshold axis: must be a string, one of `x` or `y`")
}
val := ThresholdAxis{valuer.NewString(v)}
switch val {
case ThresholdAxisX, ThresholdAxisY:
*ta = val
return nil
default:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid threshold axis %q: must be `x` or `y`", v)
}
}
type FillMode struct{ valuer.String }
var (
@@ -854,6 +1031,53 @@ func (o *FillOpacity) UnmarshalJSON(data []byte) error {
return nil
}
const (
minPointDiameter = 2
maxPointDiameter = 40
)
// PointDiameter is a scatter dot's diameter in whole CSS pixels.
type PointDiameter int
func (PointDiameter) PrepareJSONSchema(s *jsonschema.Schema) error {
s.WithMinimum(minPointDiameter).WithMaximum(maxPointDiameter)
return nil
}
func (d *PointDiameter) UnmarshalJSON(data []byte) error {
var v int
if err := json.Unmarshal(data, &v); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid point diameter: must be a whole number between %d and %d", minPointDiameter, maxPointDiameter)
}
if v < minPointDiameter || v > maxPointDiameter {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid point diameter %d: must be a whole number between %d and %d", v, minPointDiameter, maxPointDiameter)
}
*d = PointDiameter(v)
return nil
}
const minPointOpacity = 0.1
// PointOpacity is a scatter dot's fill alpha. The floor keeps a dot visible.
type PointOpacity float64
func (PointOpacity) PrepareJSONSchema(s *jsonschema.Schema) error {
s.WithMinimum(minPointOpacity).WithMaximum(1)
return nil
}
func (o *PointOpacity) UnmarshalJSON(data []byte) error {
var v float64
if err := json.Unmarshal(data, &v); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid point opacity: must be a number between %v and 1", minPointOpacity)
}
if v < minPointOpacity || v > 1 {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid point opacity %v: must be between %v and 1", v, minPointOpacity)
}
*o = PointOpacity(v)
return nil
}
type SpanGaps struct {
FillOnlyBelow bool `json:"fillOnlyBelow" description:"Controls whether lines connect across null values. When false (default), all gaps are connected. When true, only gaps smaller than fillLessThan are connected."`
FillLessThan string `json:"fillLessThan" description:"The maximum gap size to connect when fillOnlyBelow is true. Gaps larger than this duration are left disconnected."`

View File

@@ -0,0 +1,134 @@
{
"display": {
"name": "service health",
"description": "throughput against tail latency per pod"
},
"variables": [],
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"display": {
"name": "throughput vs p99",
"description": ""
},
"plugin": {
"kind": "signoz/ScatterPlotPanel",
"spec": {
"visualization": {
"timePreference": "last_1_hr"
},
"dimensions": {
"x": "A.count()",
"y": "A.p99(duration_nano)",
"sizeBy": "A.countIf(has_error = true)",
"colorBy": ["k8s.namespace.name", "k8s.pod.name"]
},
"formatting": {
"columnUnits": {
"A.p99(duration_nano)": "ns"
},
"decimalPrecision": "1"
},
"axes": {
"x": {
"softMin": 0,
"softMax": null,
"scale": "log",
"label": "Throughput"
},
"y": {
"softMin": null,
"softMax": 1000,
"scale": "symlog",
"label": "p99 latency"
}
},
"chartAppearance": {
"points": {
"size": 8,
"minSize": 4,
"maxSize": 24,
"opacity": 0.1
}
},
"legend": {
"position": "right",
"mode": "list",
"customColors": {
"checkout": "#1f77b4"
}
},
"thresholds": [
{
"value": 300,
"unit": "ms",
"color": "#f00",
"label": "p99 SLO",
"axis": "y"
},
{
"value": 50,
"color": "#0f0",
"axis": "x"
}
]
}
},
"links": [],
"queries": [
{
"kind": "scalar",
"spec": {
"plugin": {
"kind": "signoz/BuilderQuery",
"spec": {
"name": "A",
"signal": "traces",
"aggregations": [
{
"expression": "count()"
},
{
"expression": "p99(duration_nano)"
},
{
"expression": "countIf(has_error = true)"
}
],
"groupBy": [
{
"name": "k8s.namespace.name"
},
{
"name": "k8s.pod.name"
}
]
}
}
}
}
]
}
}
},
"links": [],
"layouts": [
{
"kind": "Grid",
"spec": {
"items": [
{
"x": 0,
"y": 0,
"width": 12,
"height": 8,
"content": {
"$ref": "#/spec/panels/p1"
}
}
]
}
}
]
}

View File

@@ -266,6 +266,43 @@ type CompositeQuery struct {
Queries []QueryEnvelope `json:"queries"`
}
// ResultColumnKeys is what a scalar query yields per row, keyed the way the
// UI keys columns: an aggregation column is the query name, or
// queryName.expression when the query has several aggregations; a group
// column is a group-by key name.
type ResultColumnKeys struct {
Aggregations map[string]struct{}
Groups map[string]struct{}
}
// CollectResultColumnKeys reports false when a query's columns are only known
// at run time, as for ClickHouse SQL and PromQL. A disabled query yields no
// aggregation column but keeps its group columns, since a formula over it
// still carries them.
func (c *CompositeQuery) CollectResultColumnKeys() (ResultColumnKeys, bool) {
keys := ResultColumnKeys{Aggregations: map[string]struct{}{}, Groups: map[string]struct{}{}}
for _, envelope := range c.Queries {
if envelope.GetType() == QueryTypeClickHouseSQL || envelope.GetType() == QueryTypePromQL {
return ResultColumnKeys{}, false
}
for _, key := range envelope.GetGroupBy() {
keys.Groups[key.Name] = struct{}{}
}
if envelope.IsDisabled() {
continue
}
expressions := envelope.GetAggregationExpressions()
if len(expressions) <= 1 {
keys.Aggregations[envelope.GetQueryName()] = struct{}{}
continue
}
for _, expression := range expressions {
keys.Aggregations[envelope.GetQueryName()+"."+expression] = struct{}{}
}
}
return keys, true
}
// PrepareJSONSchema adds description to the CompositeQuery schema.
func (c *CompositeQuery) PrepareJSONSchema(schema *jsonschema.Schema) error {
schema.WithDescription("Composite query containing one or more query envelopes. Each query envelope specifies its type and corresponding spec.")

View File

@@ -225,6 +225,34 @@ func (q *QueryEnvelope) GetGroupBy() []GroupByKey {
return nil
}
// GetAggregationExpressions returns one entry per aggregation. A metric
// aggregation has no expression, so it contributes an empty entry.
func (q *QueryEnvelope) GetAggregationExpressions() []string {
switch spec := q.Spec.(type) {
case QueryBuilderTraceOperator:
expressions := make([]string, len(spec.Aggregations))
for i, aggregation := range spec.Aggregations {
expressions[i] = aggregation.Expression
}
return expressions
case QueryBuilderQuery[TraceAggregation]:
expressions := make([]string, len(spec.Aggregations))
for i, aggregation := range spec.Aggregations {
expressions[i] = aggregation.Expression
}
return expressions
case QueryBuilderQuery[LogAggregation]:
expressions := make([]string, len(spec.Aggregations))
for i, aggregation := range spec.Aggregations {
expressions[i] = aggregation.Expression
}
return expressions
case QueryBuilderQuery[MetricAggregation]:
return make([]string, len(spec.Aggregations))
}
return nil
}
// GetFilter returns the filter.
func (q *QueryEnvelope) GetFilter() *Filter {
switch spec := q.Spec.(type) {

View File

@@ -1903,3 +1903,63 @@ func TestQueryRangeRequest_StepIntervalForQuery(t *testing.T) {
})
}
}
func TestCompositeQueryCollectResultColumnKeys(t *testing.T) {
testCases := []struct {
scenario string
composite CompositeQuery
expectedKeys ResultColumnKeys
expectedKnown bool
}{
{
scenario: "a single aggregation is keyed by the query name",
composite: CompositeQuery{Queries: []QueryEnvelope{{Type: QueryTypeBuilder, Spec: QueryBuilderQuery[TraceAggregation]{
Name: "A",
Aggregations: []TraceAggregation{{Expression: "count()"}},
GroupBy: []GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "service.name"}}},
}}}},
expectedKeys: ResultColumnKeys{Aggregations: map[string]struct{}{"A": {}}, Groups: map[string]struct{}{"service.name": {}}},
expectedKnown: true,
},
{
scenario: "several aggregations are keyed by query name and expression",
composite: CompositeQuery{Queries: []QueryEnvelope{{Type: QueryTypeBuilder, Spec: QueryBuilderQuery[LogAggregation]{
Name: "A",
Aggregations: []LogAggregation{{Expression: "count()"}, {Expression: "p99(duration_nano)"}},
}}}},
expectedKeys: ResultColumnKeys{Aggregations: map[string]struct{}{"A.count()": {}, "A.p99(duration_nano)": {}}, Groups: map[string]struct{}{}},
expectedKnown: true,
},
{
scenario: "a disabled query yields no aggregation but keeps its groups for the formula over it",
composite: CompositeQuery{Queries: []QueryEnvelope{
{Type: QueryTypeBuilder, Spec: QueryBuilderQuery[MetricAggregation]{
Name: "A",
Disabled: true,
Aggregations: []MetricAggregation{{MetricName: "http.server.request.count"}},
GroupBy: []GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "service.name"}}},
}},
{Type: QueryTypeFormula, Spec: QueryBuilderFormula{Name: "F1", Expression: "A * 2"}},
}},
expectedKeys: ResultColumnKeys{Aggregations: map[string]struct{}{"F1": {}}, Groups: map[string]struct{}{"service.name": {}}},
expectedKnown: true,
},
{
scenario: "clickhouse columns are only known at run time",
composite: CompositeQuery{Queries: []QueryEnvelope{{Type: QueryTypeClickHouseSQL, Spec: ClickHouseQuery{
Name: "A",
Query: "SELECT 1",
}}}},
expectedKeys: ResultColumnKeys{},
expectedKnown: false,
},
}
for _, testCase := range testCases {
t.Run(testCase.scenario, func(t *testing.T) {
keys, known := testCase.composite.CollectResultColumnKeys()
assert.Equal(t, testCase.expectedKnown, known)
assert.Equal(t, testCase.expectedKeys, keys)
})
}
}

View File

@@ -13,3 +13,10 @@ SIGNOZ_E2E_BASE_URL=https://app.us.staging.signoz.cloud
# Test credentials (required only when SIGNOZ_E2E_BASE_URL is set — i.e. staging mode)
SIGNOZ_E2E_USERNAME=
SIGNOZ_E2E_PASSWORD=
# Base path mode. `pnpm env:start:base-path` serves SigNoz under /signoz, the
# way SIGNOZ_GLOBAL_EXTERNAL__URL deploys it, and writes this to .env.local;
# specs then run unchanged (see helpers/base-path.ts). The root and base path
# stacks share one database, so stop one and run `pnpm env:clean` before
# starting the other.
# SIGNOZ_E2E_BASE_PATH=/signoz

View File

View File

@@ -0,0 +1,55 @@
import pytest
from testcontainers.core.container import Network
from fixtures import types
from fixtures.auth import license_applier, register_admin
from fixtures.signoz import create_signoz
# The e2e stack served under a URL path prefix, as SIGNOZ_GLOBAL_EXTERNAL__URL
# deploys it. These shadow the root fixtures of the same name; the separate
# cache keys keep the two stacks from restoring each other's containers.
BASE_PATH = "/signoz"
@pytest.fixture(name="base_path", scope="package")
def base_path() -> str:
return BASE_PATH
@pytest.fixture(name="signoz", scope="package")
def signoz_base_path( # pylint: disable=too-many-arguments,too-many-positional-arguments
network: Network,
zeus: types.TestContainerDocker,
gateway: types.TestContainerDocker,
sqlstore: types.TestContainerSQL,
clickhouse: types.TestContainerClickhouse,
e2e_env: dict,
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
) -> types.SigNoz:
return create_signoz(
network=network,
zeus=zeus,
gateway=gateway,
sqlstore=sqlstore,
clickhouse=clickhouse,
request=request,
pytestconfig=pytestconfig,
cache_key="signoz_base_path",
env_overrides=e2e_env | {"SIGNOZ_GLOBAL_EXTERNAL__URL": f"http://localhost:8080{BASE_PATH}"},
)
@pytest.fixture(name="create_user_admin", scope="package")
def create_user_admin_base_path(signoz: types.SigNoz, request: pytest.FixtureRequest, pytestconfig: pytest.Config) -> types.Operation:
return register_admin(signoz, request, pytestconfig, cache_key="create_user_admin_base_path", base_path=BASE_PATH)
@pytest.fixture(name="apply_license", scope="package")
def apply_license_base_path(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
) -> types.Operation:
return license_applier(signoz, request, pytestconfig, cache_key="apply_license_base_path", base_path=BASE_PATH)

View File

@@ -0,0 +1,5 @@
# The root bootstrap's tests, collected again here so they resolve the
# base-path fixtures from this package's conftest.py.
from e2e.bootstrap.setup import test_setup, test_teardown
__all__ = ["test_setup", "test_teardown"]

View File

@@ -0,0 +1,45 @@
import pytest
from testcontainers.core.container import Network
from fixtures import types
from fixtures.signoz import create_signoz
@pytest.fixture(name="base_path", scope="package")
def base_path() -> str:
"""URL path prefix SigNoz is served under. Empty serves it at the root;
basepath/conftest.py overrides it."""
return ""
@pytest.fixture(name="e2e_env", scope="package")
def e2e_env() -> dict:
"""Environment every e2e SigNoz gets. Each Playwright worker logs in once,
and the default cap of 5 sessions per user evicts the oldest, which turns a
worker's requests into 401s mid-run."""
return {"SIGNOZ_TOKENIZER_OPAQUE_TOKEN_MAX__PER__USER": 100}
@pytest.fixture(name="signoz", scope="package")
def signoz( # pylint: disable=too-many-arguments,too-many-positional-arguments
network: Network,
zeus: types.TestContainerDocker,
gateway: types.TestContainerDocker,
sqlstore: types.TestContainerSQL,
clickhouse: types.TestContainerClickhouse,
tls: types.TLS,
e2e_env: dict,
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
) -> types.SigNoz:
return create_signoz(
network=network,
zeus=zeus,
gateway=gateway,
sqlstore=sqlstore,
clickhouse=clickhouse,
request=request,
pytestconfig=pytestconfig,
tls=tls,
env_overrides=e2e_env,
)

View File

@@ -15,11 +15,12 @@ def _env_file(pytestconfig: pytest.Config) -> Path:
return pytestconfig.rootpath / "e2e" / ".env.local"
def test_setup(
def test_setup( # pylint: disable=too-many-arguments,too-many-positional-arguments
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument
apply_license: types.Operation, # pylint: disable=unused-argument
seeder: types.TestContainerDocker,
base_path: str,
pytestconfig: pytest.Config,
) -> None:
"""Bring the backend up and write e2e coordinates to .env.local."""
@@ -33,6 +34,8 @@ def test_setup(
f.write(f"SIGNOZ_E2E_USERNAME={USER_ADMIN_EMAIL}\n")
f.write(f"SIGNOZ_E2E_PASSWORD={USER_ADMIN_PASSWORD}\n")
f.write(f"SIGNOZ_E2E_SEEDER_URL={seeder_cfg.base()}\n")
if base_path:
f.write(f"SIGNOZ_E2E_BASE_PATH={base_path}\n")
def test_teardown(

93
tests/e2e/drivers/kit.ts generated Normal file
View File

@@ -0,0 +1,93 @@
/**
* AI-owned. Generated and maintained by coding agents alongside the specs that
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
*/
import type { Browser, Page, TestType } from '@playwright/test';
import { newAdminContext } from '../helpers/auth';
export { expect, test as kit } from '../fixtures/auth';
export interface DriverContext {
page: Page;
}
type Fixtures = Record<string, unknown>;
/**
* Adds test-scoped fixtures to any test carrying `authedPage`. `T` is inferred
* from the argument: TypeScript cannot invert `T & { authedPage: Page }`, so
* constraining that way widens every existing fixture to `unknown`.
*/
export type Layer<Adds> = <T extends { authedPage: Page }, W extends object>(
base: TestType<T, W>,
) => TestType<T & Adds, W>;
export type WorkerLayer<Adds> = <T extends object, W extends object>(
base: TestType<T, W>,
) => TestType<T, W & Adds>;
/** A `withX` layer binding `build` to the authenticated page. */
export function driver<Name extends string, T>(
name: Name,
build: (ctx: DriverContext) => T,
): Layer<{ [K in Name]: T }> {
const fixture = {
// Playwright reads fixture dependencies from the source text, so
// `authedPage` must stay destructured literally.
[name]: async (
{ authedPage }: { authedPage: Page },
use: (value: T) => Promise<void>,
) => {
await use(build({ page: authedPage }));
},
};
return ((test: TestType<Fixtures, Fixtures>) =>
test.extend(fixture as never)) as Layer<{ [K in Name]: T }>;
}
/**
* A `withX` layer that seeds once per worker and tears down after. `open` and
* `close` run on a throwaway admin page: worker fixtures cannot reach
* `authedPage`.
*/
export function seeded<Name extends string, T>(
name: Name,
open: (ctx: { page: Page; workerIndex: number }) => Promise<T>,
close?: (ctx: { page: Page; value: T }) => Promise<void>,
options: { timeout?: number } = {},
): WorkerLayer<{ [K in Name]: T }> {
const fixture = {
[name]: [
async (
{ browser }: { browser: Browser },
use: (value: T) => Promise<void>,
workerInfo: { workerIndex: number },
) => {
const value = await onAdminPage(browser, (page) =>
open({ page, workerIndex: workerInfo.workerIndex }),
);
await use(value);
if (close) {
await onAdminPage(browser, (page) => close({ page, value }));
}
},
{ scope: 'worker', timeout: options.timeout },
],
};
return ((test: TestType<Fixtures, Fixtures>) =>
test.extend(fixture as never)) as WorkerLayer<{ [K in Name]: T }>;
}
/** Run `body` on a throwaway page authenticated as the admin. */
export async function onAdminPage<T>(
browser: Browser,
body: (page: Page) => Promise<T>,
): Promise<T> {
const ctx = await newAdminContext(browser);
try {
return await body(await ctx.newPage());
} finally {
await ctx.close();
}
}

392
tests/e2e/drivers/routing.ts generated Normal file
View File

@@ -0,0 +1,392 @@
/**
* AI-owned. Generated and maintained by coding agents alongside the specs that
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
*/
import { randomBytes } from 'crypto';
import { expect, type Locator, type Page } from '@playwright/test';
import { ADMIN } from '../helpers/auth';
import { authToken } from '../helpers/common';
import {
createDashboardViaApi,
deleteDashboardViaApi,
} from '../helpers/dashboards';
import { seedPodMetricsViaSeeder } from '../helpers/infra-monitoring';
import { gotoLogsExplorer } from '../helpers/logs-explorer';
import {
gotoTraceUntilLoaded,
type LargeTrace,
loadLargeTrace,
seedTracesViaSeeder,
} from '../helpers/trace-details';
import { driver, kit, seeded } from './kit';
// The react-router v5 -> v7 regression net. Router-sensitive mechanics (history
// depth, POP, same-url suppression, the `newTab` branch) live here so the specs
// read as the property they guard.
export const PATH = {
alerts: '/alerts',
dashboards: '/dashboard',
home: '/home',
k8s: '/infrastructure-monitoring/kubernetes',
login: '/login',
metricsSummary: '/metrics-explorer/summary',
metricsViews: '/metrics-explorer/views',
roles: '/settings/roles',
roleCreate: '/settings/roles/new',
services: '/services',
tracesExplorer: '/traces-explorer',
/** Matches no entry of `AppRoutes/routes.ts`. */
unknown: '/definitely-not-a-route',
} as const;
/** A resource attribute every seeded pod carries. */
const NAMESPACE_ATTR = 'k8s.namespace.name';
const RELATIVE_TIME_LABELS: Record<string, string> = {
'30m': 'Last 30 minutes',
'1h': 'Last 1 hour',
'6h': 'Last 6 hours',
};
/** Three browser projects share one backend, so names need more than a timestamp. */
function uniqueSuffix(): string {
return randomBytes(4).toString('hex');
}
/** Role names allow lowercase letters and hyphens only. */
export function uniqueAlphaSuffix(): string {
return [...randomBytes(6)]
.map((byte) => String.fromCharCode(97 + (byte % 26)))
.join('');
}
export function dashboardListParams(title: string): Record<string, string> {
return { search: title, columnKey: 'updatedAt', order: 'descend', page: '1' };
}
function groupByOf(url: URL): string {
return url.searchParams.get('groupBy') ?? '';
}
export interface Routing {
goto(path: string): Promise<void>;
reload(): Promise<void>;
goBack(): Promise<void>;
goForward(): Promise<void>;
/** Go back one entry and wait for `pathname`. */
goBackTo(pathname: string): Promise<void>;
url(): URL;
params(): Record<string, string>;
waitForPath(pathname: string): Promise<void>;
waitForParam(key: string, value: string): Promise<void>;
/** `history.length`: Chromium keeps it on POP and REPLACE, grows it on PUSH. */
historyDepth(): Promise<number>;
/** Complete the login form on the page as it stands, without navigating. */
login(): Promise<void>;
/** Sidebar entries are `div.nav-item`, not links. */
sidebarItem(label: string): Locator;
/** antd puts a `RouteTab` item's route on `data-node-key`. */
routeTab(route: string): Locator;
activeRouteTab(route: string): Locator;
/** The picker `TopNav` renders. Several pages mount their own. */
globalTimePicker(): Locator;
selectRelativeTime(value: string): Promise<void>;
expectRelativeTime(value: string): Promise<void>;
/** Ctrl/Cmd-click `target`, run `body` on the tab it opens, then close it. */
inNewTab(
target: Locator,
body: (tab: Routing) => Promise<void>,
): Promise<void>;
/** Hold every script requested during `body`, so lazy route chunks suspend. */
whileScriptsHeld(body: () => Promise<void>): Promise<void>;
gotoLogsExplorer(): Promise<void>;
dashboards: {
gotoList(title?: string): Promise<void>;
/** Not `dashboard-title-0`: the search tokenises on hyphens and matches leftovers. */
row(title: string): Locator;
landmark(): Locator;
title(): Locator;
};
k8s: {
/** Open the pods list once its mount-time `compositeQuery` rewrite landed. */
goto(params: Record<string, string>): Promise<void>;
/** One nuqs push: `groupBy`, `page` and `orderBy` flush together. */
groupBy(attribute: string): Promise<void>;
waitUntilGrouped(attribute: string, grouped?: boolean): Promise<void>;
groupBySelect(): Locator;
};
trace: {
path(trace: LargeTrace): string;
goto(trace: LargeTrace): Promise<void>;
rootRow(trace: LargeTrace): Locator;
back(): Promise<void>;
};
roles: {
/** Fill the create form far enough to arm the navigation blocker. */
openCreateForm(name: string): Promise<void>;
nameInput(): Locator;
cancel(): Promise<void>;
save(): Promise<void>;
discardDialog(): Locator;
settings(): Locator;
removeByName(names: string[]): Promise<void>;
};
}
export function routingDriver(page: Page): Routing {
const timePicker = (): Locator => page.getByTestId('dropDown');
const routing: Routing = {
goto: async (path) => {
await page.goto(path);
},
reload: async () => {
await page.reload();
},
goBack: async () => {
await page.goBack();
},
goForward: async () => {
await page.goForward();
},
goBackTo: async (pathname) => {
await page.goBack();
await routing.waitForPath(pathname);
},
url: () => new URL(page.url()),
params: () => Object.fromEntries(routing.url().searchParams),
waitForPath: (pathname) =>
page.waitForURL((url) => url.pathname === pathname),
waitForParam: (key, value) =>
page.waitForURL((url) => url.searchParams.get(key) === value),
historyDepth: () =>
page.evaluate(
() =>
(globalThis as unknown as { history: { length: number } }).history.length,
),
login: async () => {
await page.getByTestId('email').fill(ADMIN.email);
await page.getByTestId('initiate_login').click();
await page.getByTestId('password').fill(ADMIN.password);
await page.getByTestId('password_authn_submit').click();
},
sidebarItem: (label) =>
page.locator('.nav-item').filter({ hasText: new RegExp(`^${label}$`) }),
routeTab: (route) => page.locator(`.ant-tabs-tab[data-node-key="${route}"]`),
activeRouteTab: (route) =>
routing.routeTab(route).getByRole('tab', { selected: true }),
globalTimePicker: () =>
page.locator('.top-nav-container').getByTestId('dropDown'),
// A click before the picker is interactive is dropped silently, so retry the
// open. The popover unmounts on close, which is what proves the selection
// committed: the url may legitimately stay the same.
selectRelativeTime: async (value) => {
const option = page.getByTestId(`time-option-${value}`);
await expect(async () => {
if (!(await option.isVisible())) {
await timePicker().click();
}
await expect(option).toBeVisible({ timeout: 3_000 });
}).toPass({ timeout: 30_000 });
await option.click();
await expect(option).toBeHidden();
},
expectRelativeTime: async (value) => {
await expect(timePicker()).toHaveAccessibleName(RELATIVE_TIME_LABELS[value]);
},
inNewTab: async (target, body) => {
const [tab] = await Promise.all([
page.context().waitForEvent('page'),
target.click({ modifiers: ['ControlOrMeta'] }),
]);
try {
await tab.waitForLoadState();
await body(routingDriver(tab));
} finally {
await tab.close();
}
},
// The handler stays installed after the release: `unroute` drops the
// requests still parked in it.
whileScriptsHeld: async (body) => {
let release = (): void => {};
const held = new Promise<void>((resolve) => {
release = resolve;
});
await page.route('**/*.js', async (route) => {
await held;
await route.continue();
});
try {
await body();
} finally {
release();
}
},
gotoLogsExplorer: () => gotoLogsExplorer(page),
dashboards: {
gotoList: async (title) => {
if (!title) {
await page.goto(PATH.dashboards);
await expect(routing.dashboards.landmark()).toBeVisible();
return;
}
const search = new URLSearchParams(dashboardListParams(title));
await page.goto(`${PATH.dashboards}?${search.toString()}`);
await expect(routing.dashboards.row(title)).toBeVisible();
},
row: (title) =>
page
.locator('[data-testid^="dashboard-title-"]')
.filter({ hasText: title }),
// The views rail renders whether or not the list is empty.
landmark: () => page.getByTestId('dashboards-view-search'),
title: () => page.getByTestId('dashboard-title'),
},
k8s: {
goto: async (params) => {
const search = new URLSearchParams({ category: 'pods', ...params });
await page.goto(`${PATH.k8s}?${search.toString()}`);
await page.waitForURL((url) => url.searchParams.has('compositeQuery'));
await expect(routing.k8s.groupBySelect()).toBeVisible();
},
// The search input stays readonly until the dropdown opens, and `hasText`
// would also match longer keys, so type after opening and match the title.
groupBy: async (attribute) => {
await routing.k8s.groupBySelect().click();
await page.keyboard.type(attribute);
await page.locator(`.ant-select-item-option[title="${attribute}"]`).click();
await page.keyboard.press('Escape');
await routing.k8s.waitUntilGrouped(attribute);
},
waitUntilGrouped: (attribute, grouped = true) =>
page.waitForURL((url) => groupByOf(url).includes(attribute) === grouped),
groupBySelect: () => page.getByTestId('k8s-table-group-by-select'),
},
trace: {
path: (trace) => `/trace/${trace.traceId}`,
goto: (trace) =>
gotoTraceUntilLoaded(
page,
routing.trace.path(trace),
`cell-0-${trace.landmarks.root}`,
),
rootRow: (trace) => page.getByTestId(`cell-0-${trace.landmarks.root}`),
back: async () => {
await page.getByRole('button', { name: 'Back' }).click();
},
},
roles: {
openCreateForm: async (name) => {
await page.goto(PATH.roleCreate);
await routing.roles.nameInput().fill(name);
await expect(page.getByText('Unsaved changes')).toBeVisible();
},
nameInput: () => page.getByTestId('role-name-input'),
cancel: async () => {
await page.getByTestId('cancel-button').click();
},
save: async () => {
await page.getByTestId('save-button').click();
},
discardDialog: () => page.getByTestId('discard-changes-dialog'),
settings: () => page.getByTestId('roles-settings'),
removeByName: async (names) => {
const headers = { Authorization: `Bearer ${await authToken(page)}` };
const res = await page.request.get('/api/v1/roles', { headers });
const { data } = (await res.json()) as {
data: { id: string; name: string }[];
};
await Promise.all(
data
.filter((role) => names.includes(role.name))
.map((role) =>
page.request.delete(`/api/v1/roles/${role.id}`, { headers }),
),
);
},
},
};
return routing;
}
// The seeder serves inserts on one ClickHouse session, so parallel workers
// collide with a transient `500 concurrent queries within the same session`.
const SEED_RETRY_MS = 120_000;
const SEED_TIMEOUT_MS = 180_000;
async function seedWithRetry(seed: () => Promise<void>): Promise<void> {
await expect(seed).toPass({
timeout: SEED_RETRY_MS,
intervals: [1_000, 2_000, 5_000],
});
}
const withRouting = driver('routing', ({ page }) => routingDriver(page));
const withDashboard = seeded(
'dashboard',
async ({ page }) => {
const title = `routing-${uniqueSuffix()}`;
const id = await createDashboardViaApi(page, title);
return { id, title, path: `${PATH.dashboards}/${id}` };
},
async ({ page, value }) =>
deleteDashboardViaApi(page.request, value.id, await authToken(page)),
);
const withTrace = seeded(
'largeTrace',
async ({ page }) => {
const trace = loadLargeTrace();
await seedWithRetry(() => seedTracesViaSeeder(page.request, trace.spans));
return trace;
},
undefined,
{ timeout: SEED_TIMEOUT_MS },
);
// Group-by options come from `/fields/keys` over the queried window, so the
// select stays empty until pod metrics exist inside it.
const withPods = seeded(
'pods',
async ({ page }) => {
await seedWithRetry(() => seedPodMetricsViaSeeder(page));
return { groupBy: NAMESPACE_ATTR };
},
undefined,
{ timeout: SEED_TIMEOUT_MS },
);
export const test = withPods(
withTrace(withDashboard(withRouting(kit))),
).extend<{
/** The routing driver on an unauthenticated page. */
guest: Routing;
}>({
guest: async ({ browser }, use) => {
const ctx = await browser.newContext();
await use(routingDriver(await ctx.newPage()));
await ctx.close();
},
});
export { expect, onAdminPage } from './kit';

View File

@@ -1,27 +1,42 @@
import { test as base, expect, type Page } from '@playwright/test';
import { ADMIN, storageStateFor, type User } from '../helpers/auth';
import { installBasePathMode } from '../helpers/base-path';
// The login flow and the per-worker session cache live in `helpers/auth.ts` so
// worker-scoped fixtures and suite hooks share one login with this fixture.
export { ADMIN };
export type { User };
export const test = base.extend<{
/**
* User identity for this test. Override with `test.use({ user: ... })` at
* the describe or test level to run the suite as a different user.
* Defaults to ADMIN (the pytest-bootstrap-seeded admin).
*/
user: User;
export const test = base.extend<
{
/**
* User identity for this test. Override with `test.use({ user: ... })` at
* the describe or test level to run the suite as a different user.
* Defaults to ADMIN (the pytest-bootstrap-seeded admin).
*/
user: User;
/**
* A Page whose context is already authenticated as `user`. First request
* for a given user triggers one login per worker; the resulting
* storageState is held in memory and reused for all later requests.
*/
authedPage: Page;
},
{
/** Applies SIGNOZ_E2E_BASE_PATH before any page or login in the worker. */
basePathMode: void;
}
>({
basePathMode: [
async ({ browser }, use) => {
await installBasePathMode(browser);
await use();
},
{ scope: 'worker', auto: true },
],
/**
* A Page whose context is already authenticated as `user`. First request
* for a given user triggers one login per worker; the resulting
* storageState is held in memory and reused for all later requests.
*/
authedPage: Page;
}>({
user: [ADMIN, { option: true }],
authedPage: async ({ browser, user }, use) => {

View File

@@ -72,6 +72,7 @@ export async function gotoAlertList(
await page.goto(`${ALERTS_LIST_PATH}?${query.toString()}`);
await expect(page.getByTestId('list-alerts-search-input')).toBeVisible();
if (expectRows) {
await expect(alertRuleRows(page).first()).toBeVisible();
// Skeleton rows are empty and ignore clicks; wait for a loaded one.
await expect(alertRuleRows(page).first()).toHaveText(/\S/);
}
}

View File

@@ -1,5 +1,7 @@
import type { Browser, BrowserContext, Page } from '@playwright/test';
import { storageKey } from './base-path';
export type User = { email: string; password: string };
/** Default user — admin from the pytest bootstrap (.env.local) or staging .env. */
@@ -57,7 +59,8 @@ async function login(page: Page, user: User): Promise<void> {
async function pinSidenav(page: Page): Promise<void> {
const token = await page.evaluate(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
() => (globalThis as any).localStorage.getItem('AUTH_TOKEN') || '',
(key) => (globalThis as any).localStorage.getItem(key) || '',
storageKey('AUTH_TOKEN'),
);
const res = await page.request.put('/api/v1/user/preferences/sidenav_pinned', {
data: { value: true },

View File

@@ -0,0 +1,105 @@
import type { APIRequestContext, Browser, Page } from '@playwright/test';
// Base path mode: with SIGNOZ_E2E_BASE_PATH set (`pnpm env:start:base-path`
// writes `/signoz`), specs run unchanged against SigNoz served under that URL
// prefix. `page.goto`, `page.waitForURL` string targets and `request.*` take
// app paths and get the prefix; `page.url()` and `waitForURL` predicates see
// the URL without it. `expect(page).toHaveURL` still sees the real URL.
export const BASE_PATH = (process.env.SIGNOZ_E2E_BASE_PATH ?? '').replace(
/\/$/,
'',
);
/** The app scopes its localStorage keys to the base path (`utils/storage.ts`). */
export function storageKey(key: string): string {
return BASE_PATH ? `${BASE_PATH}/${key}` : key;
}
function toServedPath(path: string): string {
return path.startsWith('/') && !path.startsWith('//')
? `${BASE_PATH}${path}`
: path;
}
function toAppUrl(href: string): string {
const url = new URL(href);
if (url.pathname === BASE_PATH || url.pathname.startsWith(`${BASE_PATH}/`)) {
url.pathname = url.pathname.slice(BASE_PATH.length) || '/';
}
return url.toString();
}
function patchMethod<T extends object, K extends keyof T>(
proto: T,
name: K,
wrap: (original: T[K]) => T[K],
): void {
proto[name] = wrap(proto[name]);
}
let installed = false;
/**
* Patches the Page and APIRequestContext prototypes once per worker. Playwright
* exports neither class, so the prototypes come from a throwaway page.
*/
export async function installBasePathMode(browser: Browser): Promise<void> {
if (!BASE_PATH || installed) {
return;
}
installed = true;
const context = await browser.newContext();
const page = await context.newPage();
const pageProto: Page = Object.getPrototypeOf(page);
const requestProto: APIRequestContext = Object.getPrototypeOf(page.request);
await context.close();
patchMethod(
pageProto,
'goto',
(goto) =>
function (this: Page, target, options) {
return goto.call(this, toServedPath(target), options);
},
);
patchMethod(
pageProto,
'url',
(url) =>
function (this: Page) {
return toAppUrl(url.call(this));
},
);
patchMethod(
pageProto,
'waitForURL',
(waitForURL) =>
function (this: Page, matcher, options) {
let appMatcher = matcher;
if (typeof matcher === 'function') {
appMatcher = (current: URL): boolean =>
matcher(new URL(toAppUrl(current.href)));
} else if (typeof matcher === 'string') {
appMatcher = toServedPath(matcher);
}
return waitForURL.call(this, appMatcher, options);
},
);
patchMethod(
requestProto,
'fetch',
(fetch) =>
function (this: APIRequestContext, target, options) {
return fetch.call(
this,
typeof target === 'string' ? toServedPath(target) : target,
options,
);
},
);
}

View File

@@ -1,5 +1,7 @@
import type { Page, Request } from '@playwright/test';
import { storageKey } from './base-path';
// Shared helpers used across feature-specific helper modules (dashboards,
// trace-details, …). Keep this to genuinely cross-feature utilities.
@@ -45,6 +47,8 @@ const HARNESS_CONSOLE_NOISE = [
'Request failed with status code 404',
'client never received a response, or request never left',
'ErrorResponseHandler: unclassified error',
// Google Fonts, which the chromium project fails at DNS (playwright.config.ts).
'Failed to load resource: net::ERR_NAME_NOT_RESOLVED',
];
export interface ConsoleWatch {
@@ -130,7 +134,9 @@ export function requestUrl(request: Request): URL {
export async function authToken(page: Page): Promise<string> {
const state = await page.context().storageState();
for (const origin of state.origins) {
const entry = origin.localStorage.find((e) => e.name === 'AUTH_TOKEN');
const entry = origin.localStorage.find(
(e) => e.name === storageKey('AUTH_TOKEN'),
);
if (entry) {
return entry.value;
}

View File

@@ -11,6 +11,8 @@ import apmMetricsTemplate from '../testdata/apm-metrics.json';
import chartDataTemplate from '../testdata/chart-data-dashboard.json';
import variablesTemplate from '../testdata/variables-dashboard.json';
import { storageKey } from './base-path';
// ─── Constants ───────────────────────────────────────────────────────────
//
// UI strings and well-known values referenced both within this file and by
@@ -45,7 +47,8 @@ export async function authToken(page: Page): Promise<string> {
}
return page.evaluate(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
() => (globalThis as any).localStorage.getItem('AUTH_TOKEN') || '',
(key) => (globalThis as any).localStorage.getItem(key) || '',
storageKey('AUTH_TOKEN'),
);
}
@@ -82,12 +85,39 @@ async function postDashboard(
return json.data.id;
}
/** Seed a minimally-named dashboard via API. Returns the new ID. */
/**
* Seed a minimally-named dashboard via API. Returns the new ID.
*
* v2 (Perses spec) only: `POST /api/v1/dashboards` now answers 501
* `dashboard_deprecated`. Payload mirrors what the New-dashboard modal sends
* (`BlankDashboardPanel.tsx`), `schemaVersion` included.
*/
export async function createDashboardViaApi(
page: Page,
title: string,
): Promise<string> {
return postDashboard(page, { title, uploadedGrafana: false });
const token = await authToken(page);
const res = await page.request.post('/api/v2/dashboards', {
data: {
schemaVersion: 'v6',
generateName: true,
tags: null,
spec: {
display: { name: title },
layouts: [],
panels: {},
variables: [],
},
},
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok()) {
throw new Error(
`POST /api/v2/dashboards ${res.status()}: ${await res.text()}`,
);
}
const json = (await res.json()) as { data: { id: string } };
return json.data.id;
}
/**
@@ -342,7 +372,7 @@ export async function deleteDashboardViaApi(
token: string,
): Promise<void> {
await request
.delete(`/api/v1/dashboards/${id}`, {
.delete(`/api/v2/dashboards/${id}`, {
headers: { Authorization: `Bearer ${token}` },
})
.catch(() => undefined);

View File

@@ -0,0 +1,110 @@
import type { Page } from '@playwright/test';
import { seederUrl } from './common';
// Seeding for the Kubernetes lists under /infrastructure-monitoring.
/**
* The metric the pods list queries. Its rows are what put the pod resource
* attributes into `/fields/keys`, which is where the group-by select gets its
* options, so this is the metric to seed, not an arbitrary one.
*
* Dotted, not `k8s_pod_cpu_usage`: the list passes this name straight through as
* `metricNamespace` and the backend matches it literally, so the underscored
* spelling returns an empty key set and the group-by select renders "No data".
*/
const POD_CPU_METRIC = 'k8s.pod.cpu.usage';
const POD_MEMORY_METRIC = 'k8s.pod.memory.usage';
const NAMESPACES = ['default', 'kube-system', 'signoz'];
const PODS_PER_NAMESPACE = 2;
const POINTS_PER_POD = 4;
const POINT_INTERVAL_MS = 60_000;
type SeededMetric = {
metric_name: string;
timestamp: string;
value: number;
type_: string;
temporality: string;
is_monotonic: boolean;
unit: string;
resource_attrs: Record<string, string>;
labels: Record<string, string>;
};
function podResourceAttrs(
namespace: string,
pod: string,
): Record<string, string> {
return {
'k8s.cluster.name': 'e2e-cluster',
'k8s.namespace.name': namespace,
'k8s.node.name': `node-${namespace}`,
'k8s.pod.name': pod,
'k8s.pod.uid': `${namespace}-${pod}`,
};
}
function podPoints(now: number): SeededMetric[] {
const points: SeededMetric[] = [];
for (const namespace of NAMESPACES) {
for (let p = 0; p < PODS_PER_NAMESPACE; p += 1) {
const pod = `${namespace}-pod-${p}`;
const resourceAttrs = podResourceAttrs(namespace, pod);
for (let i = 0; i < POINTS_PER_POD; i += 1) {
const timestamp = new Date(
now - (POINTS_PER_POD - i) * POINT_INTERVAL_MS,
).toISOString();
points.push({
metric_name: POD_CPU_METRIC,
timestamp,
value: 0.1 + i * 0.05,
type_: 'Gauge',
temporality: 'Unspecified',
is_monotonic: false,
unit: '1',
resource_attrs: resourceAttrs,
labels: resourceAttrs,
});
points.push({
metric_name: POD_MEMORY_METRIC,
timestamp,
value: 100_000_000 + i * 1_000_000,
type_: 'Gauge',
temporality: 'Unspecified',
is_monotonic: false,
unit: 'By',
resource_attrs: resourceAttrs,
labels: resourceAttrs,
});
}
}
}
return points;
}
/**
* Seed pod metrics so the k8s list has rows and its group-by select has
* options. Both matter: with no rows inside the queried window the select
* renders empty and every `setK8sGroupBy` call times out.
*
* The pod keys go in `labels` as well as `resource_attrs`: the list's queries
* reference some of them as `tag` and others as `resource`, and the series
* fingerprint is computed off labels alone. With an empty label map all six
* pods collapse into one series and the list comes back empty.
*
* Wrap the call in `seedWithRetry`: the seeder serves inserts on one
* ClickHouse session, so parallel workers collide with a transient 500.
*/
export async function seedPodMetricsViaSeeder(page: Page): Promise<void> {
const res = await page.request.post(`${seederUrl()}/telemetry/metrics`, {
data: podPoints(Date.now()),
headers: { 'Content-Type': 'application/json' },
});
if (!res.ok()) {
throw new Error(
`seeder POST /telemetry/metrics ${res.status()}: ${await res.text()}`,
);
}
}

View File

@@ -6,6 +6,7 @@ export const LOGS_EXPLORER_PATH = '/logs/logs-explorer';
const SETTINGS_PANEL_TITLE = 'Edit quick filters';
const SETTINGS_ICON_TEST_ID = 'settings-icon';
const SETTINGS_CLOSE_TEST_ID = 'quick-filters-settings-close';
const ANNOUNCEMENT_DISMISSED_KEY = 'QUICK_FILTERS_SETTINGS_ANNOUNCEMENT';
/**
* Conservative lower bound for the open panel width. The panel is 342px in
@@ -127,7 +128,7 @@ export async function assertQuickFiltersSettingsDrawerOpen(
await expect(panel).not.toHaveClass(/hidden/);
await expect(page.getByText(SETTINGS_PANEL_TITLE)).toBeVisible();
await expect(page.getByPlaceholder('Search for a filter...')).toBeVisible();
await expect(panel.locator('.qf-header-icon')).toBeVisible();
await expect(panel.getByTestId(SETTINGS_CLOSE_TEST_ID)).toBeVisible();
const addedList = panel.locator('.qf-added-filters-list');
await expect(panel.getByText('ADDED FILTERS')).toBeVisible();
@@ -161,6 +162,6 @@ export async function assertQuickFiltersSettingsDrawerOpen(
export async function closeQuickFiltersSettingsPanel(
page: Page,
): Promise<void> {
await settingsPanel(page).locator('.qf-header-icon').click();
await settingsPanel(page).getByTestId(SETTINGS_CLOSE_TEST_ID).click();
await assertQuickFiltersSettingsDrawerClosed(page);
}

View File

@@ -7,6 +7,8 @@
"preinstall": "npx only-allow pnpm",
"env:start": "cd .. && uv run pytest --basetemp=./tmp/ -vv --reuse --rebuild --capture=no --with-web e2e/bootstrap/setup.py::test_setup",
"env:stop": "cd .. && uv run pytest --basetemp=./tmp/ -vv --teardown --capture=no e2e/bootstrap/setup.py::test_teardown",
"env:start:base-path": "cd .. && uv run pytest --basetemp=./tmp/ -vv --reuse --rebuild --capture=no --with-web e2e/bootstrap/basepath/setup.py::test_setup",
"env:stop:base-path": "cd .. && uv run pytest --basetemp=./tmp/ -vv --teardown --capture=no e2e/bootstrap/basepath/setup.py::test_teardown",
"env:clean": "rm -rf ../tmp ../.pytest_cache .env.local artifacts && echo 'Cleaned. Run docker container prune if needed.'",
"test": "playwright test",
"test:local": "pnpm env:start && pnpm test",

View File

@@ -108,7 +108,15 @@ export default defineConfig({
},
{
name: 'chromium',
use: devices['Desktop Chrome'],
use: {
...devices['Desktop Chrome'],
launchOptions: {
// Google Fonts CSS blocks the app script and `load` until it settles,
// so a stalled request blanks the page. Failing DNS settles it at once;
// a `route` would too, but routing turns off the HTTP cache.
args: ['--host-resolver-rules=MAP fonts.googleapis.com ~NOTFOUND'],
},
},
dependencies: ['setup'],
},
{

View File

@@ -26,6 +26,7 @@ import {
v2SaveTooltip,
v2TestButton,
} from '../../../helpers/alert-forms/v2';
import { BASE_PATH } from '../../../helpers/base-path';
// CV2-* — the v2 create builder.
//
@@ -446,7 +447,7 @@ test.describe('Alert create — v2 builder', () => {
await ownedRules.register(response);
expect(response.status()).toBe(201);
expect(new URL(response.url()).pathname).toBe('/api/v2/rules');
expect(new URL(response.url()).pathname).toBe(`${BASE_PATH}/api/v2/rules`);
const body = response.request().postDataJSON();
expect(body.schemaVersion).toBe('v2alpha1');

View File

@@ -15,7 +15,8 @@ test.describe('Alert details — page chrome', () => {
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
await gotoAlertHistory(page, alertHistory.ruleId);
const expected = page.url();
// The served URL: base path mode strips the prefix from `page.url()`.
const expected = page.mainFrame().url();
await page.getByRole('button', { name: 'Copy link' }).click();
await expect(page.getByText('Copied')).toBeVisible();

View File

@@ -27,7 +27,8 @@ test.describe('Alerts page shell', () => {
await page.goto(ALERTS_LIST_PATH);
// No `tab` param at all — `getActiveKey()` falls back to AlertRules.
await expect(page).toHaveURL(ALERTS_LIST_PATH);
await expect(page).toHaveURL(new RegExp(`${ALERTS_LIST_PATH}(\\?|$)`));
await expect(page).not.toHaveURL(/[?&]tab=/);
await expect(
page.getByRole('tab', { name: TAB_NAMES.rules }),
).toHaveAttribute('aria-selected', 'true');

View File

@@ -0,0 +1,45 @@
import { expect, PATH, test } from '../../drivers/routing';
// `<Redirect>` replaces the history entry and v7's `<Navigate>` pushes unless
// told otherwise, which only shows when someone presses Back. Runs as `guest`:
// an authenticated page would skip the chain under test.
test.describe('Routing — auth guards', () => {
test('TC-14 a deep-linked private route survives the login round trip', async ({
guest,
}) => {
await guest.goto(PATH.services);
await guest.waitForPath(PATH.login);
await guest.login();
// Private.tsx stashed the requested path and redirects back to it.
await guest.waitForPath(PATH.services);
expect(guest.url().pathname).toBe(PATH.services);
});
test('TC-15 the login redirects replace, so Back does not re-run the chain', async ({
guest,
}) => {
const depthBeforeGoto = await guest.historyDepth();
await guest.goto(PATH.services);
await guest.waitForPath(PATH.login);
// A pushing redirect would read one higher.
const depthAtLogin = await guest.historyDepth();
expect(depthAtLogin).toBe(depthBeforeGoto + 1);
await guest.login();
await guest.waitForPath(PATH.services);
expect(await guest.historyDepth()).toBe(depthAtLogin + 1);
// Back reaches the replaced /login entry, where being logged in redirects
// straight out again.
await guest.goBack();
await expect
.poll(() => guest.url().pathname, { timeout: 15_000 })
.not.toBe(PATH.login);
expect(guest.url().pathname).toBe(PATH.services);
});
});

View File

@@ -0,0 +1,49 @@
import { expect, PATH, test } from '../../drivers/routing';
// The shape of the router mount, which no other spec observes.
test.describe('Routing: router mount', () => {
// Under a React transition the previous screen stays up instead of the
// Suspense fallback, so an uncached route renders no loader at all.
test('TC-18 an in-app navigation to an uncached route commits the Suspense fallback', async ({
authedPage: page,
routing,
}) => {
await routing.dashboards.gotoList();
await routing.whileScriptsHeld(async () => {
await routing.sidebarItem('Alerts').click();
await routing.waitForPath(PATH.alerts);
await expect(routing.dashboards.landmark()).toBeHidden();
await expect(
page.getByRole('img', { name: 'loading' }).first(),
).toBeVisible();
});
await expect(
page
.getByRole('heading', { name: 'Alert Rules' })
.or(page.getByTestId('list-alerts-search-input')),
).toBeVisible();
});
// nuqs' react-router adapter needs a router above it; the wrong order throws
// on the first page that reads a nuqs param.
test('TC-19 a page with nuqs params mounts with no router-context error', async ({
authedPage: page,
routing,
}) => {
const reported: string[] = [];
page.on('pageerror', (error) => reported.push(error.message));
page.on('console', (message) => reported.push(message.text()));
await routing.k8s.goto({ relativeTime: '30m' });
expect(
reported.filter((text) =>
/useNavigate|useSearchParams|context of a <Router>/i.test(text),
),
).toEqual([]);
});
});

View File

@@ -0,0 +1,62 @@
import {
expect,
onAdminPage,
PATH,
routingDriver,
test,
uniqueAlphaSuffix,
} from '../../drivers/routing';
// `/settings/roles/new` is the only `useNavigationBlocker` consumer, so this is
// the whole observable surface of the blocker protocol.
// Both tests drive the create form; keep them off each other's role names.
test.describe.configure({ mode: 'serial' });
const BLOCKED_ROLE_NAME = `routing-blocker-${uniqueAlphaSuffix()}`;
const SAVED_ROLE_NAME = `routing-bypass-${uniqueAlphaSuffix()}`;
test.describe('Routing — unsaved-changes navigation blocker', () => {
test.afterAll(async ({ browser }) => {
await onAdminPage(browser, (page) =>
routingDriver(page).roles.removeByName([BLOCKED_ROLE_NAME, SAVED_ROLE_NAME]),
);
});
test('TC-16 a blocked navigation can be cancelled and then confirmed', async ({
routing,
}) => {
const { roles } = routing;
await roles.openCreateForm(BLOCKED_ROLE_NAME);
await roles.cancel();
await expect(roles.discardDialog()).toBeVisible();
// Cancel drops the blocked transition rather than retrying it.
await roles
.discardDialog()
.getByRole('button', { name: 'Keep editing' })
.click();
await expect(roles.discardDialog()).toBeHidden();
expect(routing.url().pathname).toBe(PATH.roleCreate);
await expect(roles.nameInput()).toHaveValue(BLOCKED_ROLE_NAME);
await roles.cancel();
await roles.discardDialog().getByRole('button', { name: 'Discard' }).click();
await routing.waitForPath(PATH.roles);
});
test('TC-17 saving bypasses the blocker for the next navigation', async ({
routing,
}) => {
const { roles } = routing;
await roles.openCreateForm(SAVED_ROLE_NAME);
// `allowNextNavigation()` unarms the blocker for the push after a save.
await roles.save();
await routing.waitForPath(PATH.roles);
await expect(roles.discardDialog()).toHaveCount(0);
await expect(roles.settings()).toBeVisible();
});
});

View File

@@ -0,0 +1,87 @@
import { dashboardListParams, expect, PATH, test } from '../../drivers/routing';
// Imperative navigation: the `history.listen` counter behind `hasInAppHistory()`,
// `useSafeNavigate`'s same-url suppression, and its `newTab` branch.
test.describe('Routing — imperative navigation', () => {
test('TC-05 Back from a detail page restores the list with its params', async ({
routing,
dashboard,
}) => {
await routing.dashboards.gotoList(dashboard.title);
await routing.dashboards.row(dashboard.title).click();
await routing.waitForPath(dashboard.path);
await routing.goBackTo(PATH.dashboards);
expect(routing.params()).toMatchObject(dashboardListParams(dashboard.title));
});
// Both branches of the trace header's Back button in one document:
// `hasInAppHistory() ? goBack() : push(TRACES_EXPLORER)`.
test('TC-06 the trace-details previous button pushes when deep-linked and pops once in-app', async ({
routing,
largeTrace: trace,
}) => {
await routing.goto(PATH.services);
await routing.trace.goto(trace);
// The document load reset the counter, so Back pushes the explorer.
const depthBeforePush = await routing.historyDepth();
await routing.trace.back();
await routing.waitForPath(PATH.tracesExplorer);
expect(routing.url().searchParams.has('selectedExplorerView')).toBe(false);
expect(await routing.historyDepth()).toBe(depthBeforePush + 1);
// That push is in-app history, so after a POP back Back pops too.
await routing.goBackTo(routing.trace.path(trace));
await expect(routing.trace.rootRow(trace)).toBeVisible();
const depthBeforePop = await routing.historyDepth();
await routing.trace.back();
await routing.waitForPath(PATH.services);
expect(await routing.historyDepth()).toBe(depthBeforePop);
});
test('TC-07 re-selecting the current relative time adds no history entry', async ({
routing,
}) => {
test.slow();
await routing.k8s.goto({ relativeTime: '30m' });
await routing.expectRelativeTime('30m');
const before = routing.params();
const depthBefore = await routing.historyDepth();
await routing.selectRelativeTime('30m');
// `areUrlsEffectivelySame` ignores the regenerated compositeQuery `id`. Not
// followed by "one Back leaves the page": the k8s list pushes its own
// compositeQuery rewrite on mount.
expect(await routing.historyDepth()).toBe(depthBefore);
expect(routing.url().pathname).toBe(PATH.k8s);
expect(routing.params()).toMatchObject({
relativeTime: '30m',
category: before.category,
});
await routing.expectRelativeTime('30m');
});
test('TC-08 modifier-clicking an in-app link opens the same url in a new tab', async ({
routing,
dashboard,
}) => {
await routing.dashboards.gotoList(dashboard.title);
await routing.inNewTab(
routing.dashboards.row(dashboard.title),
async (tab) => {
expect(tab.url().pathname).toBe(dashboard.path);
await expect(tab.dashboards.title()).toHaveText(dashboard.title);
},
);
expect(routing.url().pathname).toBe(PATH.dashboards);
expect(routing.params()).toMatchObject(dashboardListParams(dashboard.title));
});
});

View File

@@ -0,0 +1,84 @@
import { expect, PATH, test } from '../../drivers/routing';
// Every place a `matchPath` / `generatePath` call decides what renders:
// `RouteTab`, `TopNav`'s per-route checks, and `SETTINGS`, a prefix route that
// needs an explicit `/*` under v7.
test.describe('Routing — route matching', () => {
test('TC-09 RouteTab switches the url and survives a reload', async ({
routing,
}) => {
await routing.goto(PATH.metricsSummary);
await expect(routing.activeRouteTab(PATH.metricsSummary)).toBeVisible();
await routing.routeTab(PATH.metricsViews).click();
await routing.waitForPath(PATH.metricsViews);
await expect(routing.activeRouteTab(PATH.metricsViews)).toBeVisible();
await routing.reload();
expect(routing.url().pathname).toBe(PATH.metricsViews);
await expect(routing.activeRouteTab(PATH.metricsViews)).toBeVisible();
});
test('TC-10 a prefix-route deep link loads with the right tab selected', async ({
authedPage: page,
routing,
}) => {
await routing.goto(PATH.roles);
expect(routing.url().pathname).toBe(PATH.roles);
await expect(page.getByTestId('settings-page-sidenav')).toBeVisible();
await expect(page.getByTestId('roles')).toHaveClass(/active/);
await expect(routing.roles.settings()).toBeVisible();
});
test('TC-11 a seeded dashboard id round-trips byte-identical', async ({
routing,
dashboard,
}) => {
await routing.dashboards.gotoList(dashboard.title);
await routing.dashboards.row(dashboard.title).click();
// Exact match: any re-encoding of the uuid fails here.
await routing.waitForPath(dashboard.path);
await expect(routing.dashboards.title()).toHaveText(dashboard.title);
await routing.reload();
expect(routing.url().pathname).toBe(dashboard.path);
await expect(routing.dashboards.title()).toHaveText(dashboard.title);
});
test('TC-12 the global time picker renders only where matchPath allows it', async ({
routing,
}) => {
await routing.goto(`${PATH.services}?relativeTime=30m`);
await expect(routing.globalTimePicker()).toBeVisible();
// LOGS_EXPLORER is in `routesToDisable`.
await routing.gotoLogsExplorer();
await expect(routing.globalTimePicker()).toHaveCount(0);
// ROLES_SETTINGS is in `routesToSkip`.
await routing.goto(PATH.roles);
await expect(routing.roles.settings()).toBeVisible();
await expect(routing.globalTimePicker()).toHaveCount(0);
});
// `PrivateRoute` redirects a logged-in user on an unknown path to HOME before
// the catch-all runs, so `NotFound` is unreachable while authenticated.
test('TC-13 an unknown path redirects home and replaces its history entry', async ({
authedPage: page,
routing,
}) => {
await routing.dashboards.gotoList();
const depthBefore = await routing.historyDepth();
await routing.goto(PATH.unknown);
await routing.waitForPath(PATH.home);
await expect(page.getByTestId('not-found')).toHaveCount(0);
// The redirect replaced the entry, so one Back reaches the list.
expect(await routing.historyDepth()).toBe(depthBefore + 1);
await routing.goBackTo(PATH.dashboards);
});
});

View File

@@ -0,0 +1,83 @@
import { expect, PATH, test } from '../../drivers/routing';
// The nuqs adapter swap. On the k8s list `groupBy` is written by nuqs straight
// to the History API with `history: 'push'`, while `relativeTime` is written by
// the router through `useSafeNavigate`.
test.describe('Routing — nuqs and router param coexistence', () => {
test('TC-01 a router write keeps the nuqs params it did not author', async ({
routing,
pods,
}) => {
await routing.k8s.goto({ relativeTime: '30m' });
await routing.k8s.groupBy(pods.groupBy);
await routing.selectRelativeTime('1h');
await routing.waitForParam('relativeTime', '1h');
expect(routing.url().pathname).toBe(PATH.k8s);
expect(routing.params()).toMatchObject({
relativeTime: '1h',
category: 'pods',
});
expect(routing.params().groupBy).toContain(pods.groupBy);
await expect(routing.k8s.groupBySelect()).toContainText(pods.groupBy);
});
test('TC-02 a nuqs write keeps the relativeTime the router authored', async ({
routing,
pods,
}) => {
await routing.k8s.goto({ relativeTime: '30m' });
await routing.selectRelativeTime('6h');
await routing.waitForParam('relativeTime', '6h');
await routing.k8s.groupBy(pods.groupBy);
expect(routing.url().pathname).toBe(PATH.k8s);
expect(routing.params().relativeTime).toBe('6h');
expect(routing.params().groupBy).toContain(pods.groupBy);
await routing.expectRelativeTime('6h');
});
test('TC-03 POP across a nuqs push reverts both url and list', async ({
routing,
pods,
}) => {
await routing.k8s.goto({ relativeTime: '30m' });
// One flush, one entry: otherwise one Back lands on an intermediate url.
const depthBeforeGroupBy = await routing.historyDepth();
await routing.k8s.groupBy(pods.groupBy);
expect(await routing.historyDepth()).toBe(depthBeforeGroupBy + 1);
await expect(routing.k8s.groupBySelect()).toContainText(pods.groupBy);
await routing.goBack();
await routing.k8s.waitUntilGrouped(pods.groupBy, false);
expect(routing.url().pathname).toBe(PATH.k8s);
await expect(routing.k8s.groupBySelect()).not.toContainText(pods.groupBy);
await routing.goForward();
await routing.k8s.waitUntilGrouped(pods.groupBy);
await expect(routing.k8s.groupBySelect()).toContainText(pods.groupBy);
});
test('TC-04 a deep link carrying both families applies both on first paint', async ({
routing,
pods,
}) => {
await routing.k8s.goto({
relativeTime: '6h',
groupBy: JSON.stringify([pods.groupBy]),
});
// `k8s.goto` waited for the mount-time republish, so anything it dropped is
// missing by now.
expect(routing.url().pathname).toBe(PATH.k8s);
expect(routing.params().relativeTime).toBe('6h');
expect(routing.params().groupBy).toContain(pods.groupBy);
await routing.expectRelativeTime('6h');
await expect(routing.k8s.groupBySelect()).toContainText(pods.groupBy);
});
});

View File

@@ -18,6 +18,6 @@
"outDir": "./dist",
"rootDir": "."
},
"include": ["tests/**/*.ts", "helpers/**/*.ts", "fixtures/**/*.ts", "bootstrap/**/*.ts", "playwright.config.ts"],
"include": ["tests/**/*.ts", "drivers/**/*.ts", "helpers/**/*.ts", "fixtures/**/*.ts", "bootstrap/**/*.ts", "playwright.config.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -36,28 +36,6 @@ USERS_BASE = "/api/v2/users"
USER_ROLES_BASE = "/api/v2/user_roles"
def _login(signoz: types.SigNoz, email: str, password: str) -> str:
"""Complete GET /sessions/context + POST /sessions/email_password; return accessToken."""
ctx = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/sessions/context"),
params={
"email": email,
"ref": f"{signoz.self.host_configs['8080'].base()}",
},
timeout=5,
)
assert ctx.status_code == HTTPStatus.OK
org_id = ctx.json()["data"]["orgs"][0]["id"]
login = requests.post(
signoz.self.host_configs["8080"].get("/api/v2/sessions/email_password"),
json={"email": email, "password": password, "orgId": org_id},
timeout=5,
)
assert login.status_code == HTTPStatus.OK
return login.json()["data"]["accessToken"]
def register_admin(
signoz: types.SigNoz,
request: pytest.FixtureRequest,
@@ -182,16 +160,14 @@ def get_tokens(signoz: types.SigNoz) -> Callable[[str, str], tuple[str, str]]:
return tokens_getter(signoz)
@pytest.fixture(name="apply_license", scope="package")
def apply_license(
def license_applier(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument,redefined-outer-name
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
cache_key: str = "apply_license",
base_path: str = "",
) -> types.Operation:
"""Stub Zeus license-lookup, then POST /api/v4/licenses so the BE flips
to ENTERPRISE. Package-scoped so an e2e bootstrap can pull it in and
every spec inherits the licensed state."""
"""Apply the ENTERPRISE license and complete org onboarding, under base_path. Reuse-wrapped."""
def create() -> types.Operation:
Config.base_url = signoz.zeus.host_configs["8080"].get("/__admin")
@@ -224,12 +200,12 @@ def apply_license(
)
)
access_token = _login(signoz, USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
access_token = token_getter(signoz, base_path)(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
# 201 = applied, 409 = already applied. Retry transient failures —
# the BE occasionally 5xxs right after startup before the license
# sync goroutine is ready.
license_url = signoz.self.host_configs["8080"].get("/api/v4/licenses")
license_url = signoz.self.host_configs["8080"].get(f"{base_path}/api/v4/licenses")
auth_header = {"Authorization": f"Bearer {access_token}"}
for attempt in range(10):
resp = requests.post(
@@ -248,7 +224,7 @@ def apply_license(
# redirects first-time admins to a questionnaire. Mark the preference
# complete so specs can navigate directly to the feature under test.
pref_resp = requests.put(
signoz.self.host_configs["8080"].get("/api/v1/org/preferences/org_onboarding"),
signoz.self.host_configs["8080"].get(f"{base_path}/api/v1/org/preferences/org_onboarding"),
json={"value": True},
headers=auth_header,
timeout=5,
@@ -265,7 +241,7 @@ def apply_license(
return reuse.wrap(
request,
pytestconfig,
"apply_license",
cache_key,
lambda: types.Operation(name=""),
create,
delete,
@@ -273,6 +249,19 @@ def apply_license(
)
@pytest.fixture(name="apply_license", scope="package")
def apply_license(
signoz: types.SigNoz,
create_user_admin: types.Operation, # pylint: disable=unused-argument,redefined-outer-name
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
) -> types.Operation:
"""Stub Zeus license-lookup, then POST /api/v4/licenses so the BE flips
to ENTERPRISE. Package-scoped so an e2e bootstrap can pull it in and
every spec inherits the licensed state."""
return license_applier(signoz, request, pytestconfig)
# This is not a fixture purposefully, we just want to add a license to the signoz instance.
# This is also idempotent in nature.
def add_license(